From 832a7f7960039c3e1c93ffaf2cb2a3bff2d94d3b Mon Sep 17 00:00:00 2001 From: wangdl Date: Sat, 4 Jul 2026 11:09:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E4=BD=93=E7=B3=BB=20=E2=80=94=20ErrorBoundar?= =?UTF-8?q?y=20+=20toastError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 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 --- src/App.tsx | 12 +++++- src/components/ErrorBoundary.tsx | 64 ++++++++++++++++++++++++++++++++ src/lib/antd-global.ts | 2 + src/lib/error-handler.ts | 32 ++++++++++++++++ src/pages/ReportingAdmin.tsx | 7 ++-- src/pages/TaskAssistant.tsx | 11 +++--- src/pages/VectorAdmin.tsx | 5 ++- 7 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/lib/error-handler.ts diff --git a/src/App.tsx b/src/App.tsx index dc6215b..c94a145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { AuthProvider } from './contexts/AuthContext' import AuthGuard from './components/AuthGuard' import PermissionGuard from './components/PermissionGuard' import PageLoading from './components/PageLoading' +import { ErrorBoundary } from './components/ErrorBoundary' +import { toastError } from './lib/error-handler' import AdminLayout from './layouts/AdminLayout' const Login = lazy(() => import('./pages/Login')) @@ -60,7 +62,13 @@ const BackupAdmin = lazy(() => import('./pages/BackupAdmin')) const KnowledgeSourcesPage = lazy(() => import('./pages/KnowledgeSources')) const ReportingAdmin = lazy(() => import('./pages/ReportingAdmin')) -const queryClient = new QueryClient() +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + onError: (error) => { toastError(error) }, + }, + }, +}) function App() { return ( @@ -71,6 +79,7 @@ function App() { }> + } /> } /> @@ -216,6 +225,7 @@ function App() { } /> + diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..ed42899 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( + window.location.reload()}> + 刷新页面 + , + , + isDev && ( + + ), + ]} + > + {isDev && this.state.error && ( +
+              {this.state.error.stack || this.state.error.message}
+            
+ )} +
+ ) + } + + return this.props.children + } +} diff --git a/src/lib/antd-global.ts b/src/lib/antd-global.ts index 2af2e4b..f9fff42 100644 --- a/src/lib/antd-global.ts +++ b/src/lib/antd-global.ts @@ -1,6 +1,7 @@ import { App } from 'antd' import type { MessageInstance } from 'antd/es/message/interface' import type { NotificationInstance } from 'antd/es/notification/interface' +import { setErrorMessageFn } from './error-handler' let message: MessageInstance | null = null let notification: NotificationInstance | null = null @@ -10,6 +11,7 @@ export function StaticAntdProvider() { const staticFunc = App.useApp() message = staticFunc.message notification = staticFunc.notification + setErrorMessageFn((msg: string) => staticFunc.message.error(msg)) return null } diff --git a/src/lib/error-handler.ts b/src/lib/error-handler.ts new file mode 100644 index 0000000..5d46431 --- /dev/null +++ b/src/lib/error-handler.ts @@ -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 + 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) +} diff --git a/src/pages/ReportingAdmin.tsx b/src/pages/ReportingAdmin.tsx index ae0af48..779e094 100644 --- a/src/pages/ReportingAdmin.tsx +++ b/src/pages/ReportingAdmin.tsx @@ -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 { useQuery } from '@tanstack/react-query' import { reportingAPI } from '@/api' +import { toastError } from '@/lib/error-handler' import { useState } from 'react' const { Title } = Typography @@ -26,8 +27,8 @@ export default function ReportingAdmin() { try { const text = await reportingAPI.exportCSV(type, String(days)) downloadBlob(text as string, `${type}-report-${days}d.csv`) - } catch { - message.error('导出失败') + } catch (err) { + toastError(err, '导出失败') } } diff --git a/src/pages/TaskAssistant.tsx b/src/pages/TaskAssistant.tsx index c74448f..a7c09a1 100644 --- a/src/pages/TaskAssistant.tsx +++ b/src/pages/TaskAssistant.tsx @@ -4,6 +4,7 @@ import { PlusOutlined, ToolOutlined, DeleteOutlined, MessageOutlined, ArrowUpOut import { aiAPI, conversationsAPI, type Conversation } from '@/api' import { streamChat, type StreamEvent } from '@/services/ai-chat' import Markdown from '@/components/Markdown' +import { toastError } from '@/lib/error-handler' const { Text } = Typography @@ -29,7 +30,7 @@ function ChatPage() { const messagesEndRef = useRef(null) const editInputRef = useRef(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(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations]) @@ -37,23 +38,23 @@ function ChatPage() { const switchConversation = useCallback(async (id: string) => { if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) } 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]) const handleNew = async () => { 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({ 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 saveTitle = async (id: string) => { 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) } diff --git a/src/pages/VectorAdmin.tsx b/src/pages/VectorAdmin.tsx index 0c6c1f6..5837c57 100644 --- a/src/pages/VectorAdmin.tsx +++ b/src/pages/VectorAdmin.tsx @@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Card, Row, Col, Statistic, Button, Typography, Descriptions, App } from 'antd' import { ReloadOutlined, DatabaseOutlined, NodeIndexOutlined } from '@ant-design/icons' import { vectorAPI } from '@/api' +import { toastError } from '@/lib/error-handler' const { Title } = Typography @@ -26,8 +27,8 @@ export default function VectorAdminPage() { await vectorAPI.reindex() message.success('索引重建已提交') qc.invalidateQueries({ queryKey: ['vector'] }) - } catch { - message.error('操作失败') + } catch (err) { + toastError(err, '操作失败') } }