Files
MoShiro/web/shared/hooks.ts
T
justme 83e5120377 refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries,
images, systemd units, config paths, environment variables, protocol method
names, the media type and the raw chunk magic all move to MoShiro/moshiro.
Existing data and deployments are not compatible and must be recreated.
Also fix the architecture tests after the solution layout change and make
the update tests independent of their order.
2026-10-07 01:49:04 +08:00

88 lines
3.1 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError } from './api/client'
export interface QueryState<T> {
data: T | undefined
error: ApiError | Error | undefined
loading: boolean
/** 正在加载,而且不是定时刷新引起的(第一次加载、换了条件):用来显示进度条,定时刷新不打扰用户。 */
busy: boolean
reload: () => void
}
/** 取数:依赖变化时重新取;可选轮询。重新取数时保留上一次的数据,避免界面闪烁。 */
export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs?: number): QueryState<T> {
const [data, setData] = useState<T>()
const [error, setError] = useState<ApiError | Error>()
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<Theme>(() => (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'
}