feat: 前端 Error Boundary + 体验改进

- 新增 ErrorBoundary 组件: 捕获组件崩溃,显示友好错误页+重试按钮
- App: 用 ErrorBoundary 包裹,标题改为「先知 Profeto」
- Matches 页面:
  - 预测模式切换 (单次/多 Agent)
  - 加载中动画指示
  - 预测中状态提示
  - 错误提示可关闭
This commit is contained in:
shangfangjian
2026-09-09 21:58:57 +08:00
parent 494751bbb0
commit 3071ffd533
4 changed files with 2825 additions and 12 deletions
+2727
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,15 +1,18 @@
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches'
export default function App() {
return (
<ErrorBoundary>
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<h1 className="text-xl font-bold text-blue-700"> Profeto</h1>
<h1 className="text-xl font-bold text-blue-700"> Profeto</h1>
<span className="text-sm text-gray-500"> LLM </span>
</header>
<main className="max-w-5xl mx-auto p-6">
<Matches />
</main>
</div>
</ErrorBoundary>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
}
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo)
}
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3">
<div className="text-4xl"></div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-500">
{this.state.error?.message || '未知错误'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}
+33 -3
View File
@@ -73,6 +73,7 @@ export default function Matches() {
const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const load = useCallback(async () => {
setLoading(true)
@@ -100,7 +101,7 @@ export default function Matches() {
const res = await fetch('/api/v1/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: matchId }),
body: JSON.stringify({ match_id: matchId, mode }),
})
if (!res.ok) {
const t = await res.text()
@@ -134,6 +135,17 @@ export default function Matches() {
<option value="finished"></option>
<option value=""></option>
</select>
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">:</span>
<button
onClick={() => setMode('single')}
className={`px-2 py-1 rounded text-xs ${mode === 'single' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}
></button>
<button
onClick={() => setMode('multi')}
className={`px-2 py-1 rounded text-xs ${mode === 'multi' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}
> Agent</button>
</div>
<button onClick={load} disabled={loading}
className="bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? '加载中...' : '刷新'}
@@ -141,7 +153,12 @@ export default function Matches() {
<span className="text-sm text-gray-500"> {matches.length} </span>
</div>
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm flex items-center justify-between">
<span> {error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 text-xs"></button>
</div>
)}
{/* 比赛表 */}
<div className="bg-white rounded border overflow-hidden">
@@ -157,7 +174,12 @@ export default function Matches() {
</tr>
</thead>
<tbody>
{matches.length === 0 && !loading && (
{loading && (
<tr><td colSpan={6} className="text-center text-gray-400 py-8">
<span className="inline-block animate-spin mr-2"></span>...
</td></tr>
)}
{!loading && matches.length === 0 && (
<tr><td colSpan={6} className="text-center text-gray-400 py-8">,</td></tr>
)}
{matches.map(m => (
@@ -189,6 +211,14 @@ export default function Matches() {
</table>
</div>
{/* 预测中指示 */}
{predictingId && (
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm flex items-center gap-2">
<span className="inline-block animate-spin"></span>
LLM ,...
</div>
)}
{/* 预测结果 */}
{prediction && (
<div className="bg-white rounded border p-5 space-y-3">