diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 008c4e7..47fc703 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,7 @@ import { ApiException, type ApiError } from './types' const BASE = '' // 同源;dev 走 vite proxy +const DEFAULT_TIMEOUT_MS = 30_000 interface RequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' @@ -27,29 +28,43 @@ export async function request(path: string, opts: RequestOptions = {}): Promi const token = authTokenProvider() if (token) headers['Authorization'] = `Bearer ${token}` - const resp = await fetch(BASE + path, { - method: opts.method || 'GET', - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - signal: opts.signal, - }) - - if (resp.status === 401) { - onUnauthorized() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS) + if (opts.signal) { + if (opts.signal.aborted) { + controller.abort() + } else { + opts.signal.addEventListener('abort', () => controller.abort(), { once: true }) + } } - if (resp.status === 204) { - return undefined as T - } + try { + const resp = await fetch(BASE + path, { + method: opts.method || 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: controller.signal, + }) - const text = await resp.text() - const data = text ? JSON.parse(text) : null + if (resp.status === 401) { + onUnauthorized() + } - if (!resp.ok) { - throw new ApiException( - resp.status, - (data as ApiError) || { code: 'unknown', message: resp.statusText }, - ) + if (resp.status === 204) { + return undefined as T + } + + const text = await resp.text() + const data = text ? JSON.parse(text) : null + + if (!resp.ok) { + throw new ApiException( + resp.status, + (data as ApiError) || { code: 'unknown', message: resp.statusText }, + ) + } + return data as T + } finally { + clearTimeout(timer) } - return data as T } diff --git a/web/src/composables/useNotifications.ts b/web/src/composables/useNotifications.ts index 0418480..0e24ccb 100644 --- a/web/src/composables/useNotifications.ts +++ b/web/src/composables/useNotifications.ts @@ -18,13 +18,35 @@ export function useUnreadPoller() { } } - onMounted(() => { + function start() { + if (timer !== null) return refresh() timer = setInterval(refresh, POLL_INTERVAL_MS) + } + + function stop() { + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + + function onVisibilityChange() { + if (document.hidden) { + stop() + } else { + start() + } + } + + onMounted(() => { + start() + document.addEventListener('visibilitychange', onVisibilityChange) }) onUnmounted(() => { - if (timer) clearInterval(timer) + document.removeEventListener('visibilitychange', onVisibilityChange) + stop() }) return { refresh, error } diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 2319e6e..14b0162 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -8,26 +8,64 @@ export interface User { is_admin: boolean } +function safeReadJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key) + if (raw === null) return fallback + return JSON.parse(raw) as T + } catch { + // 脏数据 / 反序列化失败 → 清掉,回退默认。 + try { + localStorage.removeItem(key) + } catch { + /* noop */ + } + return fallback + } +} + +function safeReadString(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function safeWrite(key: string, value: string) { + try { + localStorage.setItem(key, value) + } catch { + /* QuotaExceeded / disabled */ + } +} + +function safeRemove(key: string) { + try { + localStorage.removeItem(key) + } catch { + /* noop */ + } +} + export const useAuthStore = defineStore('auth', () => { - const token = ref(localStorage.getItem('token')) - const user = ref( - JSON.parse(localStorage.getItem('user') || 'null') as User | null, - ) + const token = ref(safeReadString('token')) + const user = ref(safeReadJSON('user', null)) const isAuthenticated = computed(() => !!token.value) function setSession(t: string, u: User) { token.value = t user.value = u - localStorage.setItem('token', t) - localStorage.setItem('user', JSON.stringify(u)) + safeWrite('token', t) + safeWrite('user', JSON.stringify(u)) } function clearSession() { token.value = null user.value = null - localStorage.removeItem('token') - localStorage.removeItem('user') + safeRemove('token') + safeRemove('user') } return { token, user, isAuthenticated, setSession, clearSession } diff --git a/web/src/stores/ui.ts b/web/src/stores/ui.ts index 1a823c8..8b51b2d 100644 --- a/web/src/stores/ui.ts +++ b/web/src/stores/ui.ts @@ -3,15 +3,34 @@ import { computed, ref, watch } from 'vue' type ThemeMode = 'light' | 'dark' | 'system' -export const useUIStore = defineStore('ui', () => { - const themeMode = ref( - (localStorage.getItem('themeMode') as ThemeMode) || 'system', - ) - const systemDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches) +function safeReadString(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} - window - .matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', (e) => (systemDark.value = e.matches)) +function safeWrite(key: string, value: string) { + try { + localStorage.setItem(key, value) + } catch { + /* QuotaExceeded / disabled */ + } +} + +export const useUIStore = defineStore('ui', () => { + const storedTheme = safeReadString('themeMode') as ThemeMode | null + const themeMode = ref(storedTheme || 'system') + + const mediaQuery = + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + const systemDark = ref(mediaQuery ? mediaQuery.matches : false) + if (mediaQuery) { + mediaQuery.addEventListener('change', (e) => (systemDark.value = e.matches)) + } const effectiveDark = computed(() => themeMode.value === 'system' ? systemDark.value : themeMode.value === 'dark', @@ -25,13 +44,11 @@ export const useUIStore = defineStore('ui', () => { function setThemeMode(mode: ThemeMode) { themeMode.value = mode - localStorage.setItem('themeMode', mode) + safeWrite('themeMode', mode) } - const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true') - watch(sidebarCollapsed, (v) => - localStorage.setItem('sidebarCollapsed', String(v)), - ) + const sidebarCollapsed = ref(safeReadString('sidebarCollapsed') === 'true') + watch(sidebarCollapsed, (v) => safeWrite('sidebarCollapsed', String(v))) return { themeMode, effectiveDark, setThemeMode, sidebarCollapsed } }) diff --git a/web/test/api-client.test.ts b/web/test/api-client.test.ts index 9e8f07e..2df4dd1 100644 --- a/web/test/api-client.test.ts +++ b/web/test/api-client.test.ts @@ -58,4 +58,79 @@ describe('api client', () => { expect(ex.body.code).toBe('invalid_input') } }) + + it('schedules a default 30s timeout and forwards abort to fetch', async () => { + // Replace setTimeout with a stub that captures the scheduled callback, + // so we can assert the default-timeout scheduling and trigger it manually + // without waiting wall-clock 30 seconds. + const captured: Array<{ cb: () => void; ms: number }> = [] + const realSetTimeout = globalThis.setTimeout + vi.stubGlobal('setTimeout', ((cb: () => void, ms?: number, ...args: unknown[]) => { + if (ms === 30_000) { + captured.push({ cb, ms }) + return 0 as unknown as ReturnType + } + return realSetTimeout(cb, ms, ...args) + }) as typeof setTimeout) + + try { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + const sig = init?.signal as AbortSignal | undefined + sig?.addEventListener('abort', () => { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + }) + }), + ) + + let rejected: unknown = null + const promise = request('/api/test').catch((e) => { + rejected = e + }) + + // Allow the request to register its setTimeout + start the fetch. + await Promise.resolve() + + expect(captured.length).toBe(1) + expect(captured[0].ms).toBe(30_000) + + // Fire the captured timeout callback to simulate elapsed 30s. + captured[0].cb() + await promise + + expect(rejected).not.toBeNull() + const init = fetchMock.mock.calls[0][1] as RequestInit + expect((init.signal as AbortSignal).aborted).toBe(true) + } finally { + vi.unstubAllGlobals() + } + }) + + it('forwards a caller-supplied signal abort to the fetch', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation( + (_input, init) => + new Promise((_resolve, reject) => { + const sig = init?.signal as AbortSignal | undefined + if (sig) { + sig.addEventListener('abort', () => { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + }) + } + }), + ) + + const ctl = new AbortController() + const promise = request('/api/test', { signal: ctl.signal }) + const settled = expect(promise).rejects.toThrow() + ctl.abort() + await settled + + const init = fetchMock.mock.calls[0][1] as RequestInit + expect((init.signal as AbortSignal).aborted).toBe(true) + }) })