mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat(update): 启动静默检查新版本, 侧栏左下角版本号旁显示 NEW 徽标 — 共享单例 updateCheck store(24h 本地缓存+latest.json 兜底), 设置→系统检查更新同源复用, 仓库地址修正为改名后名称
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -16,19 +16,7 @@ import {
|
||||
listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI,
|
||||
} from '@/lib/voiceBroadcast'
|
||||
import { loadStockExternalTemplate, saveStockExternalTemplate } from '@/lib/stock-external-link'
|
||||
|
||||
// 检查更新的目标仓库 (Release 清单来源)
|
||||
const UPDATE_REPO = 'shy3130/tickflow-stock-panel'
|
||||
|
||||
/** 语义版本比较: a<b → -1, 相等 → 0, a>b → 1; 无法解析按 0.0.0 处理 */
|
||||
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 [a1 = 0, a2 = 0, a3 = 0] = parse(a)
|
||||
const [b1 = 0, b2 = 0, b3 = 0] = parse(b)
|
||||
if (a1 !== b1) return Math.sign(a1 - b1)
|
||||
if (a2 !== b2) return Math.sign(a2 - b2)
|
||||
return Math.sign(a3 - b3)
|
||||
}
|
||||
import { useUpdateCheck } from '@/lib/updateCheck'
|
||||
|
||||
export function SettingsSystemPanel() {
|
||||
const qc = useQueryClient()
|
||||
@@ -40,38 +28,12 @@ export function SettingsSystemPanel() {
|
||||
const [extTpl, setExtTpl] = useState(() => loadStockExternalTemplate())
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
// ===== 检查更新 =====
|
||||
// 优先 api.github.com (官方带 CORS, 浏览器可直连; 未认证限额 60 次/时, 手动触发足够);
|
||||
// latest.json 清单 (release.yml 产物) 作兜底 — API 限流或字段变动时仍可取到版本号。
|
||||
const [updateState, setUpdateState] = useState<'idle' | 'checking' | 'latest' | 'found' | 'error'>('idle')
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null)
|
||||
const checkUpdate = useCallback(async () => {
|
||||
const current = (versionData?.version ?? '').trim()
|
||||
setUpdateState('checking')
|
||||
try {
|
||||
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')
|
||||
setUpdateInfo({ latest, url })
|
||||
setUpdateState(compareVersion(current, latest) < 0 ? 'found' : 'latest')
|
||||
} catch {
|
||||
setUpdateState('error')
|
||||
}
|
||||
}, [versionData?.version])
|
||||
// ===== 检查更新 (共享单例 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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user