import { useCallback, useEffect, useRef, useState } from 'react' import { ApiError } from './api/client' export interface QueryState { data: T | undefined error: ApiError | Error | undefined loading: boolean /** 正在加载,而且不是定时刷新引起的(第一次加载、换了条件):用来显示进度条,定时刷新不打扰用户。 */ busy: boolean reload: () => void } /** 取数:依赖变化时重新取;可选轮询。重新取数时保留上一次的数据,避免界面闪烁。 */ export function useQuery(fetcher: () => Promise, deps: unknown[], intervalMs?: number): QueryState { const [data, setData] = useState() const [error, setError] = useState() const [loading, setLoading] = useState(true) const [tick, setTick] = useState(0) const [silent, setSilent] = useState(false) const lastTick = useRef(0) const fetcherRef = useRef(fetcher) fetcherRef.current = fetcher useEffect(() => { let alive = true // 只有 tick 变了(定时刷新、reload)才算静默刷新;条件变了是用户能感觉到的加载 setSilent(lastTick.current !== tick) lastTick.current = tick setLoading(true) fetcherRef .current() .then((value) => { if (!alive) return // 轮询取到一模一样的数据时保持原来的对象,依赖它的图表不会白白重建(那是闪烁的来源) setData((previous) => (previous !== undefined && JSON.stringify(previous) === JSON.stringify(value) ? previous : value)) setError(undefined) }) .catch((e: unknown) => { if (alive) setError(e instanceof Error ? e : new Error(String(e))) }) .finally(() => { if (alive) setLoading(false) }) return () => { alive = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, [...deps, tick]) useEffect(() => { if (!intervalMs) return const id = window.setInterval(() => setTick((t) => t + 1), intervalMs) return () => window.clearInterval(id) }, [intervalMs]) const reload = useCallback(() => setTick((t) => t + 1), []) return { data, error, loading, busy: loading && !silent, reload } } export type Theme = 'light' | 'dark' export function useTheme(): [Theme, () => void] { const [theme, setTheme] = useState(() => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')) const toggle = useCallback(() => { const next: Theme = theme === 'dark' ? 'light' : 'dark' document.documentElement.dataset.theme = next try { localStorage.setItem('moshiro-theme', next) } catch { // 存储不可用时只在本次会话生效 } setTheme(next) }, [theme]) return [theme, toggle] } /** 首屏之前调用:按保存的选择或系统偏好设置主题。 */ export function applyStoredTheme() { let theme: string | null = null try { theme = localStorage.getItem('moshiro-theme') } catch { // 忽略 } const dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches document.documentElement.dataset.theme = dark ? 'dark' : 'light' }