feat: 前端 Error Boundary + 体验改进
- 新增 ErrorBoundary 组件: 捕获组件崩溃,显示友好错误页+重试按钮 - App: 用 ErrorBoundary 包裹,标题改为「先知 Profeto」 - Matches 页面: - 预测模式切换 (单次/多 Agent) - 加载中动画指示 - 预测中状态提示 - 错误提示可关闭
This commit is contained in:
Generated
+2727
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user