diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b4859ac..623dca1 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "profeto-frontend",
"version": "0.1.0",
"dependencies": {
+ "@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
@@ -709,6 +710,15 @@
"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": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5eca45c..2d42d3e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b52a89c..815d65f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,7 +6,7 @@
* - /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 Matches from './pages/Matches'
import { adminRoutes } from './admin/routes'
@@ -23,9 +23,10 @@ function dateLine(): string {
function HomePage() {
return (
-
+ // grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
+
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
-
+
diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx
index bf9b952..cf75856 100644
--- a/frontend/src/admin/AdminLayout.tsx
+++ b/frontend/src/admin/AdminLayout.tsx
@@ -6,7 +6,7 @@
*/
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'
const NAV_ITEMS = [
@@ -133,13 +133,13 @@ export default function AdminLayout() {
{/* 底部 */}
@@ -170,13 +170,13 @@ export default function AdminLayout() {
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
-
前台
-
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a56d78c..5f2009a 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -3,6 +3,12 @@
@tailwind utilities;
@layer base {
+ /* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
+ :root {
+ --c-press: #9E1B1B;
+ --c-ink-900: #17140F;
+ }
+
html {
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
@@ -19,7 +25,7 @@
/* 统一焦点环:印报红细线,键盘可达 */
:focus-visible {
- outline: 2px solid #9e1b1b;
+ outline: 2px solid var(--c-press);
outline-offset: 2px;
}
@@ -53,29 +59,61 @@
@layer components {
/* ── 报头双线:粗线在上、细线在下 ── */
.masthead-rule {
- border-top: 3px solid #17140f;
+ border-top: 3px solid var(--c-ink-900);
}
- /* ── 按钮:方正边框式,悬停反白 ── */
+ /* ── 纸张噪点: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 {
- @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
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
disabled:cursor-not-allowed disabled:opacity-40
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
}
.btn-sm {
- @apply px-2.5 py-1 text-xs;
+ @apply min-h-[32px] px-2.5 py-1 text-xs;
}
.btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
}
- /* ── 表单控件:方正、无圆角 ── */
+ /* ── 表单控件:方正、无圆角;焦点态保留全局焦点环,只追加红边 ── */
.field {
- @apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
- transition-colors hover:border-ink-400
- focus:border-press focus:outline-none;
+ @apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
+ transition-colors hover:border-ink-400 focus:border-press;
}
/* ── 版面切换文字标签(联赛/状态/模式) ── */
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 964aeb4..d59d762 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
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'
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx
index 5b32364..7e0977d 100644
--- a/frontend/src/pages/Matches.tsx
+++ b/frontend/src/pages/Matches.tsx
@@ -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 (
+
+ {items.map((it, i) => (
+
+ {i > 0 && /}
+
+
+ ))}
+
+ )
+}
+
export default function Matches() {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
@@ -276,28 +300,6 @@ export default function Matches() {
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 }[]
- }) => (
-
- {items.map((it, i) => (
-
- {i > 0 && /}
-
-
- ))}
-
- )
-
return (
{/* ── 联赛版面切换 ── */}
@@ -374,7 +376,7 @@ export default function Matches() {
)}
- {!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 homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
@@ -384,9 +386,11 @@ export default function Matches() {
return (
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
@@ -496,12 +500,13 @@ function PredictionPanel({
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return (
-
+ // rise-in:预测版面整块入场,内部区块再错峰
+
{/* 版头 */}
-
+
预测版 · {homeName} 对 {awayName}
-
+
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
@@ -557,7 +562,7 @@ function PredictionPanel({
{/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && (
- 终裁意见
+ 终裁意见
{prediction.reasoning}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 2217c23..219b792 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -12,12 +12,13 @@ export default {
300: '#DDD7C7',
},
// 墨色:暖黑灰阶,替代冷调 slate
+ // 400 档压在 4.5:1 线上(paper-100 底 4.55:1 / paper-50 底 4.87:1),可作正文级小字
ink: {
50: '#FAF9F7',
100: '#F0EEE9',
200: '#E2DFD7',
300: '#C9C4B8',
- 400: '#9C9587',
+ 400: '#756F61',
500: '#6E675B',
600: '#524C42',
700: '#3B362E',
@@ -33,8 +34,11 @@ export default {
},
fontFamily: {
// 标题与比分:宋体血统,报纸版面的骨架
+ // 拉丁字形走 Georgia(含真斜体,供 Profeto 刊名),中文走 Noto Serif SC webfont;
+ // 原栈在 Windows 上会跌进 SimSun(点阵感),webfont 兜底解决跨端不一致
serif: [
'Georgia',
+ '"Noto Serif SC"',
'"Times New Roman"',
'"Songti SC"',
'STSong',