live-sfu-demo/web/src/components/error-boundary.tsx

266 lines
8.6 KiB
TypeScript

import { ErrorBoundary, Show, createSignal } from 'solid-js'
import type { JSX } from 'solid-js'
import { CircleAlert, LogIn, RotateCw } from 'lucide-solid'
import { normalizeError } from '../lib/errors'
import type { AppError } from '../lib/errors'
export type ErrorSeverity = 'danger' | 'warning' | 'info'
export interface ErrorContent {
title: string
description: string
severity?: ErrorSeverity
actionLabel?: string
actionHref?: string
}
export type ErrorContentMap = Record<string | number, Partial<ErrorContent>>
interface ResolvedErrorContent extends Required<Omit<ErrorContent, 'actionHref'>> {
actionHref?: string
codeLabel: string
}
const DEFAULT_CONTENT: Record<string, ErrorContent> = {
UNAUTHENTICATED: {
title: '登录已过期',
description: '请重新登录后再继续操作。',
severity: 'warning',
actionLabel: '去登录',
actionHref: '/login',
},
TOKEN_EXPIRED: {
title: '登录状态失效',
description: '当前凭证已过期,请重新登录。',
severity: 'warning',
actionLabel: '重新登录',
actionHref: '/login',
},
PERMISSION_DENIED: {
title: '没有访问权限',
description: '你的账号角色无法使用这个功能。',
severity: 'warning',
},
ACCESS_DENIED: {
title: '没有访问权限',
description: '请联系管理员获取所需权限。',
severity: 'warning',
},
ACCOUNT_BANNED: {
title: '账号不可用',
description: '账号已被限制使用,请联系管理员处理。',
severity: 'warning',
},
NOT_FOUND: {
title: '内容不存在',
description: '请求的资源可能已被删除或从未存在。',
severity: 'info',
},
VALIDATION_ERROR: {
title: '提交内容有误',
description: '请检查输入内容后重试。',
severity: 'info',
},
RATE_LIMITED: {
title: '操作过于频繁',
description: '请稍等片刻后再试。',
severity: 'warning',
},
UPSTREAM_ERROR: {
title: '上游服务异常',
description: '依赖的服务暂时不可用,请稍后重试。',
},
INTERNAL_ERROR: {
title: '服务暂时不可用',
description: '服务器处理请求时出现问题,请稍后重试。',
},
NETWORK_ERROR: {
title: '网络连接异常',
description: '请检查网络连接后重试。',
},
RUNTIME_ERROR: {
title: '页面渲染出错',
description: '前端执行时发生异常,可以尝试重新加载该区域。',
severity: 'info',
},
UNKNOWN: {
title: '出现未知错误',
description: '操作未能完成,请重试。',
},
}
const STATUS_CONTENT: Record<number, keyof typeof DEFAULT_CONTENT> = {
400: 'VALIDATION_ERROR',
401: 'UNAUTHENTICATED',
403: 'PERMISSION_DENIED',
404: 'NOT_FOUND',
408: 'NETWORK_ERROR',
429: 'RATE_LIMITED',
500: 'INTERNAL_ERROR',
502: 'UPSTREAM_ERROR',
503: 'UPSTREAM_ERROR',
504: 'UPSTREAM_ERROR',
}
const SEVERITY_CLASS: Record<ErrorSeverity, string> = {
danger: 'border-destructive/40 bg-destructive/10 text-destructive',
warning: 'border-warning/40 bg-warning/10 text-warning',
info: 'border-acid-cyan/40 bg-acid-cyan/10 text-accent-foreground',
}
function resolveContent(
error: AppError,
overrides: ErrorContentMap | undefined,
): ResolvedErrorContent {
const explicitKey = error.code
const upperKey = explicitKey?.toUpperCase()
const override = explicitKey === undefined ? undefined : overrides?.[explicitKey]
const upperOverride = upperKey === undefined ? undefined : overrides?.[upperKey]
const statusName = error.httpStatus === undefined ? undefined : STATUS_CONTENT[error.httpStatus]
const base =
(explicitKey === undefined ? undefined : DEFAULT_CONTENT[explicitKey]) ??
(upperKey === undefined ? undefined : DEFAULT_CONTENT[upperKey]) ??
(statusName === undefined ? undefined : DEFAULT_CONTENT[statusName]) ??
(error.source === 'network' ? DEFAULT_CONTENT.NETWORK_ERROR : undefined) ??
(error.source === 'runtime' ? DEFAULT_CONTENT.RUNTIME_ERROR : undefined) ??
DEFAULT_CONTENT.UNKNOWN
return {
title: upperOverride?.title ?? override?.title ?? base.title,
description: upperOverride?.description ?? override?.description ?? base.description,
severity:
upperOverride?.severity ??
override?.severity ??
base.severity ??
(error.source === 'runtime' ? 'info' : 'danger'),
actionLabel:
upperOverride?.actionLabel ?? override?.actionLabel ?? base.actionLabel ?? '重试',
actionHref: upperOverride?.actionHref ?? override?.actionHref ?? base.actionHref,
codeLabel: explicitKey ?? (error.httpStatus === undefined ? 'RUNTIME' : `HTTP ${error.httpStatus}`),
}
}
function formatDetail(error: AppError): string {
const lines = [`code: ${error.code ?? '-'}`, `source: ${error.source}`, `message: ${error.message}`]
if (error.httpStatus !== undefined) lines.splice(1, 0, `http_status: ${error.httpStatus}`)
if (error.raw instanceof Error && error.raw.stack) lines.push('', error.raw.stack)
else if (error.raw !== undefined && !(error.raw instanceof Error)) {
try {
lines.push('', JSON.stringify(error.raw, null, 2))
} catch {
// 循环引用等序列化失败时不需要阻塞错误展示。
}
}
return lines.join('\n')
}
export function AppErrorView(props: {
error: unknown
onRetry?: () => void
contentOverrides?: ErrorContentMap
class?: string
}) {
const error = normalizeError(props.error)
const content = resolveContent(error, props.contentOverrides)
const Icon = CircleAlert
const retryButton = (
<button
type="button"
class="btn btn-outline btn-sm"
onClick={() => props.onRetry?.()}
disabled={!props.onRetry}
>
<RotateCw class="size-3.5" />
重试
</button>
)
return (
<div
role="alert"
class={`card flex min-h-52 w-full flex-col justify-center gap-4 p-5 sm:p-7 ${
props.class ?? ''
}`}
>
<div class="flex min-w-0 items-start gap-3">
<span class={`flex size-9 shrink-0 items-center justify-center border ${SEVERITY_CLASS[content.severity]}`}>
<Icon class="size-4" />
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 class="display text-lg leading-tight sm:text-xl">{content.title}</h2>
<span class="badge border-line bg-card/70 text-muted-foreground">{content.codeLabel}</span>
</div>
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground">
{content.description}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show
when={content.actionHref}
fallback={retryButton}
>
<a class="btn btn-primary btn-sm" href={content.actionHref}>
<LogIn class="size-3.5" />
{content.actionLabel}
</a>
{retryButton}
</Show>
</div>
<details class="max-w-3xl border border-line/70 bg-background/50">
<summary class="cursor-pointer px-3 py-2 font-mono text-[11px] tracking-[0.14em] text-muted-foreground uppercase select-none hover:text-foreground">
错误详情
</summary>
<pre class="overflow-auto border-t border-line/70 px-3 py-2.5 text-xs whitespace-pre-wrap break-words text-muted-foreground">
{formatDetail(error)}
</pre>
</details>
</div>
)
}
export function AppErrorBoundary(props: {
children: JSX.Element
/** 按后端业务码覆盖标题、描述、动作或严重级别。 */
contentByCode?: ErrorContentMap
/** 重试前可用来清空本地状态或触发 query refetch。 */
onRetry?: (error: AppError) => void
fallback?: (error: AppError, retry: () => void) => JSX.Element
class?: string
}) {
// 改变 key 能让子树重建,避免复用已经处于异常渲染状态的节点。
const [attempt, setAttempt] = createSignal(1)
const retry = (reset: () => void, error: AppError) => {
props.onRetry?.(error)
setAttempt((value) => value + 1)
reset()
}
return (
<ErrorBoundary
onError={(raw: unknown) => props.onError?.(normalizeError(raw))}
fallback={(raw: unknown, reset: () => void) =>
props.fallback?.(normalizeError(raw), () => retry(reset, normalizeError(raw))) ?? (
<AppErrorView
error={raw}
class={props.class}
contentOverrides={props.contentByCode}
onRetry={() => retry(reset, normalizeError(raw))}
/>
)
}
>
<Show when={attempt()} keyed>
{() => props.children}
</Show>
</ErrorBoundary>
)
}