3 Commits
Author SHA1 Message Date
shangfangjian 476156524f Merge pull request 'feat(ui): 编辑排版三件套与信息效率升级' (#6) from feat-editorial-polish into fix-a11y-typography
Reviewed-on: #6
2026-09-18 12:21:55 +08:00
shangfangjian 2190ea2af6 feat(ui): 编辑排版三件套与信息效率升级
- 报头升格:刊名 60px,Oxford 粗细双线,期号行(当年第几天)+体育版位
- 预测比分:主客队名纵排(writing-mode vertical-rl),右上「终裁」红印
- 五路专家倾向条:home_edge 分歧可视化,红=利主/墨=利客,条高=强度
- 强调色纪律:tab 激活态与后台导航选中改墨色,红只留 live/终裁/印章/错误
- 数字字形:serif 栈 Noto Serif SC 提到 Georgia 之前(直线数字),斜体只留给刊名(.wordmark)
- 赛程行悬停左缘红书签;空态改双线「启事」框
2026-09-18 11:24:19 +08:00
shangfangjian 1a37c75907 fix(a11y): ink-400 对比度达标,接入 Noto Serif SC webfont,补纸张噪点与入场编排
- ink-400 #9C9587(2.90:1)→ #756F61(paper-50 4.87:1 / paper-100 4.55:1),正文级小字全部达标
- serif 栈插入 Noto Serif SC(fontsource 子集加载),Windows 不再跌进 SimSun
- 首页加 feTurbulence 纸张噪点层(.grain);报头/赛程行/预测版 rise-in 错峰入场(60ms 步进)
- reduce 动效用户:隐藏态与动画同在 no-preference 块,不会白屏
- Switch 提到模块顶层(修 render 重建丢焦点);a→Link(SPA 内链);h3→h2/h4→h3 修标题层级
- 按钮/输入热区抬高(btn 40px / btn-sm 32px / field py-2);index.css 硬编码色值改 :root 令牌
2026-09-18 11:04:46 +08:00
8 changed files with 274 additions and 81 deletions
+10
View File
@@ -8,6 +8,7 @@
"name": "profeto-frontend", "name": "profeto-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.6" "react-router-dom": "^6.30.6"
@@ -709,6 +710,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fontsource/noto-serif-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.30.6" "react-router-dom": "^6.30.6"
+28 -13
View File
@@ -6,7 +6,7 @@
* - /admin/* → 管理后台 * - /admin/* → 管理后台
*/ */
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches' import Matches from './pages/Matches'
import { adminRoutes } from './admin/routes' import { adminRoutes } from './admin/routes'
@@ -21,32 +21,47 @@ function dateLine(): string {
}) })
} }
/** 期号:当年第几天,报纸连载的仪式感 */
function issueNo(): number {
const now = new Date()
const start = new Date(now.getFullYear(), 0, 0)
return Math.floor((now.getTime() - start.getTime()) / 86400000)
}
function HomePage() { function HomePage() {
return ( return (
<div className="min-h-screen bg-paper-50"> // grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */} <div className="grain min-h-screen bg-paper-50">
<header className="masthead-rule"> {/* ── 报头:Oxford 双线 + 大字刊名 + 期号报眉 ── */}
<header className="masthead-rule rise-in">
<div className="mx-auto max-w-5xl px-5 sm:px-8"> <div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6"> <div className="border-b border-ink-900 py-6 text-center sm:py-8">
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900"> <h1 className="font-serif text-5xl font-bold leading-none tracking-[0.16em] text-ink-900 sm:text-6xl">
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500"> <span className="wordmark ml-3 align-baseline text-base font-normal italic tracking-normal text-ink-500 sm:text-lg">
Profeto Profeto
</span> </span>
</h1> </h1>
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500"> <p className="mt-3 text-2xs tracking-[0.4em] text-ink-500">
· · · ·
</p> </p>
</div> </div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500"> <div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span> <span className="tabular-nums">
<a <span className="text-ink-700"> {issueNo()} </span>
href="/admin" <span className="mx-2 text-ink-300" aria-hidden="true">|</span>
className="flex items-center gap-1.5 text-press hover:text-press-dark transition-colors" {dateLine()}
</span>
<span className="flex items-center gap-3">
<span className="tracking-[0.3em] text-ink-400"></span>
<Link
to="/admin"
className="flex min-h-[32px] items-center gap-1.5 text-press transition-colors hover:text-press-dark"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </Link>
</span>
</div> </div>
</div> </div>
</header> </header>
+11 -11
View File
@@ -6,7 +6,7 @@
*/ */
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom' import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'
import { fetchHealth } from './dal' import { fetchHealth } from './dal'
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -90,14 +90,14 @@ export default function AdminLayout() {
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4"> <div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900"> <h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
<span className="ml-2 align-baseline font-serif text-xs font-normal italic tracking-normal text-ink-500"> <span className="wordmark ml-2 align-baseline text-xs font-normal italic tracking-normal text-ink-500">
Profeto Profeto
</span> </span>
</h1> </h1>
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span> <span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
</div> </div>
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */} {/* 导航:选中项以墨色方块标记(红色只留给异常状态,与前台纪律一致) */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航"> <nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{NAV_ITEMS.map(item => ( {NAV_ITEMS.map(item => (
@@ -108,7 +108,7 @@ export default function AdminLayout() {
className={({ isActive }) => className={({ isActive }) =>
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${ `flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
isActive isActive
? 'bg-press-wash/60 font-medium text-press' ? 'bg-paper-200/70 font-medium text-ink-900'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900' : 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}` }`
} }
@@ -116,7 +116,7 @@ export default function AdminLayout() {
{({ isActive }) => ( {({ isActive }) => (
<> <>
<span <span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`} className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-ink-900' : 'bg-transparent'}`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="text-base leading-none opacity-50" aria-hidden="true"> <span className="text-base leading-none opacity-50" aria-hidden="true">
@@ -133,13 +133,13 @@ export default function AdminLayout() {
{/* 底部 */} {/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3"> <div className="border-t border-ink-200 px-4 py-3">
<a <Link
href="/" to="/"
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press" className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </Link>
</div> </div>
</aside> </aside>
@@ -170,13 +170,13 @@ export default function AdminLayout() {
/> />
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'} {healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span> </span>
<a <Link
href="/" to="/"
className="text-press transition-colors hover:text-press-dark sm:hidden" className="text-press transition-colors hover:text-press-dark sm:hidden"
aria-label="返回前台" aria-label="返回前台"
> >
</a> </Link>
</div> </div>
</header> </header>
+73 -13
View File
@@ -3,6 +3,12 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
/* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
:root {
--c-press: #9E1B1B;
--c-ink-900: #17140F;
}
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -19,7 +25,7 @@
/* 统一焦点环:印报红细线,键盘可达 */ /* 统一焦点环:印报红细线,键盘可达 */
:focus-visible { :focus-visible {
outline: 2px solid #9e1b1b; outline: 2px solid var(--c-press);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -51,43 +57,97 @@
} }
@layer components { @layer components {
/* ── 报头双线:粗线在上、细线在下 ── */ /* ── 报头 Oxford 粗细双线:3px 粗线 + 2px 空 + 1px 细线 ── */
.masthead-rule { .masthead-rule {
border-top: 3px solid #17140f; border-top: 3px solid var(--c-ink-900);
position: relative;
}
.masthead-rule::before {
content: '';
position: absolute;
top: 2px;
left: 0;
right: 0;
height: 1px;
background: var(--c-ink-900);
} }
/* ── 按钮:方正边框式,悬停反白 ── */ /* ── 刊名斜体专用:Georgia 提供真斜体(其 old-style 数字不适合比分,已从主栈让位) ── */
.wordmark {
font-family: Georgia, 'Times New Roman', serif;
}
/* ── 纵排文字:预测比分两侧的队名,中文报纸原生版面语言 ── */
.v-name {
writing-mode: vertical-rl;
letter-spacing: 0.3em;
}
/* ── 纸张噪点:feTurbulence 颗粒,给纸面一点纤维感 ──
加在页面根元素上,fixed 全屏覆盖,不响应指针 */
.grain::after {
content: '';
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.04;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ── 入场:整页一次编排好的升起序列 ──
只动 opacity/transform;错峰由元素上的 animation-delay 内联指定。
隐藏态必须与动画同在一个 no-preference 块里,否则 reduce 用户会看到永久空白 */
@media (prefers-reduced-motion: no-preference) {
.rise-in {
opacity: 0;
animation: rise-in 0.48s cubic-bezier(0.16, 1, 0.3, 1) both;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
/* ── 按钮:方正边框式,悬停反白;热区压到 40px(紧凑报纸行距下的折中) ── */
.btn { .btn {
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5 @apply inline-flex min-h-[40px] items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
text-sm text-ink-700 transition-colors duration-150 text-sm text-ink-700 transition-colors duration-150
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
disabled:cursor-not-allowed disabled:opacity-40 disabled:cursor-not-allowed disabled:opacity-40
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700; disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
} }
.btn-sm { .btn-sm {
@apply px-2.5 py-1 text-xs; @apply min-h-[32px] px-2.5 py-1 text-xs;
} }
.btn-solid { .btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press; @apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
} }
/* ── 表单控件:方正、无圆角 ── */ /* ── 表单控件:方正、无圆角;焦点态保留全局焦点环,只追加红边 ── */
.field { .field {
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800 @apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
transition-colors hover:border-ink-400 transition-colors hover:border-ink-400 focus:border-press;
focus:border-press focus:outline-none;
} }
/* ── 版面切换文字标签(联赛/状态/模式) ── */ /* ── 版面切换文字标签(联赛/状态/模式) ──
激活态用墨色:印报红只留给 live/终裁/印章/错误,维持 10% 纪律 */
.tab { .tab {
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900; @apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
} }
.tab-on { .tab-on {
@apply font-medium text-press; @apply font-medium text-ink-900;
} }
.tab-on::after { .tab-on::after {
content: ''; content: '';
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press; @apply absolute inset-x-0 bottom-0 h-0.5 bg-ink-900;
} }
/* ── 小节标题:宋体加粗 + 墨色底线 ── */ /* ── 小节标题:宋体加粗 + 墨色底线 ── */
+5
View File
@@ -1,6 +1,11 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
// 中文衬线 webfont:按需子集加载(fontsource 按 unicode-range 切片,只拉用到的字块),
// font-display: swap 由包内 CSS 自带,不会白屏
import '@fontsource/noto-serif-sc/400.css'
import '@fontsource/noto-serif-sc/600.css'
import '@fontsource/noto-serif-sc/700.css'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+138 -40
View File
@@ -176,6 +176,30 @@ function OutcomeLine({
) )
} }
/** 状态/模式一组的文字切换(模块级组件:定义在组件体内会每次 render 重建,导致焦点丢失) */
function Switch({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) {
return (
<span className="inline-flex items-center gap-2.5">
{items.map((it, i) => (
<span key={it.v} className="inline-flex items-center gap-2.5">
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
onClick={() => onChange(it.v)}
title={it.title}
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
>
{it.label}
</button>
</span>
))}
</span>
)
}
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')
@@ -276,28 +300,6 @@ export default function Matches() {
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
/** 状态/模式一组的文字切换 */
const Switch = ({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) => (
<span className="inline-flex items-center gap-2.5">
{items.map((it, i) => (
<span key={it.v} className="inline-flex items-center gap-2.5">
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
onClick={() => onChange(it.v)}
title={it.title}
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
>
{it.label}
</button>
</span>
))}
</span>
)
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* ── 联赛版面切换 ── */} {/* ── 联赛版面切换 ── */}
@@ -368,13 +370,19 @@ export default function Matches() {
{loading && <SkeletonRows n={4} />} {loading && <SkeletonRows n={4} />}
{!loading && matches.length === 0 && ( {!loading && matches.length === 0 && (
<div className="border-y border-ink-200 py-14 text-center"> /* 空态做成报纸「启事」体:外粗线 + 内细线框 */
<p className="font-serif text-sm text-ink-600"></p> <div className="border-b border-ink-900 py-10 text-center">
<p className="mt-1.5 text-xs text-ink-400"> {leagueName} </p> <div className="mx-auto max-w-sm border border-ink-300 px-6 py-6">
<p className="font-serif text-xs tracking-[0.5em] text-ink-500"></p>
<p className="mt-3 font-serif text-sm font-bold text-ink-800"></p>
<p className="mt-1.5 text-xs leading-relaxed text-ink-500">
{leagueName}
</p>
</div>
</div> </div>
)} )}
{!loading && matches.map(m => { {!loading && matches.map((m, idx) => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' } 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
@@ -384,10 +392,17 @@ export default function Matches() {
return ( return (
<div <div
key={m.id} key={m.id}
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${ className={`rise-in group relative border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
active ? 'bg-press-wash/50' : '' active ? 'bg-press-wash/50' : ''
}`} }`}
// 入场错峰:60ms 步进,封顶 420ms,长列表不拖沓
style={{ animationDelay: `${Math.min(idx * 60, 420)}ms` }}
> >
{/* 悬停书签:左缘红刻度,报眼式标记 */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-0.5 bg-press opacity-0 transition-opacity duration-150 group-hover:opacity-100"
/>
<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 flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */} {/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
<div className="flex items-center justify-between sm:contents"> <div className="flex items-center justify-between sm:contents">
@@ -482,6 +497,89 @@ export default function Matches() {
) )
} }
/** 终裁印章:预测版右上角的红印,全站强调色的点睛之处 */
function FinalSeal() {
return (
<span
aria-hidden="true"
className="absolute right-1 top-0 flex h-9 w-9 -rotate-6 items-center justify-center rounded-[2px] bg-press"
>
<span className="font-serif text-[13px] font-bold leading-[1.2] tracking-[0.15em] text-paper-50">
</span>
</span>
)
}
/** 预测比分:版面核心,大号宋体 + 纵排队名 + 终裁印章 */
function ScoreMast({
homeName,
awayName,
home,
away,
}: {
homeName: string
awayName: string
home: number | null
away: number | null
}) {
return (
<div className="relative text-center">
<FinalSeal />
<div className="flex items-center justify-center gap-5 sm:gap-7">
<span className="v-name font-serif text-sm text-ink-700">{homeName}</span>
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
{home ?? '-'}
<span className="mx-2 font-normal text-ink-300 sm:mx-3">:</span>
{away ?? '-'}
</p>
<span className="v-name font-serif text-sm text-ink-700">{awayName}</span>
</div>
<p className="mt-4 text-2xs tracking-[0.5em] text-ink-400"></p>
</div>
)
}
/** 五路专家倾向条:home_edge 的分歧可视化,红=利主,墨=利客,条高=强度 */
function ExpertLeans({ reports }: { reports: AgentReport[] }) {
const withEdge = reports.filter(
(r): r is AgentReport & { home_edge: number } => r.status === 'ok' && r.home_edge !== null,
)
if (withEdge.length === 0) return null
const summary = withEdge
.map(r => `${AGENT_LABELS[r.agent] ?? r.agent} ${r.home_edge > 0 ? '利主' : '利客'} ${Math.abs(r.home_edge).toFixed(2)}`)
.join('')
return (
<div
className="mt-3 flex items-end justify-center gap-5"
role="img"
aria-label={`五路专家倾向:${summary}`}
>
{withEdge.map((r, i) => {
const up = r.home_edge >= 0
const h = Math.round(Math.min(1, Math.abs(r.home_edge)) * 50)
return (
<div
key={r.agent}
className="flex flex-col items-center gap-1.5"
title={`${AGENT_LABELS[r.agent] ?? r.agent} · 主队优势 ${(r.home_edge > 0 ? '+' : '') + r.home_edge.toFixed(2)}`}
>
<div className="relative h-16 w-2 bg-ink-200">
<span className="absolute inset-x-0 top-1/2 h-px bg-ink-400" aria-hidden="true" />
<span
aria-hidden="true"
className={`absolute inset-x-0 ${up ? 'bottom-1/2 bg-press' : 'top-1/2 bg-ink-600'}`}
style={{ height: `${h}%` }}
/>
</div>
<span className="font-serif text-2xs text-ink-500">{CN_NUM[i] ?? i + 1}</span>
</div>
)
})}
</div>
)
}
function PredictionPanel({ function PredictionPanel({
prediction, prediction,
match, match,
@@ -496,12 +594,13 @@ function PredictionPanel({
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok') const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return ( return (
<article className="border border-ink-900 bg-paper-50"> // rise-in:预测版面整块入场,内部区块再错峰
<article className="rise-in border border-ink-900 bg-paper-50">
{/* 版头 */} {/* 版头 */}
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5"> <div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="font-serif text-sm font-bold text-ink-900"> <h2 className="font-serif text-sm font-bold text-ink-900">
· {homeName} {awayName} · {homeName} {awayName}
</h3> </h2>
<span className="text-2xs tabular-nums text-ink-500"> <span className="text-2xs tabular-nums text-ink-500">
{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`}
@@ -509,15 +608,13 @@ function PredictionPanel({
</div> </div>
<div className="space-y-7 px-4 py-6 sm:px-5"> <div className="space-y-7 px-4 py-6 sm:px-5">
{/* ── 预测比分:版面核心,大号宋体 ── */} {/* ── 预测比分:版面核心,纵排队名 + 终裁印章 ── */}
<div className="text-center"> <ScoreMast
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl"> homeName={homeName}
{prediction.pred_home_goals ?? '-'} awayName={awayName}
<span className="mx-3 font-normal text-ink-300">:</span> home={prediction.pred_home_goals}
{prediction.pred_away_goals ?? '-'} away={prediction.pred_away_goals}
</p> />
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
</div>
{/* ── 胜平负 ── */} {/* ── 胜平负 ── */}
<div className="border-y border-ink-200 py-4"> <div className="border-y border-ink-200 py-4">
@@ -547,6 +644,7 @@ function PredictionPanel({
</div> </div>
<div> <div>
<ExpertLeans reports={prediction.agent_outputs} />
{prediction.agent_outputs.map((r, i) => ( {prediction.agent_outputs.map((r, i) => (
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} /> <AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))} ))}
@@ -557,7 +655,7 @@ function PredictionPanel({
{/* ── 终裁意见:引文式,红竖线 ── */} {/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && ( {prediction.reasoning && (
<section> <section>
<h4 className="section-head mb-3"></h4> <h3 className="section-head mb-3"></h3>
<blockquote className="border-l-2 border-press pl-4"> <blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700"> <p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{prediction.reasoning} {prediction.reasoning}
+5 -1
View File
@@ -12,12 +12,13 @@ export default {
300: '#DDD7C7', 300: '#DDD7C7',
}, },
// 墨色:暖黑灰阶,替代冷调 slate // 墨色:暖黑灰阶,替代冷调 slate
// 400 档压在 4.5:1 线上(paper-100 底 4.55:1 / paper-50 底 4.87:1),可作正文级小字
ink: { ink: {
50: '#FAF9F7', 50: '#FAF9F7',
100: '#F0EEE9', 100: '#F0EEE9',
200: '#E2DFD7', 200: '#E2DFD7',
300: '#C9C4B8', 300: '#C9C4B8',
400: '#9C9587', 400: '#756F61',
500: '#6E675B', 500: '#6E675B',
600: '#524C42', 600: '#524C42',
700: '#3B362E', 700: '#3B362E',
@@ -33,7 +34,10 @@ export default {
}, },
fontFamily: { fontFamily: {
// 标题与比分:宋体血统,报纸版面的骨架 // 标题与比分:宋体血统,报纸版面的骨架
// Noto Serif SC 站首位:拉丁与数字走它的直线字形(Georgia 是 old-style 数字,
// 比分 3:1 会高低不齐);Georgia 只保留给刊名斜体(.wordmark)
serif: [ serif: [
'"Noto Serif SC"',
'Georgia', 'Georgia',
'"Times New Roman"', '"Times New Roman"',
'"Songti SC"', '"Songti SC"',