Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
88 lines
3.1 KiB
TypeScript
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('monitor-theme', next)
|
|
} catch {
|
|
// 存储不可用时只在本次会话生效
|
|
}
|
|
setTheme(next)
|
|
}, [theme])
|
|
return [theme, toggle]
|
|
}
|
|
|
|
/** 首屏之前调用:按保存的选择或系统偏好设置主题。 */
|
|
export function applyStoredTheme() {
|
|
let theme: string | null = null
|
|
try {
|
|
theme = localStorage.getItem('monitor-theme')
|
|
} catch {
|
|
// 忽略
|
|
}
|
|
const dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
|
}
|