feat(update): 启动静默检查新版本, 侧栏版本号旁 NEW 徽标 — 自 0.3.2 移植共享单例 updateCheck store(24h 缓存+latest.json 兜底), 设置→系统复用同一 hook; 顺带修复设置页检查更新按钮仍指向改名前仓库

This commit is contained in:
shy3130
2026-09-27 18:53:09 +08:00
parent 2acf025652
commit cb4dc820f0
4 changed files with 432 additions and 12 deletions
+32
View File
@@ -16,6 +16,7 @@ import {
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import { useUpdateCheck } from '@/lib/updateCheck'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
@@ -354,6 +355,9 @@ export function Layout() {
const { data: settingsState } = useSettings()
const { data: matrix } = useCapabilityMatrix()
const { data: versionData } = useVersion()
// 更新检查 (单例 store): 启动静默查一次 GitHub Releases, 供左下角版本号徽标
const update = useUpdateCheck()
const hasUpdate = update.status === 'found'
const { data: prefs } = usePreferences()
// 数据源列表 (用于实时行情状态显示当前数据源名称)
const { data: dataSources } = useQuery({
@@ -1017,8 +1021,36 @@ export function Layout() {
{!railMode && version && (
<span className="ml-auto font-mono text-[10px] text-muted/50 select-none shrink-0">
{version}
{hasUpdate && update.info && (
<span
role="button"
tabIndex={0}
title={`发现新版本 ${update.info.latest},点击前往检查更新`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigate('/settings?tab=system')
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
navigate('/settings?tab=system')
}
}}
className="ml-1.5 inline-flex cursor-pointer items-center rounded-full bg-accent px-1.5 py-px text-[9px] font-semibold leading-none text-white transition-colors hover:bg-accent/90"
>
NEW
</span>
)}
</span>
)}
{railMode && hasUpdate && (
<span
className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_6px_rgba(59,130,246,0.8)]"
title="发现新版本"
/>
)}
</>
)}
</NavLink>
+165
View File
@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// useVersion 仅被 useUpdateCheck hook 使用; 打断 react-query 导入链, 单测只测命令式 API
vi.mock('@/lib/useSharedQueries', () => ({
useVersion: () => ({ data: undefined }),
}))
import { checkForUpdate, compareVersion, getUpdateState } from './updateCheck'
const CACHE_KEY = 'update_check_cache'
const DAY = 24 * 60 * 60 * 1000
function seedCache(blob: Record<string, unknown>) {
localStorage.setItem(CACHE_KEY, JSON.stringify(blob))
}
function readCacheRaw(): string | null {
return localStorage.getItem(CACHE_KEY)
}
/** 模拟 GitHub Releases API 响应 (tag_name 可空以触发 latest.json 兜底) */
function mockFetchReleases(tagName: string) {
return vi.fn(async (url: unknown) => {
const u = String(url)
if (u.includes('api.github.com')) {
return { ok: true, json: async () => ({ tag_name: tagName, html_url: 'https://github.com/r/rel/1' }) }
}
if (u.includes('latest.json')) {
return { ok: true, json: async () => ({ tag: tagName, notes_url: 'https://github.com/r/rel/1' }) }
}
return { ok: false, json: async () => ({}) }
})
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('compareVersion', () => {
it('按语义版本比较大小', () => {
expect(compareVersion('0.3.2', '0.3.3')).toBe(-1)
expect(compareVersion('0.4.0', '0.3.9')).toBe(1)
expect(compareVersion('1.0.0', '1.0')).toBe(0)
})
it('容忍 v 前缀与非法输入 (按 0.0.0 处理)', () => {
expect(compareVersion('0.3.2', 'v0.3.3')).toBe(-1)
expect(compareVersion('v1.2.3', '1.2.3')).toBe(0)
expect(compareVersion('', '0.0.1')).toBe(-1)
})
})
describe('checkForUpdate', () => {
it('同版本 24h 内命中缓存, 不发网络请求', async () => {
seedCache({
current: '0.3.2', latest: '0.3.3', url: 'https://github.com/r/rel/9',
found: true, checkedAt: Date.now() - 1000,
})
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await checkForUpdate('0.3.2')
expect(fetchMock).not.toHaveBeenCalled()
expect(getUpdateState().status).toBe('found')
expect(getUpdateState().info?.latest).toBe('0.3.3')
expect(getUpdateState().info?.url).toBe('https://github.com/r/rel/9')
})
it('缓存过期后重新请求并回写缓存', async () => {
seedCache({
current: '0.3.2', latest: '0.3.3', url: 'u',
found: true, checkedAt: Date.now() - DAY - 1000,
})
vi.stubGlobal('fetch', mockFetchReleases('0.9.0'))
await checkForUpdate('0.3.2')
expect(getUpdateState().status).toBe('found')
expect(getUpdateState().info?.latest).toBe('0.9.0')
const cached = JSON.parse(readCacheRaw() ?? '{}')
expect(cached).toMatchObject({ current: '0.3.2', latest: '0.9.0', found: true })
})
it('当前版本已是最新时状态为 latest', async () => {
vi.stubGlobal('fetch', mockFetchReleases('v0.3.2'))
await checkForUpdate('0.3.2')
expect(getUpdateState().status).toBe('latest')
})
it('版本变化后缓存失效, 重新请求', async () => {
seedCache({
current: '0.3.1', latest: '0.3.2', url: 'u',
found: true, checkedAt: Date.now(),
})
const fetchMock = mockFetchReleases('0.3.2')
vi.stubGlobal('fetch', fetchMock)
await checkForUpdate('0.3.2') // 本地版本已升级, 与缓存的 current 不一致
expect(fetchMock).toHaveBeenCalled()
expect(getUpdateState().status).toBe('latest')
})
it('force 绕过新鲜缓存立即刷新', async () => {
seedCache({
current: '0.3.2', latest: '0.9.9', url: 'u',
found: true, checkedAt: Date.now(),
})
const fetchMock = mockFetchReleases('0.3.2')
vi.stubGlobal('fetch', fetchMock)
await checkForUpdate('0.3.2', { force: true })
expect(fetchMock).toHaveBeenCalled()
expect(getUpdateState().status).toBe('latest')
})
it('API 失败时状态 error 且不落缓存 (下次启动重试)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
await checkForUpdate('0.3.2')
expect(getUpdateState().status).toBe('error')
expect(readCacheRaw()).toBeNull()
})
it('GitHub API 字段为空时回退 latest.json 清单', async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url)
if (u.includes('api.github.com')) {
return { ok: true, json: async () => ({}) } // tag_name 缺失
}
if (u.includes('latest.json')) {
return { ok: true, json: async () => ({ tag: '1.0.0', notes_url: 'https://github.com/r/notes' }) }
}
return { ok: false, json: async () => ({}) }
})
vi.stubGlobal('fetch', fetchMock)
await checkForUpdate('0.3.2')
expect(getUpdateState().status).toBe('found')
expect(getUpdateState().info?.latest).toBe('1.0.0')
expect(getUpdateState().info?.url).toBe('https://github.com/r/notes')
})
it('版本未知时空操作 (不请求不落缓存)', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
await checkForUpdate('')
expect(fetchMock).not.toHaveBeenCalled()
expect(getUpdateState().status).not.toBe('checking')
})
})
+187
View File
@@ -0,0 +1,187 @@
/**
* 应用更新检查 — 单例 store (useSyncExternalStore)。
*
* 侧栏左下角版本号徽标与 设置→系统 的「检查更新」共用同一状态与网络请求。
* 静默检查结果按天缓存 localStorage (GitHub 未认证 API 限额 60 次/时):
* 同一版本 24h 内只发一次真实请求; 手动检查 (force) 绕过缓存立即刷新。
* 检查失败不落缓存, 下次启动仍会重试, 但单次会话只静默检查一次。
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react'
import { useVersion } from '@/lib/useSharedQueries'
// 检查更新的目标仓库 (Release 清单来源) — 仓库已由 tickflow-stock-panel 改名,
// 用当前名可少依赖一次 GitHub 的改名重定向
const UPDATE_REPO = 'shy3130/tick-stock-panel'
const CACHE_KEY = 'update_check_cache'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
export type UpdateStatus = 'idle' | 'checking' | 'latest' | 'found' | 'error'
export interface UpdateInfo {
latest: string
url: string
}
interface UpdateState {
status: UpdateStatus
info: UpdateInfo | null
checkedAt: number
}
let state: UpdateState = { status: 'idle', info: null, checkedAt: 0 }
const listeners = new Set<() => void>()
function setState(next: Partial<UpdateState>): void {
state = { ...state, ...next }
listeners.forEach((fn) => fn())
}
function subscribe(fn: () => void): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
export function getUpdateState(): UpdateState {
return state
}
/** 语义版本比较: a<b → -1, 相等 → 0, a>b → 1; 无法解析按 0.0.0 处理 */
export function compareVersion(a: string, b: string): number {
const parse = (v: string) =>
(v.trim().replace(/^v/i, '').match(/\d+/g) ?? []).slice(0, 3).map(Number)
const pa = parse(a)
const pb = parse(b)
for (let i = 0; i < 3; i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
if (d !== 0) return Math.sign(d)
}
return 0
}
interface CacheBlob {
current: string
latest: string
url: string
found: boolean
checkedAt: number
}
function readCache(): CacheBlob | null {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (!raw) return null
const v = JSON.parse(raw) as Partial<CacheBlob>
if (
typeof v.current !== 'string' ||
typeof v.latest !== 'string' ||
typeof v.url !== 'string' ||
typeof v.found !== 'boolean' ||
typeof v.checkedAt !== 'number'
) {
return null
}
return v as CacheBlob
} catch {
return null
}
}
function writeCache(blob: CacheBlob): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(blob))
} catch {
// 隐私模式/存储满: 只影响下次启动多一次请求, 可忽略
}
}
// 优先 api.github.com (官方带 CORS, 浏览器可直连; 未认证限额 60 次/时);
// latest.json 清单 (release.yml 产物) 作兜底 — API 限流或字段变动时仍可取到版本号。
async function fetchLatest(): Promise<UpdateInfo> {
let latest = ''
let url = `https://github.com/${UPDATE_REPO}/releases/latest`
const r = await fetch(`https://api.github.com/repos/${UPDATE_REPO}/releases/latest`, {
cache: 'no-store',
})
if (r.ok) {
const rel = await r.json()
latest = String(rel.tag_name ?? '')
url = rel.html_url || url
}
if (!latest) {
const m = await fetch(
`https://github.com/${UPDATE_REPO}/releases/latest/download/latest.json`,
{ cache: 'no-store' },
)
if (m.ok) {
const manifest = await m.json()
latest = String(manifest.tag ?? '')
url = manifest.notes_url || url
}
}
if (!latest) throw new Error('no release info')
return { latest, url }
}
let inFlight = false
export async function checkForUpdate(
current: string,
opts: { force?: boolean } = {},
): Promise<void> {
const cur = current.trim()
if (!cur || inFlight) return
if (!opts.force) {
const c = readCache()
if (c && c.current === cur && Date.now() - c.checkedAt < CACHE_TTL_MS) {
setState({
status: c.found ? 'found' : 'latest',
info: { latest: c.latest, url: c.url },
checkedAt: c.checkedAt,
})
return
}
}
inFlight = true
setState({ status: 'checking' })
const checkedAt = Date.now()
try {
const info = await fetchLatest()
const found = compareVersion(cur, info.latest) < 0
setState({ status: found ? 'found' : 'latest', info, checkedAt })
writeCache({ current: cur, latest: info.latest, url: info.url, found, checkedAt })
} catch {
// 不落缓存: 下次启动重试 (静默检查每次会话最多一次, 不会打爆限额)
setState({ status: 'error', checkedAt })
} finally {
inFlight = false
}
}
let silentStarted = false
/**
* 侧栏与设置页共用的更新检查 hook。
* 版本号就绪后自动做一次静默检查 (每次会话最多一次);
* `check` 为手动入口, 绕过缓存立即刷新。
*/
export function useUpdateCheck(): {
status: UpdateStatus
info: UpdateInfo | null
checkedAt: number
check: () => void
} {
const s = useSyncExternalStore(subscribe, getUpdateState)
const { data: versionData } = useVersion()
const current = (versionData?.version ?? '').trim()
useEffect(() => {
if (!current || silentStarted) return
silentStarted = true
void checkForUpdate(current)
}, [current])
const check = useCallback(() => {
void checkForUpdate(current, { force: true })
}, [current])
return { status: s.status, info: s.info, checkedAt: s.checkedAt, check }
}
+48 -12
View File
@@ -5,7 +5,7 @@
*/
import { useState, useCallback, useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, ExternalLink } from 'lucide-react'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, ExternalLink, CheckCircle2, Download } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -16,6 +16,7 @@ import {
listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI,
} from '@/lib/voiceBroadcast'
import { loadStockExternalTemplate, saveStockExternalTemplate } from '@/lib/stock-external-link'
import { useUpdateCheck } from '@/lib/updateCheck'
export function SettingsSystemPanel() {
const qc = useQueryClient()
@@ -26,6 +27,13 @@ export function SettingsSystemPanel() {
const screenerAutoRun = prefs?.screener_auto_run ?? true
const [extTpl, setExtTpl] = useState(() => loadStockExternalTemplate())
const [clearing, setClearing] = useState(false)
// ===== 检查更新 (共享单例 store: 与侧栏左下角 NEW 徽标同源, 手动检查绕过缓存) =====
const {
status: updateState,
info: updateInfo,
check: checkUpdate,
} = useUpdateCheck()
const [toastEnabled, setToastEnabled] = useState(() => {
try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true }
})
@@ -363,18 +371,46 @@ export function SettingsSystemPanel() {
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground">检查更新</div>
<div className="text-[11px] text-muted truncate">前往 GitHub Releases 下载最新版本</div>
<div className="text-[11px] text-muted truncate">
{updateState === 'found' && updateInfo
? `发现新版本 ${updateInfo.latest} (当前 ${versionData?.version ?? '—'})`
: updateState === 'latest'
? `已是最新版本 (${versionData?.version ?? '—'})`
: updateState === 'error'
? '检查失败 (网络受限?), 可直接前往 Releases'
: '对比 GitHub Releases 最新 Release, 提示新版本'}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{updateState === 'found' && updateInfo && (
<a
href={updateInfo.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-accent text-white hover:bg-accent/90 transition-colors"
>
<Download className="h-3.5 w-3.5" />
前往下载
</a>
)}
<button
onClick={checkUpdate}
disabled={updateState === 'checking'}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors
disabled:opacity-50"
>
{updateState === 'checking' ? (
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
) : updateState === 'latest' ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{updateState === 'checking' ? '检查中…' : updateState === 'latest' ? '已检查' : '检查更新'}
</button>
</div>
<a
href="https://github.com/shy3130/tickflow-stock-panel/releases/latest"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
bg-elevated text-secondary hover:text-foreground transition-colors shrink-0"
>
<RefreshCw className="h-3.5 w-3.5" />
检查更新
</a>
</div>
</section>
</>