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> interface ResolvedErrorContent extends Required> { actionHref?: string codeLabel: string } const DEFAULT_CONTENT: Record = { 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 = { 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 = { 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 = ( ) return ( ) } 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 ( props.onError?.(normalizeError(raw))} fallback={(raw: unknown, reset: () => void) => props.fallback?.(normalizeError(raw), () => retry(reset, normalizeError(raw))) ?? ( retry(reset, normalizeError(raw))} /> ) } > {() => props.children} ) }