feat: 统一错误处理体系 — ErrorBoundary + toastError
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 13s

- 新建 ErrorBoundary 组件,捕获渲染崩溃,避免白屏
- 新建 error-handler.ts,统一错误格式化 + toast 提示
- App.tsx: ErrorBoundary 包裹路由 + QueryClient mutation onError
- TaskAssistant.tsx: 修复 5 处空 catch {} 吞错
- ReportingAdmin / VectorAdmin: 替换空 catch
- antd-global.ts: 自动注册 message 到 error-handler

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-07-04 11:09:29 +08:00
parent 91698a2f3b
commit 832a7f7960
7 changed files with 122 additions and 11 deletions

View File

@ -9,6 +9,8 @@ import { AuthProvider } from './contexts/AuthContext'
import AuthGuard from './components/AuthGuard' import AuthGuard from './components/AuthGuard'
import PermissionGuard from './components/PermissionGuard' import PermissionGuard from './components/PermissionGuard'
import PageLoading from './components/PageLoading' import PageLoading from './components/PageLoading'
import { ErrorBoundary } from './components/ErrorBoundary'
import { toastError } from './lib/error-handler'
import AdminLayout from './layouts/AdminLayout' import AdminLayout from './layouts/AdminLayout'
const Login = lazy(() => import('./pages/Login')) const Login = lazy(() => import('./pages/Login'))
@ -60,7 +62,13 @@ const BackupAdmin = lazy(() => import('./pages/BackupAdmin'))
const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources')) const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources'))
const ReportingAdmin = lazy(() => import('./pages/ReportingAdmin')) const ReportingAdmin = lazy(() => import('./pages/ReportingAdmin'))
const queryClient = new QueryClient() const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => { toastError(error) },
},
},
})
function App() { function App() {
return ( return (
@ -71,6 +79,7 @@ function App() {
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
<ErrorBoundary>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/403" element={<ForbiddenPage />} /> <Route path="/403" element={<ForbiddenPage />} />
@ -216,6 +225,7 @@ function App() {
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</ErrorBoundary>
</Suspense> </Suspense>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>

View File

@ -0,0 +1,64 @@
import { Component, type ReactNode } from 'react'
import { Button, Result } from 'antd'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error('[ErrorBoundary]', error, info.componentStack)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
const isDev = import.meta.env.DEV
return (
<Result
status="500"
title="页面出错了"
subTitle={isDev ? this.state.error?.message : '请尝试刷新页面'}
extra={[
<Button type="primary" key="reload" onClick={() => window.location.reload()}>
</Button>,
<Button key="home" onClick={() => { window.location.href = '/' }}>
</Button>,
isDev && (
<Button key="reset" onClick={this.handleReset}>
</Button>
),
]}
>
{isDev && this.state.error && (
<pre style={{ textAlign: 'left', background: '#f5f5f5', padding: 16, borderRadius: 8, maxHeight: 200, overflow: 'auto', fontSize: 12, marginTop: 16 }}>
{this.state.error.stack || this.state.error.message}
</pre>
)}
</Result>
)
}
return this.props.children
}
}

View File

@ -1,6 +1,7 @@
import { App } from 'antd' import { App } from 'antd'
import type { MessageInstance } from 'antd/es/message/interface' import type { MessageInstance } from 'antd/es/message/interface'
import type { NotificationInstance } from 'antd/es/notification/interface' import type { NotificationInstance } from 'antd/es/notification/interface'
import { setErrorMessageFn } from './error-handler'
let message: MessageInstance | null = null let message: MessageInstance | null = null
let notification: NotificationInstance | null = null let notification: NotificationInstance | null = null
@ -10,6 +11,7 @@ export function StaticAntdProvider() {
const staticFunc = App.useApp() const staticFunc = App.useApp()
message = staticFunc.message message = staticFunc.message
notification = staticFunc.notification notification = staticFunc.notification
setErrorMessageFn((msg: string) => staticFunc.message.error(msg))
return null return null
} }

32
src/lib/error-handler.ts Normal file
View File

@ -0,0 +1,32 @@
/**
*
*
* try { ... } catch (err) { toastError(err) }
*/
// ── 错误格式化 ──
export function formatError(err: unknown, fallback = '操作失败'): string {
if (!err) return fallback
if (typeof err === 'string') return err
if (err instanceof Error) return err.message
if (typeof err === 'object' && err !== null) {
const e = err as Record<string, unknown>
return String(e.message || e.error || e.msg || fallback)
}
return fallback
}
// ── Toast 错误 ──
let _messageFn: ((msg: string) => void) | null = null
export function setErrorMessageFn(fn: (msg: string) => void) {
_messageFn = fn
}
export function toastError(err: unknown, fallback = '操作失败'): void {
const msg = formatError(err, fallback)
console.error('[Admin]', msg, err instanceof Error ? err : undefined)
_messageFn?.call(null, msg)
}

View File

@ -1,7 +1,8 @@
import { Card, Button, Space, Typography, Table, message, Select } from 'antd' import { Card, Button, Space, Typography, Table, Select } from 'antd'
import { DownloadOutlined } from '@ant-design/icons' import { DownloadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { reportingAPI } from '@/api' import { reportingAPI } from '@/api'
import { toastError } from '@/lib/error-handler'
import { useState } from 'react' import { useState } from 'react'
const { Title } = Typography const { Title } = Typography
@ -26,8 +27,8 @@ export default function ReportingAdmin() {
try { try {
const text = await reportingAPI.exportCSV(type, String(days)) const text = await reportingAPI.exportCSV(type, String(days))
downloadBlob(text as string, `${type}-report-${days}d.csv`) downloadBlob(text as string, `${type}-report-${days}d.csv`)
} catch { } catch (err) {
message.error('导出失败') toastError(err, '导出失败')
} }
} }

View File

@ -4,6 +4,7 @@ import { PlusOutlined, ToolOutlined, DeleteOutlined, MessageOutlined, ArrowUpOut
import { aiAPI, conversationsAPI, type Conversation } from '@/api' import { aiAPI, conversationsAPI, type Conversation } from '@/api'
import { streamChat, type StreamEvent } from '@/services/ai-chat' import { streamChat, type StreamEvent } from '@/services/ai-chat'
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
import { toastError } from '@/lib/error-handler'
const { Text } = Typography const { Text } = Typography
@ -29,7 +30,7 @@ function ChatPage() {
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const editInputRef = useRef<any>(null) const editInputRef = useRef<any>(null)
const loadConversations = useCallback(async () => { try { setConversations(await conversationsAPI.list()) } catch {} }, []) const loadConversations = useCallback(async () => { try { setConversations(await conversationsAPI.list()) } catch (err) { toastError(err, '加载对话列表失败') } }, [])
useEffect(() => { loadConversations() }, [loadConversations]) useEffect(() => { loadConversations() }, [loadConversations])
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations]) useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations])
@ -37,23 +38,23 @@ function ChatPage() {
const switchConversation = useCallback(async (id: string) => { const switchConversation = useCallback(async (id: string) => {
if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) } if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) }
setActiveId(id); setMessages([]) setActiveId(id); setMessages([])
try { const records = await conversationsAPI.messages(id); setMessages(records.map(m => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content, timestamp: new Date(m.createdAt).getTime() }))) } catch {} try { const records = await conversationsAPI.messages(id); setMessages(records.map(m => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content, timestamp: new Date(m.createdAt).getTime() }))) } catch (err) { toastError(err, '加载消息失败') }
}, [streaming]) }, [streaming])
const handleNew = async () => { const handleNew = async () => {
if (streaming) { abortRef.current?.abort(); setStreaming(false) } if (streaming) { abortRef.current?.abort(); setStreaming(false) }
try { const conv = await conversationsAPI.create(); setConversations(prev => [conv, ...prev]); setActiveId(conv.id); setMessages([]); setInput('') } catch {} try { const conv = await conversationsAPI.create(); setConversations(prev => [conv, ...prev]); setActiveId(conv.id); setMessages([]); setInput('') } catch (err) { toastError(err, '创建对话失败') }
} }
const handleDelete = (id: string) => modal.confirm({ const handleDelete = (id: string) => modal.confirm({
title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消', title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消',
onOk: async () => { try { await conversationsAPI.remove(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch {} }, onOk: async () => { try { await conversationsAPI.remove(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch (err) { toastError(err, '删除对话失败') } },
}) })
const startEdit = (conv: Conversation) => { setEditingId(conv.id); setEditTitle(conv.title); setTimeout(() => editInputRef.current?.focus(), 50) } const startEdit = (conv: Conversation) => { setEditingId(conv.id); setEditTitle(conv.title); setTimeout(() => editInputRef.current?.focus(), 50) }
const saveTitle = async (id: string) => { const saveTitle = async (id: string) => {
const t = editTitle.trim() const t = editTitle.trim()
if (t && t !== conversations.find(c => c.id === id)?.title) { await conversationsAPI.update(id, t).catch(() => {}); setConversations(prev => prev.map(c => c.id === id ? { ...c, title: t } : c)) } if (t && t !== conversations.find(c => c.id === id)?.title) { await conversationsAPI.update(id, t).catch((err) => toastError(err, '更新标题失败')); setConversations(prev => prev.map(c => c.id === id ? { ...c, title: t } : c)) }
setEditingId(null) setEditingId(null)
} }

View File

@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd' import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd'
import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons' import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons'
import { vectorAPI } from '@/api' import { vectorAPI } from '@/api'
import { toastError } from '@/lib/error-handler'
const { Title } = Typography const { Title } = Typography
@ -26,8 +27,8 @@ export default function VectorAdminPage() {
await vectorAPI.reindex() await vectorAPI.reindex()
message.success('索引重建已提交') message.success('索引重建已提交')
qc.invalidateQueries({ queryKey: ['vector'] }) qc.invalidateQueries({ queryKey: ['vector'] })
} catch { } catch (err) {
message.error('操作失败') toastError(err, '操作失败')
} }
} }