Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df46544b8 | ||
|
|
d847f4f3f4 | ||
|
|
29e718962f | ||
|
|
e3cacc35e4 | ||
|
|
77d01450b1 | ||
|
|
acea7e699d |
@@ -10,3 +10,7 @@ frontend/dist/
|
||||
CLAUDE.md
|
||||
docs/AGENTS.md
|
||||
docs/agents/
|
||||
|
||||
# 本地审查/预览脚手架(不入库)
|
||||
.tools/
|
||||
.preview/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
+42
-5
@@ -1,17 +1,54 @@
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Matches from './pages/Matches'
|
||||
|
||||
/** 报眉日期行:2026年9月15日 星期二 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="text-sm text-gray-500">足球 LLM 预测服务</span>
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
|
||||
Profeto
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
|
||||
足球比分预测 · 五路专家 · 终裁汇总
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
|
||||
服务运行中
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto p-6">
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<Matches />
|
||||
</main>
|
||||
|
||||
{/* ── 版底 ── */}
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,102 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-paper-50 text-ink-800 font-sans antialiased;
|
||||
font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-press-wash text-press;
|
||||
}
|
||||
|
||||
/* 统一焦点环:印报红细线,键盘可达 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #9e1b1b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 细滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-ink-300;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-ink-400;
|
||||
}
|
||||
|
||||
/* 尊重「减少动态效果」偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||
.masthead-rule {
|
||||
border-top: 3px solid #17140f;
|
||||
}
|
||||
|
||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||
text-sm text-ink-700 transition-colors duration-150
|
||||
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||
disabled:cursor-not-allowed disabled:opacity-40
|
||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 py-1 text-xs;
|
||||
}
|
||||
.btn-solid {
|
||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||
}
|
||||
|
||||
/* ── 表单控件:方正、无圆角 ── */
|
||||
.field {
|
||||
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
||||
transition-colors hover:border-ink-400
|
||||
focus:border-press focus:outline-none;
|
||||
}
|
||||
|
||||
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
||||
.tab {
|
||||
@apply whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||
}
|
||||
.tab-on {
|
||||
@apply font-medium text-press;
|
||||
}
|
||||
.tab-on::after {
|
||||
content: '';
|
||||
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press;
|
||||
}
|
||||
|
||||
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
||||
.section-head {
|
||||
@apply border-b border-ink-900 pb-1.5 font-serif text-sm font-bold text-ink-900;
|
||||
}
|
||||
|
||||
/* ── 骨架占位:低调脉动,不用渐变扫光 ── */
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded-none bg-ink-200;
|
||||
}
|
||||
}
|
||||
|
||||
+508
-191
@@ -65,6 +65,117 @@ const LEAGUES = [
|
||||
{ code: 'F1', name: '法甲' },
|
||||
]
|
||||
|
||||
/** 汉字编号,给专家意见排版用 */
|
||||
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||||
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||||
live: { label: '进行中', cls: 'text-press font-medium' },
|
||||
}
|
||||
|
||||
/** 1x2 → 中文标签 */
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||
function EdgeBar({ value }: { value: number }) {
|
||||
const v = Math.max(-1, Math.min(1, value))
|
||||
const half = Math.abs(v) * 50
|
||||
return (
|
||||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||
<span
|
||||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||
style={
|
||||
v >= 0
|
||||
? { left: '50%', width: `${half}%` }
|
||||
: { right: '50%', width: `${half}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 骨架占位行:低调脉动灰块 */
|
||||
function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
function OutcomeLine({
|
||||
pick,
|
||||
confidence,
|
||||
}: {
|
||||
pick: string | null
|
||||
confidence: number | null
|
||||
}) {
|
||||
const options = ['1', 'X', '2'] as const
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||
{options.map(o => {
|
||||
const on = pick === o
|
||||
return (
|
||||
<div key={o} className="flex flex-col items-center gap-1">
|
||||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||
{OUTCOME_LABEL[o]}
|
||||
</span>
|
||||
{on && confidence !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
置信 {Math.round(confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{pick && confidence !== null && (
|
||||
<div className="mx-auto mt-3 max-w-xs">
|
||||
<Meter value={confidence} />
|
||||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
@@ -74,6 +185,7 @@ export default function Matches() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
|
||||
@@ -105,8 +217,7 @@ export default function Matches() {
|
||||
}
|
||||
}, [league, status])
|
||||
|
||||
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用,
|
||||
// 导致 limit=50 之后的数据永远看不到(见审查报告 P2-8)。
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||
@@ -130,16 +241,17 @@ export default function Matches() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const predict = async (matchId: number) => {
|
||||
const predict = async (m: Match) => {
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(matchId)
|
||||
setPredictingId(m.id)
|
||||
setError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
try {
|
||||
const res = await fetch('/api/v1/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ match_id: matchId, mode }),
|
||||
body: JSON.stringify({ match_id: m.id, mode }),
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
if (!res.ok) {
|
||||
@@ -162,205 +274,410 @@ export default function Matches() {
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
const Switch = ({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) => (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选 */}
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<select value={league} onChange={e => setLeague(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm">
|
||||
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm">
|
||||
<option value="scheduled">未开赛</option>
|
||||
<option value="finished">已完赛</option>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">模式:</span>
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||||
{LEAGUES.map(l => (
|
||||
<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 ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">共 {matches.length} 场</span>
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500">
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span className="text-2xs text-ink-400">状态</span>
|
||||
<Switch
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
items={[
|
||||
{ v: 'scheduled', label: '未开赛' },
|
||||
{ v: 'finished', label: '已完赛' },
|
||||
{ v: '', label: '全部' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span className="text-2xs text-ink-400">模式</span>
|
||||
<Switch
|
||||
value={mode}
|
||||
onChange={v => setMode(v as 'single' | 'multi')}
|
||||
items={[
|
||||
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
|
||||
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="tabular-nums">共 {matches.length} 场</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||
</button>
|
||||
</span>
|
||||
</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">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">日期</th>
|
||||
<th className="text-left px-4 py-2">主队</th>
|
||||
<th className="text-left px-4 py-2">客队</th>
|
||||
<th className="text-center px-4 py-2">比分</th>
|
||||
<th className="text-center px-4 py-2">状态</th>
|
||||
<th className="text-center px-4 py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 => (
|
||||
<tr key={m.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-gray-600">{fmtDate(m.match_date)}</td>
|
||||
<td className="px-4 py-2 font-medium">{m.home_team_zh || m.home_team}</td>
|
||||
<td className="px-4 py-2 font-medium">{m.away_team_zh || m.away_team}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
m.match_status === 'finished' ? 'bg-green-100 text-green-700' :
|
||||
m.match_status === 'scheduled' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'
|
||||
}`}>
|
||||
{m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button onClick={() => predict(m.id)}
|
||||
disabled={predictingId === m.id}
|
||||
className="text-blue-600 hover:underline text-xs disabled:opacity-50">
|
||||
{predictingId === m.id ? '预测中...' : 'LLM 预测'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页: 加载更多 */}
|
||||
{nextCursor && (
|
||||
<div className="flex justify-center">
|
||||
<button onClick={loadMore} disabled={loadingMore}
|
||||
className="bg-white border text-gray-700 text-sm px-6 py-2 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||
{loadingMore ? '加载中...' : '加载更多'}
|
||||
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-press">请求失败</p>
|
||||
<p className="mt-0.5 text-xs text-ink-600">{error}</p>
|
||||
</div>
|
||||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900" aria-label="关闭">
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</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 预测中,请稍候...
|
||||
{/* ── 赛程栏:表格化,行间细线 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
{!loading && matches.length === 0 && (
|
||||
<div className="border-y border-ink-200 py-14 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const busy = predictingId === m.id
|
||||
const active = predictionFor?.id === m.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
active ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
|
||||
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
|
||||
<div className="flex items-center gap-2 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</div>
|
||||
|
||||
{/* 比分 / VS */}
|
||||
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs tracking-widest text-ink-400">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态列(桌面) */}
|
||||
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
|
||||
|
||||
{/* 预测按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
disabled={busy}
|
||||
className="btn btn-sm w-[76px]"
|
||||
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!loading && nextCursor && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
|
||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 预测中占位 ── */}
|
||||
{predictingId && !prediction && (
|
||||
<div className="border border-ink-900">
|
||||
<div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
|
||||
<Spinner className="text-press" />
|
||||
<span className="text-sm font-medium text-ink-800">正在生成预测</span>
|
||||
<span className="text-2xs text-ink-500">
|
||||
{mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<div className="skeleton h-4 w-20" />
|
||||
<div className="skeleton h-10 w-24" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
<div className="skeleton mx-auto h-px w-64" />
|
||||
<div className="skeleton h-16 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预测结果 */}
|
||||
{prediction && (
|
||||
<div className="bg-white rounded border p-5 space-y-3">
|
||||
<h3 className="font-bold text-lg">🤖 LLM 预测结果</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">主进球</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_home_goals ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">客进球</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_away_goals ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">胜平负</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_1x2 ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">置信度</div>
|
||||
<div className="text-xl font-bold">
|
||||
{prediction.subjective_confidence !== null ? `${(prediction.subjective_confidence * 100).toFixed(0)}%` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{prediction.provider} / {prediction.model} · 耗时 {prediction.latency_ms}ms
|
||||
{prediction.mode === 'multi' && ' · 多 Agent 模式'}
|
||||
</div>
|
||||
|
||||
{/* 各专家 agent 报告 */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700">专家 Agent 报告</div>
|
||||
{prediction.agent_outputs.map((r) => (
|
||||
<details key={r.agent} className="bg-white border rounded">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{AGENT_LABELS[r.agent] || r.agent}
|
||||
{r.status !== 'ok' && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
||||
r.status === 'no_data' ? 'bg-gray-100 text-gray-500' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{r.status === 'no_data' ? '无数据' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex gap-3 text-xs text-gray-500">
|
||||
{r.home_edge !== null && (
|
||||
<span className={r.home_edge > 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}>
|
||||
主队优势 {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{r.subjective_confidence !== null && <span>信心 {(r.subjective_confidence * 100).toFixed(0)}%</span>}
|
||||
{r.probable_score && <span>比分 {r.probable_score}</span>}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 space-y-2 text-sm">
|
||||
{r.analysis && <p className="text-gray-700">{r.analysis}</p>}
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="text-xs text-gray-500 list-disc pl-4">
|
||||
{r.key_evidence.map((e, i) => <li key={i}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<div className="text-xs text-gray-500">
|
||||
进球期望: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400">
|
||||
数据充分度 {r.data_sufficiency} · {r.model} · {r.latency_ms}ms
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prediction.reasoning && (
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">推理过程</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{prediction.reasoning}</div>
|
||||
</div>
|
||||
)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整上下文</summary>
|
||||
<pre className="mt-2 bg-gray-900 text-green-300 p-3 rounded overflow-x-auto text-xs">
|
||||
{prediction.context}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/* ── 预测版 ── */}
|
||||
{prediction && predictionFor && (
|
||||
<PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
mode,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
mode: 'single' | 'multi'
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className="border border-ink-900 bg-paper-50">
|
||||
{/* 版头 */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">
|
||||
预测版 · {homeName} 对 {awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
</div>
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
|
||||
{/* ── 元信息一行 ── */}
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}
|
||||
</p>
|
||||
|
||||
{/* ── 专家意见 ── */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<section>
|
||||
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
|
||||
<span>五路专家意见</span>
|
||||
{prediction.agent_weights && (
|
||||
<span className="font-sans text-2xs font-normal text-ink-500">
|
||||
终裁权重:{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
|
||||
.join(' / ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
||||
{prediction.reasoning}
|
||||
</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 原始上下文 ── */}
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-ink-500 transition-colors hover:text-ink-800">
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
查看喂给模型的完整数据切片
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-600">
|
||||
{prediction.context}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: '正常', cls: 'text-ink-500' },
|
||||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||
error: { label: '调用失败', cls: 'text-press' },
|
||||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||
}
|
||||
|
||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||
high: '充分',
|
||||
medium: '一般',
|
||||
low: '偏少',
|
||||
none: '无',
|
||||
}
|
||||
|
||||
/** 单路专家意见:汉字编号 + 细线行 */
|
||||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||
const inactive = r.status !== 'ok'
|
||||
|
||||
return (
|
||||
<details className="group border-b border-ink-200">
|
||||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
|
||||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
{r.status === 'ok' && r.probable_score && (
|
||||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||
)}
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||
{inactive && (
|
||||
<p className="text-xs leading-relaxed text-ink-500">
|
||||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && r.home_edge !== null && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||
<span className="text-ink-500">主队优势</span>
|
||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<EdgeBar value={r.home_edge} />
|
||||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||
<span>利客队</span>
|
||||
<span>利主队</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.analysis && (
|
||||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||
)}
|
||||
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{r.key_evidence.map((e, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<p className="text-xs text-ink-500">
|
||||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
<span className="font-mono">{r.model}</span>
|
||||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 纸白:微暖底色,像新闻纸而不是纯白画布
|
||||
paper: {
|
||||
50: '#FDFCF8',
|
||||
100: '#F7F4EC',
|
||||
200: '#EDE9DE',
|
||||
300: '#DDD7C7',
|
||||
},
|
||||
// 墨色:暖黑灰阶,替代冷调 slate
|
||||
ink: {
|
||||
50: '#FAF9F7',
|
||||
100: '#F0EEE9',
|
||||
200: '#E2DFD7',
|
||||
300: '#C9C4B8',
|
||||
400: '#9C9587',
|
||||
500: '#6E675B',
|
||||
600: '#524C42',
|
||||
700: '#3B362E',
|
||||
800: '#282420',
|
||||
900: '#17140F',
|
||||
},
|
||||
// 印报红:全站唯一强调色,克制使用
|
||||
press: {
|
||||
DEFAULT: '#9E1B1B',
|
||||
dark: '#7C1414',
|
||||
wash: '#F7E9E4',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// 标题与比分:宋体血统,报纸版面的骨架
|
||||
serif: [
|
||||
'Georgia',
|
||||
'"Times New Roman"',
|
||||
'"Songti SC"',
|
||||
'STSong',
|
||||
'SimSun',
|
||||
'"Noto Serif CJK SC"',
|
||||
'serif',
|
||||
],
|
||||
// 正文:中文黑体栈
|
||||
sans: [
|
||||
'"PingFang SC"',
|
||||
'"Hiragino Sans GB"',
|
||||
'"Microsoft YaHei"',
|
||||
'"Noto Sans CJK SC"',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['11px', { lineHeight: '16px' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user