f0a1552
ZhenYi
feat(chat): add chat module with room management, message handling, and session revocation
f0a1552
import type { ReactNode } from 'react'
import { Loader2 } from 'lucide-react'
import { API_PROBE_INTERVAL_MS, retryApiHealth, useApiHealth, useApiUnavailable } from '@/shared/api'
import { Button } from '@/shared/ui'
/**
* App-wide「api error」intermediate state.
*
* `ApiErrorGate` sits above the router: while the rolling failure rate stays
* above the threshold in `@/shared/api/health` it replaces the whole tree with
* the outage screen, so a dead backend reads as one clear page instead of a
* grid of half-rendered panels each showing its own error.
*
* Unmounting is also the recovery mechanism: the health store probes the
* service on its own, and as soon as one request answers, the tree remounts and
* every view refetches through `useAsync` — no document reload involved.
*/
export function ApiErrorGate({ children }: { children: ReactNode }) {
const unavailable = useApiUnavailable()
if (unavailable) return <ServiceUnavailableScreen />
return <>{children}</>
}
/** Failure reason in words: status 0 / transport error means "no answer". */
function failureLabel(status: number | null): string {
if (status === null) return '无响应'
if (status === 0) return '网络错误(无响应)'
if (status >= 500) return `HTTP ${status} · 服务端错误`
return `HTTP ${status}`
}
function ServiceUnavailableScreen() {
const {
failures,
samples,
errorRate,
burstFailures,
lastFailureStatus,
lastFailureUrl,
probing,
lastProbeAt,
} = useApiHealth()
const percent = Math.round(errorRate * 100)
const path = lastFailureUrl ? lastFailureUrl.replace(/^https?:\/\/[^/]+/, '') : null
return (
<main
role="alert"
aria-live="assertive"
className="flex h-dvh w-full flex-col items-center justify-center gap-5 overflow-y-auto bg-shell-surface px-6 py-10 text-center text-shell-ink"
>
<img
src="/wronged.png"
alt="服务暂不可用"
draggable={false}
className="max-h-[38vh] w-auto max-w-[min(420px,72vw)] shrink-0 select-none object-contain"
/>
<div className="flex max-w-md flex-col gap-1.5">
<h1 className="text-[17px] font-semibold tracking-tight">服务暂不可用</h1>
<p className="text-[13px] leading-relaxed text-shell-ink-muted">
接口调用持续失败(连续 {burstFailures} 次请求无成功响应),页面已暂停渲染。
请确认后端服务是否已启动。
</p>
</div>
<p className="max-w-[min(560px,92vw)] truncate font-mono text-[11px] text-shell-ink-muted">
{`最近一次失败:${failureLabel(lastFailureStatus)}${path ? ` · ${path}` : ''} | 窗口失败率 ${percent}%(${failures}/${samples})`}
</p>
<div className="justify-self-center">
<Button onClick={retryApiHealth} disabled={probing} className="min-w-32">
{probing ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
{probing ? '连接中…' : '重试连接'}
</Button>
</div>
<p className="text-[11px] text-shell-ink-muted">
每 {Math.round(API_PROBE_INTERVAL_MS / 1000)}s 自动探测一次
{lastProbeAt ? ` · 上次 ${new Date(lastProbeAt).toLocaleTimeString([], { hour12: false })}` : ''}
</p>
</main>
)
}