feat: 统一错误处理体系 — ErrorBoundary + toastError
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 13s
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:
parent
91698a2f3b
commit
832a7f7960
12
src/App.tsx
12
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() {
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/403" element={<ForbiddenPage />} />
|
||||
@ -216,6 +225,7 @@ function App() {
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
|
||||
64
src/components/ErrorBoundary.tsx
Normal file
64
src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
32
src/lib/error-handler.ts
Normal file
32
src/lib/error-handler.ts
Normal 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)
|
||||
}
|
||||
@ -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, '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>(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(() => { 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)
|
||||
}
|
||||
|
||||
|
||||
@ -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, '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user