refactor(ui): 去 AI 味,前端重做为报刊赛程版视觉 #2

Merged
shangfangjian merged 2 commits from frontend-beautify into main 2026-09-15 20:00:05 +08:00
4 changed files with 397 additions and 495 deletions
Showing only changes of commit d847f4f3f4 - Show all commits
+34 -45
View File
@@ -1,64 +1,53 @@
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches' import Matches from './pages/Matches'
function Logo() { /** 报眉日期行:2026年9月15日 星期二 */
// 纯 SVG 队徽/足球图形,替代 emoji ⚽(跨平台渲染不一致) function dateLine(): string {
return ( return new Date().toLocaleDateString('zh-CN', {
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 shadow-sm"> year: 'numeric',
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true"> month: 'long',
<circle cx="12" cy="12" r="9" stroke="white" strokeWidth="1.6" /> day: 'numeric',
<path weekday: 'long',
d="M12 6.2l3.1 2.2-1.2 3.6h-3.8L8.9 8.4 12 6.2z" })
fill="white"
fillOpacity="0.95"
/>
<path
d="M12 3v3.2M4.2 9.6l4.7-1.2M7.1 19.4l2.9-4.6M16.9 19.4l-2.9-4.6M19.8 9.6l-4.7-1.2"
stroke="white"
strokeWidth="1.4"
strokeLinecap="round"
strokeOpacity="0.85"
/>
</svg>
</span>
)
} }
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="min-h-screen bg-ink-100"> <div className="min-h-screen bg-paper-50">
<header className="sticky top-0 z-20 border-b border-ink-200 bg-white/85 backdrop-blur-sm"> {/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-3"> <header className="masthead-rule">
<div className="flex items-center gap-3"> <div className="mx-auto max-w-5xl px-5 sm:px-8">
<Logo /> <div className="border-b border-ink-900 py-5 text-center sm:py-6">
<div className="leading-tight"> <h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
<h1 className="text-[15px] font-semibold tracking-tight text-ink-900">
<span className="font-normal text-ink-400">Profeto</span> <span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
</h1> Profeto
<p className="text-2xs text-ink-500"> </span>
LLM · </h1>
</p> <p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">
</div> · ·
</p>
</div> </div>
<span className="hidden items-center gap-1.5 sm:inline-flex"> <div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span className="relative flex h-2 w-2"> <span>{dateLine()}</span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-60" /> <span className="flex items-center gap-1.5">
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" /> <span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
</span> </span>
<span className="text-2xs font-medium text-ink-500"></span> </div>
</span>
</div> </div>
</header> </header>
<main className="mx-auto max-w-6xl px-6 py-6"> <main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
<Matches /> <Matches />
</main> </main>
<footer className="mx-auto max-w-6xl px-6 pb-8 pt-2"> {/* ── 版底 ── */}
<p className="text-center text-2xs text-ink-400"> <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">
</p> · ·
</div>
</footer> </footer>
</div> </div>
</ErrorBoundary> </ErrorBoundary>
+43 -39
View File
@@ -9,13 +9,18 @@
} }
body { body {
@apply bg-ink-100 text-ink-800 antialiased; @apply bg-paper-50 text-ink-800 font-sans antialiased;
font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */ font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */
} }
/* 统一焦点环,键盘可达性 */ ::selection {
@apply bg-press-wash text-press;
}
/* 统一焦点环:印报红细线,键盘可达 */
:focus-visible { :focus-visible {
@apply outline-none ring-2 ring-brand-500/40 ring-offset-1 ring-offset-white; outline: 2px solid #9e1b1b;
outline-offset: 2px;
} }
/* 细滚动条 */ /* 细滚动条 */
@@ -27,7 +32,7 @@
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-pill bg-ink-300/70; @apply rounded-full bg-ink-300;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-ink-400; @apply bg-ink-400;
@@ -46,53 +51,52 @@
} }
@layer components { @layer components {
/* ── 卡片 ── */ /* ── 报头双线:粗线在上、细线在下 ── */
.card { .masthead-rule {
@apply rounded-card border border-ink-200 bg-white shadow-card; border-top: 3px solid #17140f;
} }
/* ── 按钮 ── */ /* ── 按钮:方正边框式,悬停反白 ── */
.btn { .btn {
@apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3.5 py-2 @apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
text-sm font-medium transition-colors duration-150 text-sm text-ink-700 transition-colors duration-150
disabled:cursor-not-allowed disabled:opacity-50; hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
} disabled:cursor-not-allowed disabled:opacity-40
.btn-primary { disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
@apply btn bg-brand-600 text-white shadow-sm hover:bg-brand-700 active:bg-brand-800;
}
.btn-ghost {
@apply btn border border-ink-200 bg-white text-ink-700 hover:bg-ink-50 hover:border-ink-300;
} }
.btn-sm { .btn-sm {
@apply px-3 py-1.5 text-xs; @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 { .field {
@apply rounded-lg border border-ink-200 bg-white px-3 py-2 text-sm text-ink-800 @apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
transition-colors hover:border-ink-300 transition-colors hover:border-ink-400
focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20; focus:border-press focus:outline-none;
} }
/* ── 状态胶囊 ── */ /* ── 版面切换文字标签(联赛/状态/模式) ── */
.pill { .tab {
@apply inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-2xs font-medium; @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 { .skeleton {
@apply rounded bg-gradient-to-r from-ink-200 via-ink-100 to-ink-200 @apply animate-pulse rounded-none bg-ink-200;
bg-[length:200%_100%] animate-shimmer;
}
/* ── 分段控件(模式切换) ── */
.segment {
@apply rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150;
}
.segment-on {
@apply bg-white text-brand-700 shadow-sm;
}
.segment-off {
@apply text-ink-500 hover:text-ink-700;
} }
} }
+285 -354
View File
@@ -65,68 +65,37 @@ const LEAGUES = [
{ code: 'F1', name: '法甲' }, { code: 'F1', name: '法甲' },
] ]
/** 汉字编号,给专家意见排版用 */
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
const STATUS_META: Record<string, { label: string; cls: string }> = { const STATUS_META: Record<string, { label: string; cls: string }> = {
finished: { label: '已完赛', cls: 'bg-ink-100 text-ink-600' }, finished: { label: '已完赛', cls: 'text-ink-400' },
scheduled: { label: '未开赛', cls: 'bg-brand-50 text-brand-700' }, scheduled: { label: '未开赛', cls: 'text-ink-600' },
live: { label: '进行中', cls: 'bg-emerald-50 text-emerald-700' }, live: { label: '进行中', cls: 'text-press font-medium' },
} }
/** 1x2 → 中文标签 */ /** 1x2 → 中文标签 */
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' } const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
/** 队名取首字作视觉标记(替代队徽图片,避免额外资源与 404) */ /** 置信度细线:0~1 数值的低调可视化 */
function initial(name: string): string { function Meter({ value }: { value: number }) {
return (name || '?').trim().charAt(0)
}
/** 依据队名生成一个稳定的色相,让不同球队有可区分的淡色底 */
function hueOf(name: string): number {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360
return h
}
function TeamMark({ name, size = 40 }: { name: string; size?: number }) {
const h = hueOf(name)
return (
<span
className="flex flex-shrink-0 items-center justify-center rounded-lg font-semibold"
style={{
width: size,
height: size,
fontSize: size * 0.4,
background: `hsl(${h} 70% 96%)`,
color: `hsl(${h} 55% 38%)`,
border: `1px solid hsl(${h} 60% 88%)`,
}}
aria-hidden="true"
>
{initial(name)}
</span>
)
}
/** 置信度条:把 0~1 的数值可视化,比纯数字更易读 */
function Meter({ value, tone = 'brand' }: { value: number; tone?: 'brand' | 'emerald' | 'amber' }) {
const pct = Math.max(0, Math.min(100, Math.round(value * 100))) const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
const bar =
tone === 'emerald' ? 'bg-emerald-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-brand-500'
return ( return (
<div className="h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation"> <div className="h-px w-full bg-ink-200" role="presentation">
<div className={`h-full rounded-pill ${bar} transition-[width] duration-500`} style={{ width: `${pct}%` }} /> <div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
</div> </div>
) )
} }
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向条 */ /** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向条 */
function EdgeBar({ value }: { value: number }) { function EdgeBar({ value }: { value: number }) {
const v = Math.max(-1, Math.min(1, value)) const v = Math.max(-1, Math.min(1, value))
const half = Math.abs(v) * 50 const half = Math.abs(v) * 50
return ( return (
<div className="relative h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation"> <div className="relative h-px w-full bg-ink-200" role="presentation">
<span className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-ink-300" /> <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 <span
className={`absolute top-0 h-full transition-all duration-500 ${v >= 0 ? 'bg-brand-500' : 'bg-amber-500'}`} className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
style={ style={
v >= 0 v >= 0
? { left: '50%', width: `${half}%` } ? { left: '50%', width: `${half}%` }
@@ -137,24 +106,76 @@ function EdgeBar({ value }: { value: number }) {
) )
} }
/** 骨架屏行,替代 emoji spinner */ /** 骨架占位行:低调脉动灰块 */
function SkeletonRows({ n = 4 }: { n?: number }) { function SkeletonRows({ n = 4 }: { n?: number }) {
return ( return (
<> <>
{Array.from({ length: n }).map((_, i) => ( {Array.from({ length: n }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-card border border-ink-200 bg-white p-4"> <div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
<div className="skeleton h-10 w-10 rounded-lg" /> <div className="skeleton h-3 w-16" />
<div className="flex-1 space-y-2"> <div className="skeleton h-3 flex-1" />
<div className="skeleton h-3.5 w-40" /> <div className="skeleton h-3 w-10" />
<div className="skeleton h-3 w-24" /> <div className="skeleton h-3 flex-1" />
</div> <div className="skeleton h-3 w-16" />
<div className="skeleton h-8 w-20 rounded-lg" />
</div> </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() { export default function Matches() {
const [league, setLeague] = useState('E0') const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled') const [status, setStatus] = useState('scheduled')
@@ -196,8 +217,7 @@ export default function Matches() {
} }
}, [league, status]) }, [league, status])
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用, // 加载下一页(游标分页)
// 导致 limit=50 之后的数据永远看不到。
const loadMore = async () => { const loadMore = async () => {
if (!nextCursor || loadingMore) return if (!nextCursor || loadingMore) return
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列 const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
@@ -256,69 +276,86 @@ export default function Matches() {
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
return ( /** 状态/模式一组的文字切换 */
<div className="space-y-4"> const Switch = ({ value, onChange, items }: {
{/* ── 工具栏 ── */} value: string
<div className="card flex flex-wrap items-center gap-3 p-3"> onChange: (v: string) => void
<div className="flex items-center gap-2"> items: { v: string; label: string; title?: string }[]
<label className="text-2xs font-medium text-ink-500"></label> }) => (
<select value={league} onChange={e => setLeague(e.target.value)} className="field w-28"> <span className="inline-flex items-center gap-2.5">
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)} {items.map((it, i) => (
</select> <span key={it.v} className="inline-flex items-center gap-2.5">
</div> {i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
<div className="flex items-center gap-2"> onClick={() => onChange(it.v)}
<label className="text-2xs font-medium text-ink-500"></label> title={it.title}
<select value={status} onChange={e => setStatus(e.target.value)} className="field w-28"> className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
<option value="scheduled"></option> >
<option value="finished"></option> {it.label}
<option value=""></option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-2xs font-medium text-ink-500"></label>
<div className="inline-flex rounded-lg bg-ink-100 p-0.5">
<button
onClick={() => setMode('single')}
className={`segment ${mode === 'single' ? 'segment-on' : 'segment-off'}`}
title="单次调用,快但只有一个模型看全部数据"
></button>
<button
onClick={() => setMode('multi')}
className={`segment ${mode === 'multi' ? 'segment-on' : 'segment-off'}`}
title="5 个专家并行分析后由终裁汇总,质量更高"
> Agent</button>
</div>
</div>
<div className="ml-auto flex items-center gap-3">
<span className="text-2xs text-ink-500">
<span className="font-semibold text-ink-700">{matches.length}</span>
</span>
<button onClick={load} disabled={loading} className="btn-ghost btn-sm">
{loading ? (
<>
<Spinner />
</>
) : '刷新'}
</button> </button>
</div> </span>
))}
</span>
)
return (
<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
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> </div>
{/* ── 错误提示 ── */} {/* ── 错误提示 ── */}
{error && ( {error && (
<div className="flex items-start justify-between gap-3 rounded-card border border-red-200 bg-red-50 px-4 py-3"> <div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
<div className="flex items-start gap-2.5"> <div>
<svg viewBox="0 0 20 20" className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-500" fill="currentColor" aria-hidden="true"> <p className="text-sm font-medium text-press"></p>
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm1-4a1 1 0 100 2 1 1 0 000-2z" clipRule="evenodd" /> <p className="mt-0.5 text-xs text-ink-600">{error}</p>
</svg>
<div>
<p className="text-sm font-medium text-red-800"></p>
<p className="mt-0.5 text-xs text-red-600">{error}</p>
</div>
</div> </div>
<button onClick={() => setError(null)} className="text-red-400 transition-colors hover:text-red-600" aria-label="关闭"> <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"> <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" /> <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> </svg>
@@ -326,23 +363,19 @@ export default function Matches() {
</div> </div>
)} )}
{/* ── 比赛列表 ── */} {/* ── 赛程栏:表格化,行间细线 ── */}
<div className="space-y-2.5"> <section aria-label="赛程">
{loading && <SkeletonRows n={4} />} {loading && <SkeletonRows n={4} />}
{!loading && matches.length === 0 && ( {!loading && matches.length === 0 && (
<div className="card flex flex-col items-center justify-center gap-2 py-16"> <div className="border-y border-ink-200 py-14 text-center">
<svg viewBox="0 0 24 24" className="h-10 w-10 text-ink-300" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> <p className="font-serif text-sm text-ink-600"></p>
<circle cx="12" cy="12" r="9" /> <p className="mt-1.5 text-xs text-ink-400"> {leagueName} </p>
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<p className="text-sm font-medium text-ink-600"></p>
<p className="text-xs text-ink-400"> {leagueName} </p>
</div> </div>
)} )}
{!loading && matches.map(m => { {!loading && matches.map(m => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'bg-ink-100 text-ink-600' } 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 homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team const awayName = m.away_team_zh || m.away_team
const busy = predictingId === m.id const busy = predictingId === m.id
@@ -351,70 +384,58 @@ export default function Matches() {
return ( return (
<div <div
key={m.id} key={m.id}
className={`group card p-4 transition-all duration-200 hover:shadow-raise ${ className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
active ? 'ring-1 ring-brand-300' : '' active ? 'bg-press-wash/50' : ''
}`} }`}
> >
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4"> <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 min-w-0 flex-1 items-center gap-2 sm:gap-3"> <div className="flex items-center justify-between sm:contents">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:gap-2.5"> <span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
<span className="truncate text-sm font-medium text-ink-800">{homeName}</span> <span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
<TeamMark name={homeName} size={36} /> </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> </div>
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-16"> {/* 比分 / VS */}
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
{m.home_goals !== null && m.away_goals !== null ? ( {m.home_goals !== null && m.away_goals !== null ? (
<span className="text-base font-semibold tabular-nums text-ink-900"> <span className="font-serif text-base font-bold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 text-ink-300">:</span>{m.away_goals} {m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
</span> </span>
) : ( ) : (
<span className="text-xs font-medium text-ink-300">VS</span> <span className="text-2xs tracking-widest text-ink-400">VS</span>
)} )}
{m.home_xg !== null && m.away_xg !== null && ( {m.home_xg !== null && m.away_xg !== null && (
<span className="mt-0.5 text-2xs tabular-nums text-ink-400"> <span className="text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)} xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
</span> </span>
)} )}
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-2.5"> {/* 客队(左对齐) */}
<TeamMark name={awayName} size={36} /> <div className="flex min-w-0 flex-1 items-center">
<span className="truncate text-sm font-medium text-ink-800">{awayName}</span> <span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 sm:flex-shrink-0"> {/* 状态列(桌面) */}
{/* 元信息:桌面端独立成列,移动端与按钮同排 */} <span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 sm:w-40 sm:flex-none">
<span className={`pill ${st.cls}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
{st.label}
</span>
<span className="truncate text-2xs text-ink-400">
{m.league_code ?? '—'} · {fmtDate(m.match_date)}
</span>
</div>
{/* 操作:主功能提升为实心按钮 */} {/* 预测按钮 */}
<div className="flex justify-end">
<button <button
onClick={() => predict(m)} onClick={() => predict(m)}
disabled={busy} disabled={busy}
className="btn-primary btn-sm w-[88px] flex-shrink-0" className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多 Agent' : '单次'}模式预测这场`} title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
> >
{busy ? ( {busy ? (<><Spinner /> </>) : '预测'}
<>
<Spinner />
</>
) : (
<>
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" />
</svg>
</>
)}
</button> </button>
</div> </div>
</div> </div>
@@ -423,96 +444,39 @@ export default function Matches() {
})} })}
{!loading && nextCursor && ( {!loading && nextCursor && (
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-4">
<button onClick={loadMore} disabled={loadingMore} className="btn-ghost"> <button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
{loadingMore ? ( {loadingMore ? (<><Spinner /> </>) : '载入更多'}
<>
<Spinner />
</>
) : '加载更多'}
</button> </button>
</div> </div>
)} )}
</div> </section>
{/* ── 预测中:占位卡片(替代 emoji 提示条) ── */} {/* ── 预测中占位 ── */}
{predictingId && !prediction && ( {predictingId && !prediction && (
<div className="card animate-fade-up overflow-hidden"> <div className="border border-ink-900">
<div className="flex items-center gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3"> <div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
<Spinner className="text-brand-600" /> <Spinner className="text-press" />
<span className="text-sm font-medium text-ink-700"></span> <span className="text-sm font-medium text-ink-800"></span>
<span className="text-xs text-ink-400"> <span className="text-2xs text-ink-500">
{mode === 'multi' ? '· 5 个专家并行分析后终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒'} {mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
</span> </span>
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 px-4 py-6">
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-6">
<div className="skeleton h-3.5 w-24" /> <div className="skeleton h-4 w-20" />
<div className="skeleton h-10 w-20 rounded-lg" /> <div className="skeleton h-10 w-24" />
<div className="skeleton h-3.5 w-24" /> <div className="skeleton h-4 w-20" />
</div>
<div className="skeleton h-2 w-full rounded-pill" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="skeleton h-20 rounded-card" />
<div className="skeleton h-20 rounded-card" />
</div> </div>
<div className="skeleton mx-auto h-px w-64" />
<div className="skeleton h-16 w-full" />
</div> </div>
</div> </div>
)} )}
{/* ── 预测结果 ── */} {/* ── 预测 ── */}
{prediction && predictionFor && ( {prediction && predictionFor && (
<PredictionPanel <PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
prediction={prediction}
match={predictionFor}
mode={mode}
/>
)}
</div>
)
}
function Spinner({ className = '' }: { className?: string }) {
// 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致)
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="2" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
}
/** 胜平负概率条:LLM 只给主观置信度,这里把它按 1x2 结果做成单条高亮,
* 并显式标注「主观」避免用户误当概率 */
function OutcomeCard({
pick,
confidence,
}: {
pick: string | null
confidence: number | null
}) {
const label = pick ? OUTCOME_LABEL[pick] ?? pick : '—'
const tone = pick === '1' ? 'brand' : pick === 'X' ? 'amber' : 'emerald'
const color =
tone === 'brand' ? 'text-brand-700' : tone === 'amber' ? 'text-amber-700' : 'text-emerald-700'
const bg = tone === 'brand' ? 'bg-brand-50' : tone === 'amber' ? 'bg-amber-50' : 'bg-emerald-50'
const border =
tone === 'brand' ? 'border-brand-100' : tone === 'amber' ? 'border-amber-100' : 'border-emerald-100'
return (
<div className={`rounded-card border ${border} ${bg} p-4`}>
<p className="text-2xs font-medium text-ink-500"></p>
<p className={`mt-1 text-2xl font-semibold ${color}`}>{label}</p>
{confidence !== null && (
<div className="mt-2.5 space-y-1.5">
<Meter value={confidence} tone={tone === 'brand' ? 'brand' : tone === 'amber' ? 'amber' : 'emerald'} />
<p className="text-2xs text-ink-400"> {Math.round(confidence * 100)}%</p>
</div>
)} )}
</div> </div>
) )
@@ -532,129 +496,98 @@ function PredictionPanel({
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok') const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return ( return (
<div className="card animate-fade-up overflow-hidden"> <article className="border border-ink-900 bg-paper-50">
{/* 面板头 */} {/* 头 */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3"> <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">
<div className="flex items-center gap-2"> <h3 className="font-serif text-sm font-bold text-ink-900">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-600"> · {homeName} {awayName}
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="currentColor" aria-hidden="true"> </h3>
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" /> <span className="text-2xs tabular-nums text-ink-500">
</svg>
</span>
<h3 className="text-sm font-semibold text-ink-800">LLM </h3>
{prediction.mode === 'multi' && (
<span className="pill bg-brand-50 text-brand-700">
Agent · {okReports.length}/{prediction.agent_outputs?.length ?? 0}
</span>
)}
</div>
<span className="text-2xs text-ink-400">
{prediction.provider} / {prediction.model} {prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span> </span>
</div> </div>
<div className="space-y-5 p-5"> <div className="space-y-7 px-4 py-6 sm:px-5">
{/* ── 对阵 + 预测比分(核心) ── */} {/* ── 预测比分:版面核心,大号宋体 ── */}
<div className="flex items-center justify-center gap-4 sm:gap-8"> <div className="text-center">
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row sm:justify-end"> <p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:order-1 sm:text-right">{homeName}</span> {prediction.pred_home_goals ?? '-'}
<span className="order-1 sm:order-2"><TeamMark name={homeName} size={44} /></span> <span className="mx-3 font-normal text-ink-300">:</span>
</div> {prediction.pred_away_goals ?? '-'}
</p>
<div className="flex flex-shrink-0 flex-col items-center"> <p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
<div className="flex items-center gap-1.5">
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
{prediction.pred_home_goals ?? '-'}
</span>
<span className="text-lg text-ink-300">:</span>
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
{prediction.pred_away_goals ?? '-'}
</span>
</div>
<span className="mt-1.5 text-2xs text-ink-400"></span>
</div>
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row">
<span className="order-1"><TeamMark name={awayName} size={44} /></span>
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:text-left">{awayName}</span>
</div>
</div> </div>
{/* ── 指标卡 ── */} {/* ── 胜平负 ── */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="border-y border-ink-200 py-4">
<OutcomeCard pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} /> <OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
<div className="rounded-card border border-ink-200 p-4">
<p className="text-2xs font-medium text-ink-500"></p>
<p className="mt-1 text-2xl font-semibold text-ink-800">{mode === 'multi' ? '多 Agent' : '单次'}</p>
<p className="mt-2.5 text-2xs text-ink-400">
prompt {prediction.prompt_version ?? '—'}
</p>
</div>
<div className="rounded-card border border-ink-200 p-4">
<p className="text-2xs font-medium text-ink-500"></p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-ink-800">
{prediction.latency_ms !== null ? (prediction.latency_ms / 1000).toFixed(1) : '—'}
<span className="ml-0.5 text-sm font-normal text-ink-400">s</span>
</p>
<p className="mt-2.5 text-2xs text-ink-400"> + </p>
</div>
</div> </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 && ( {prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
<div> <section>
<div className="mb-2.5 flex items-center gap-2"> <div className="section-head flex flex-wrap items-baseline justify-between gap-1">
<h4 className="text-xs font-semibold text-ink-700"> Agent </h4> <span></span>
{prediction.agent_weights && ( {prediction.agent_weights && (
<span className="text-2xs text-ink-400"> <span className="font-sans text-2xs font-normal text-ink-500">
:{Object.entries(prediction.agent_weights)
{Object.entries(prediction.agent_weights)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`) .map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
.join(' · ')} .join(' / ')}
</span> </span>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2"> <div>
{prediction.agent_outputs.map(r => ( {prediction.agent_outputs.map((r, i) => (
<AgentCard key={r.agent} report={r} /> <AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))} ))}
</div> </div>
</div> </section>
)} )}
{/* ── 推理过程 ── */} {/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && ( {prediction.reasoning && (
<div className="rounded-card border border-ink-200 bg-ink-50 p-4"> <section>
<p className="mb-2 text-2xs font-semibold uppercase tracking-wide text-ink-500"></p> <h4 className="section-head mb-3"></h4>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-700">{prediction.reasoning}</p> <blockquote className="border-l-2 border-press pl-4">
</div> <p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{prediction.reasoning}
</p>
</blockquote>
</section>
)} )}
{/* ── 原始上下文 ── */} {/* ── 原始上下文 ── */}
<details className="group"> <details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-ink-500 transition-colors hover:text-ink-700"> <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.5 w-3.5 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true"> <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" /> <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> </svg>
</summary> </summary>
<pre className="mt-2 max-h-80 overflow-auto rounded-card border border-ink-700 bg-ink-900 p-4 font-mono text-2xs leading-relaxed text-ink-300"> <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} {prediction.context}
</pre> </pre>
</details> </details>
</div> </div>
</div> </article>
) )
} }
const STATUS_BADGE: Record<string, { label: string; cls: string }> = { const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: '正常', cls: 'bg-emerald-50 text-emerald-700 border-emerald-100' }, ok: { label: '正常', cls: 'text-ink-500' },
no_data: { label: '无数据', cls: 'bg-ink-100 text-ink-500 border-ink-200' }, no_data: { label: '无数据', cls: 'text-ink-400' },
error: { label: '调用失败', cls: 'bg-red-50 text-red-600 border-red-100' }, error: { label: '调用失败', cls: 'text-press' },
parse_error: { label: '解析失败', cls: 'bg-amber-50 text-amber-700 border-amber-100' }, parse_error: { label: '解析失败', cls: 'text-press' },
} }
const SUFFICIENCY_LABEL: Record<string, string> = { const SUFFICIENCY_LABEL: Record<string, string> = {
@@ -664,32 +597,32 @@ const SUFFICIENCY_LABEL: Record<string, string> = {
none: '无', none: '无',
} }
function AgentCard({ report: r }: { report: AgentReport }) { /** 单路专家意见:汉字编号 + 细线行 */
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'bg-ink-100 text-ink-500 border-ink-200' } 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' const inactive = r.status !== 'ok'
return ( return (
<details className="group rounded-card border border-ink-200 bg-white transition-colors open:border-ink-300 hover:border-ink-300"> <details className="group border-b border-ink-200">
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-3"> <summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 flex-shrink-0 text-ink-400 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true"> <span className="font-serif text-sm text-ink-400">{no}</span>
<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" /> <span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
</svg> <span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
<span className="text-sm font-medium text-ink-800">{AGENT_LABELS[r.agent] ?? r.agent}</span> <span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
<span className={`pill border ${badge.cls}`}>{badge.label}</span>
<span className="ml-auto flex items-center gap-3 text-2xs tabular-nums text-ink-500">
{r.status === 'ok' && r.subjective_confidence !== null && ( {r.status === 'ok' && r.subjective_confidence !== null && (
<span> {Math.round(r.subjective_confidence * 100)}%</span> <span> {Math.round(r.subjective_confidence * 100)}%</span>
)} )}
{r.status === 'ok' && r.probable_score && ( {r.status === 'ok' && r.probable_score && (
<span className="rounded bg-ink-100 px-1.5 py-0.5 font-medium text-ink-600">{r.probable_score}</span> <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> </span>
</summary> </summary>
<div className="space-y-3 border-t border-ink-100 px-3.5 pb-3.5 pt-3"> <div className="space-y-3 px-1 pb-4 pl-7">
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} {/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
{inactive && ( {inactive && (
<p className="text-xs leading-relaxed text-ink-500"> <p className="text-xs leading-relaxed text-ink-500">
@@ -701,9 +634,9 @@ function AgentCard({ report: r }: { report: AgentReport }) {
{!inactive && r.home_edge !== null && ( {!inactive && r.home_edge !== null && (
<div> <div>
<div className="mb-1.5 flex items-center justify-between text-2xs"> <div className="mb-1.5 flex items-baseline justify-between text-2xs">
<span className="font-medium text-ink-500"></span> <span className="text-ink-500"></span>
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-brand-600' : r.home_edge < 0 ? 'text-amber-600' : 'text-ink-500'}`}> <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)} {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
</span> </span>
</div> </div>
@@ -716,14 +649,14 @@ function AgentCard({ report: r }: { report: AgentReport }) {
)} )}
{r.analysis && ( {r.analysis && (
<p className="text-sm leading-relaxed text-ink-700">{r.analysis}</p> <p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
)} )}
{r.key_evidence.length > 0 && ( {r.key_evidence.length > 0 && (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{r.key_evidence.map((e, i) => ( {r.key_evidence.map((e, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600"> <li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
<span className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-ink-300" /> <span className="flex-shrink-0 text-ink-300" aria-hidden="true"></span>
<span>{e}</span> <span>{e}</span>
</li> </li>
))} ))}
@@ -731,20 +664,18 @@ function AgentCard({ report: r }: { report: AgentReport }) {
)} )}
{r.exp_home_goals !== null && r.exp_away_goals !== null && ( {r.exp_home_goals !== null && r.exp_away_goals !== null && (
<div className="flex items-center gap-2 rounded-lg bg-ink-50 px-3 py-2"> <p className="text-xs text-ink-500">
<span className="text-2xs font-medium text-ink-500"></span> <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
<span className="text-sm font-semibold tabular-nums text-ink-800"> </p>
{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
</span>
</div>
)} )}
{!inactive && ( {!inactive && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-ink-100 pt-2.5 text-2xs text-ink-400"> <p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
<span> {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}</span> {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
<span className="mx-2 text-ink-200">|</span>
<span className="font-mono">{r.model}</span> <span className="font-mono">{r.model}</span>
{r.latency_ms !== null && <span className="tabular-nums">{r.latency_ms}ms</span>} {r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
</div> </p>
)} )}
</div> </div>
</details> </details>
+35 -57
View File
@@ -4,79 +4,57 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// 品牌主色:偏冷的蓝,比默认 blue-600 更沉稳,适合数据产品 // 纸白:微暖底色,像新闻纸而不是纯白画布
brand: { paper: {
50: '#EFF6FF', 50: '#FDFCF8',
100: '#DBEAFE', 100: '#F7F4EC',
200: '#BFDBFE', 200: '#EDE9DE',
300: '#93C5FD', 300: '#DDD7C7',
400: '#60A5FA',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A',
950: '#172554',
}, },
// 中性色:带一点冷调,避免纯灰显得脏 // 墨色:暖黑灰阶,替代冷调 slate
ink: { ink: {
50: '#F8FAFC', 50: '#FAF9F7',
100: '#F1F5F9', 100: '#F0EEE9',
200: '#E2E8F0', 200: '#E2DFD7',
300: '#CBD5E1', 300: '#C9C4B8',
400: '#94A3B8', 400: '#9C9587',
500: '#64748B', 500: '#6E675B',
600: '#475569', 600: '#524C42',
700: '#334155', 700: '#3B362E',
800: '#1E293B', 800: '#282420',
900: '#0F172A', 900: '#17140F',
},
// 印报红:全站唯一强调色,克制使用
press: {
DEFAULT: '#9E1B1B',
dark: '#7C1414',
wash: '#F7E9E4',
}, },
// 语义色:状态徽章 / 提示条统一走这套
win: { bg: '#ECFDF5', fg: '#047857', line: '#A7F3D0' },
draw: { bg: '#FFFBEB', fg: '#B45309', line: '#FDE68A' },
loss: { bg: '#FEF2F2', fg: '#B91C1C', line: '#FECACA' },
}, },
fontFamily: { fontFamily: {
// 标题与比分:宋体血统,报纸版面的骨架
serif: [
'Georgia',
'"Times New Roman"',
'"Songti SC"',
'STSong',
'SimSun',
'"Noto Serif CJK SC"',
'serif',
],
// 正文:中文黑体栈
sans: [ sans: [
'ui-sans-serif',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'"PingFang SC"', '"PingFang SC"',
'"Hiragino Sans GB"', '"Hiragino Sans GB"',
'"Microsoft YaHei"', '"Microsoft YaHei"',
'"Noto Sans CJK SC"',
'sans-serif', 'sans-serif',
], ],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
}, },
fontSize: { fontSize: {
// 多一档 2xs,给徽章/元信息用
'2xs': ['11px', { lineHeight: '16px' }], '2xs': ['11px', { lineHeight: '16px' }],
}, },
borderRadius: {
card: '12px',
pill: '9999px',
},
boxShadow: {
// 卡片阴影:极浅,靠层次而非重影提升
card: '0 1px 2px 0 rgb(15 23 42 / 0.04), 0 1px 3px 0 rgb(15 23 42 / 0.06)',
raise: '0 4px 12px -2px rgb(15 23 42 / 0.08), 0 2px 6px -2px rgb(15 23 42 / 0.04)',
focus: '0 0 0 3px rgb(37 99 235 / 0.15)',
},
keyframes: {
'fade-up': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
},
animation: {
'fade-up': 'fade-up 0.22s ease-out both',
shimmer: 'shimmer 1.4s linear infinite',
},
}, },
}, },
plugins: [], plugins: [],