266 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|