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
+12 -9
View File
@@ -1,15 +1,18 @@
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches' import Matches from './pages/Matches'
export default function App() { export default function App() {
return ( return (
<div className="min-h-screen bg-gray-50"> <ErrorBoundary>
<header className="bg-white border-b px-6 py-3 flex items-center justify-between"> <div className="min-h-screen bg-gray-50">
<h1 className="text-xl font-bold text-blue-700"> Profeto</h1> <header className="bg-white border-b px-6 py-3 flex items-center justify-between">
<span className="text-sm text-gray-500"> LLM </span> <h1 className="text-xl font-bold text-blue-700"> Profeto</h1>
</header> <span className="text-sm text-gray-500"> LLM </span>
<main className="max-w-5xl mx-auto p-6"> </header>
<Matches /> <main className="max-w-5xl mx-auto p-6">
</main> <Matches />
</div> </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 [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null) const [prediction, setPrediction] = useState<Prediction | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -100,7 +101,7 @@ export default function Matches() {
const res = await fetch('/api/v1/predict', { const res = await fetch('/api/v1/predict', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: matchId }), body: JSON.stringify({ match_id: matchId, mode }),
}) })
if (!res.ok) { if (!res.ok) {
const t = await res.text() const t = await res.text()
@@ -134,6 +135,17 @@ export default function Matches() {
<option value="finished"></option> <option value="finished"></option>
<option value=""></option> <option value=""></option>
</select> </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} <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"> className="bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? '加载中...' : '刷新'} {loading ? '加载中...' : '刷新'}
@@ -141,7 +153,12 @@ export default function Matches() {
<span className="text-sm text-gray-500"> {matches.length} </span> <span className="text-sm text-gray-500"> {matches.length} </span>
</div> </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"> <div className="bg-white rounded border overflow-hidden">
@@ -157,7 +174,12 @@ export default function Matches() {
</tr> </tr>
</thead> </thead>
<tbody> <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> <tr><td colSpan={6} className="text-center text-gray-400 py-8">,</td></tr>
)} )}
{matches.map(m => ( {matches.map(m => (
@@ -189,6 +211,14 @@ export default function Matches() {
</table> </table>
</div> </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 && ( {prediction && (
<div className="bg-white rounded border p-5 space-y-3"> <div className="bg-white rounded border p-5 space-y-3">