Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df46544b8 | ||
|
|
d847f4f3f4 |
+34
-45
@@ -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
@@ -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
@@ -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
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user