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 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>
|
||||||
|
|||||||
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 { 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
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 { 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, '导出失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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, '操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user