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.
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('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'
|
|
}
|