Author SHA1 Message Date
WorkBuddy 83a1eed283 fix+polish(frontend): 预测弹窗打磨 + 全量语义色替换 + 前台交互小项
预测弹窗:
- 修复模板字符串直写 JSX,反引号原样显示在结果面板的 bug
- 入场动画(遮罩淡入+面板上浮);初始聚焦/Tab 焦点陷阱/关闭还焦;
  打开时锁定背景滚动
- 「关闭窗口即取消」→ 如实描述:前端仅停止进度显示,后台任务
  可能仍在执行并消耗额度

语义色:全部 emerald/amber/rose 裸色替换为 ok/warn/bad token
(Standings 分区与走势、KeyRing、定时任务、任务状态点、
数据完整性进度条、预测命中标记、净胜球)。

前台交互:
- 空态跳后台 a[href] → Link,不再整页刷新
- 联赛 tab 溢出右缘渐隐提示 + aria-current
- 状态行右侧计数组加细线分隔,小屏 wrap 后层级仍清晰
2026-09-22 16:56:19 +08:00
WorkBuddy 9c77efaa4b refactor(frontend): 报头抽组件 Masthead + 回到顶部抽组件 BackTop
- Masthead:统一 HomePage/StandingsLayout 双份复制实现,当前版面
  印报红高亮 + aria-current;⚙ 字符改线条 SVG;管理类入口加 title
  提示需登录,访客不再被无声踢到登录页
- BackTop:两页各一份的浮动按钮收敛为共享组件,去 rounded-full/
  shadow-lg,与全站方角无阴影语言对齐
2026-09-22 16:55:59 +08:00
WorkBuddy b9beddacf7 feat(design): ok/warn/bad 语义色 token + btn-outline + 弹窗动效 keyframes
- tailwind 新增语义状态色三组(纸底文字级 600/700 对比度 ≥4.5:1,AA),
  色相降饱和贴近墨色印刷感;后续替换全部裸 tailwind 原色(emerald/amber/rose)
- 补 btn-outline 定义:此前三处使用但从未定义,样式静默失效
- 弹窗入场动画 keyframes:遮罩淡入 0.18s + 面板上浮 0.2s,仅 opacity/transform
2026-09-22 16:55:58 +08:00
shangfangjian 432bfee8ad Merge pull request 'feat(logging): 日志持久化落盘(LOG_FILE 滚动文件)' (#14) from log-persistence into main
Reviewed-on: #14
2026-09-22 13:34:46 +08:00
shangfangjian f1c586111f Merge pull request 'admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史' (#15) from admin-ux-overhaul into main
Reviewed-on: #15
2026-09-22 13:34:37 +08:00
WorkBuddy f0c1cc1491 feat(logging): 日志持久化落盘(LOG_FILE 滚动文件)
- LOG_FILE 配置: 空(默认)保持 stdout + Admin 内存日志页(重启清零);
  填路径后额外写 RotatingFileHandler(单文件 10MB × 5 份,utf-8)
- setup_logging 幂等挂载(按 abspath 判重,相对/绝对同文件算一个);
  目录自动创建;文件基础设施失败只 warning 绝不拖垮启动
- docker-compose: api 挂 applogs 卷,默认 LOG_FILE=/app/logs/app.log,
  容器重建日志不丢;.env.example/.gitignore/docs 同步
- tests/test_log_persistence.py: 落盘/幂等/空值禁用/自动建目录/失败降级
- test_p0_standings_cutoff: 匿名约束 name=None 使 any() 子串匹配 TypeError
  (集合顺序不定 → flaky),先判真值再匹配(存量缺陷顺手加固)
2026-09-22 12:18:03 +08:00
22 changed files with 481 additions and 173 deletions
+3
View File
@@ -3,6 +3,9 @@
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
APP_ENV=development
LOG_LEVEL=INFO
# 日志持久化:空=仅 stdout + Admin 内存日志页(重启清零);填路径则额外写滚动文件(10MB×5)。
# 本地开发示例: LOG_FILE=./logs/app.log (容器内由 compose 默认设为 /app/logs/app.log 并挂卷)
LOG_FILE=
API_PORT=8000
FRONTEND_PORT=3000
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
.pytest_cache/
frontend/node_modules/
frontend/dist/
logs/
# AI 助手上下文文件(不入库)
CLAUDE.md
+1 -1
View File
@@ -203,7 +203,7 @@ Profeto/
│ │ ├── config.py # pydantic-settings 配置
│ │ ├── crypto.py # 加密/哈希
│ │ ├── http_client.py # 共享 httpx 客户端
│ │ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
│ │ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
│ │ ├── scheduler.py # 进程内 cron 调度器
│ │ └── security_check.py # 启动安全校验
+4
View File
@@ -28,6 +28,8 @@ services:
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
# 日志持久化:写入挂载卷,容器重建不丢(应用内滚动 10MB×6 份封顶)
LOG_FILE: /app/logs/app.log
env_file: .env
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=5).status==200 else 1)\" || exit 1"]
@@ -42,6 +44,7 @@ services:
- ./src:/app/src
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini
- applogs:/app/logs
frontend:
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
@@ -57,3 +60,4 @@ services:
volumes:
pgdata:
applogs:
+1
View File
@@ -86,6 +86,7 @@ cd frontend && npm install && npm run dev
|---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `LOG_FILE` | ❌ | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
+1 -1
View File
@@ -117,7 +117,7 @@ Profeto/
│ ├── config.py # pydantic-settings 配置
│ ├── http_client.py # 共享 httpx 客户端
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
+5 -58
View File
@@ -11,48 +11,17 @@
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
*/
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import Masthead from './components/Masthead'
import Matches from './pages/Matches'
import Standings from './pages/Standings'
import { adminRoutes } from './admin/routes'
/** 报眉日期行 */
function dateLine(): string {
return new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
}
function StandingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-paper-50">
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
/
</Link>
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</Link>
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span>
</Link>
</nav>
</div>
</div>
</header>
<Masthead active="standings" />
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
{children}
@@ -70,30 +39,8 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
function HomePage() {
return (
<div className="min-h-screen bg-paper-50">
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
</Link>
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
</Link>
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
<span aria-hidden="true"></span>
</Link>
</nav>
</div>
</div>
</header>
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */}
<Masthead active="home" />
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
<Matches />
+3 -3
View File
@@ -333,8 +333,8 @@ export default function CollectionPage() {
)}
{taskStatus === 'done' && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-emerald-700">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<div className="flex items-center gap-2 text-xs text-ok-700">
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
<span>{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
</div>
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
@@ -373,7 +373,7 @@ export default function CollectionPage() {
<div>
{recentJobs.map(j => {
const st = j.status
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse'
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
return (
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
@@ -26,9 +26,9 @@ const FIELD_LABELS: Record<string, string> = {
}
function pctColor(pct: number): string {
if (pct >= 80) return 'bg-emerald-500'
if (pct >= 50) return 'bg-amber-500'
return 'bg-rose-500'
if (pct >= 80) return 'bg-ok-500'
if (pct >= 50) return 'bg-warn-500'
return 'bg-bad-500'
}
/** 根据问题描述生成可操作的修复链接 */
@@ -90,7 +90,7 @@ export default function PredictionHistoryPage() {
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() {
<td className="px-4 py-3">
{p.pred_1x2 ? (
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
predHit === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
predHit === false ? 'border-press text-press bg-press-wash' :
'border-ink-200 text-ink-600'
}`}>
+3 -3
View File
@@ -317,11 +317,11 @@ export default function SettingsPage() {
return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>}
</div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
@@ -489,7 +489,7 @@ export default function SettingsPage() {
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
<span className="text-xs font-medium text-ink-800">{s.id}</span>
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
</div>
+30
View File
@@ -0,0 +1,30 @@
/**
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
*/
import { useEffect, useState } from 'react'
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
const [show, setShow] = useState(false)
useEffect(() => {
const handleScroll = () => setShow(window.scrollY > threshold)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [threshold])
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center border border-ink-300 bg-paper-50 text-ink-600 transition-all duration-300 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 ${
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
)
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 报头组件(前台共用)。
*
* 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移
* (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。
* 现在统一为 Masthead:
* - active 声明当前版面,对应导航项加印报红高亮 + aria-current
* - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙)
* - 管理类入口带 title 提示,避免访客被无声踢到登录页
* 页脚文案各页不同,仍由调用方自行渲染。
*/
import { Link } from 'react-router-dom'
export type MastheadActive = 'home' | 'standings'
function GearIcon() {
return (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
function MastheadLink({
to,
active = false,
title,
children,
}: {
to: string
/** 当前版面才高亮;管理类入口无高亮概念 */
active?: boolean
title?: string
children: React.ReactNode
}) {
return (
<Link
to={to}
title={title}
aria-current={active ? 'page' : undefined}
className={`transition-colors ${
active ? 'font-medium text-press' : 'text-ink-500 hover:text-press'
}`}
>
{children}
</Link>
)
}
export default function Masthead({ active }: { active: MastheadActive }) {
return (
<header className="masthead-rule">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}</span>
<nav className="flex items-center gap-4" aria-label="页面导航">
<MastheadLink to="/" active={active === 'home'}> / </MastheadLink>
<MastheadLink to="/standings" active={active === 'standings'}></MastheadLink>
{/* 管理类页面对访客需要登录,入口处先说明,避免无声跳登录页 */}
<MastheadLink to="/admin/eval" title="评估页面向管理员开放,需登录"></MastheadLink>
<MastheadLink to="/admin" title="管理后台,需登录">
<span className="inline-flex items-center gap-1">
<GearIcon />
</span>
</MastheadLink>
</nav>
</div>
</div>
</header>
)
}
+22
View File
@@ -103,6 +103,28 @@
.btn-danger {
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
}
/* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合
(此前三处使用但从未定义,样式静默失效) */
.btn-outline {
@apply border-ink-900 bg-transparent text-ink-900
hover:border-press hover:bg-press-wash hover:text-press-dark;
}
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
.modal-overlay-enter {
animation: modal-fade 0.18s ease-out both;
}
.modal-panel-enter {
animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes modal-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── 统一空态 ── */
.empty-state {
+38 -33
View File
@@ -10,9 +10,11 @@
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
import BackTop from '../components/BackTop'
import { useMatchesList } from './matches/hooks/useMatchesList'
import { useMatchPredict } from './matches/hooks/useMatchPredict'
import { useLeagues } from './matches/hooks/useLeagues'
@@ -38,13 +40,8 @@ export default function Matches() {
loadMore,
} = useMatchesList({ onError: setError })
const {
predictingId,
prediction,
predictionFor,
predict,
closePredict,
} = useMatchPredict({ onError: setError })
const { predictingId, prediction, predictionFor, predict, closePredict } =
useMatchPredict({ onError: setError })
// ── 详情展开(懒加载,只读,不触发 LLM) ──
const [expandedId, setExpandedId] = useState<number | null>(null)
@@ -52,16 +49,8 @@ export default function Matches() {
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
const [detailLoading, setDetailLoading] = useState<number | null>(null)
// 监听滚动,超过 300px 显示回到顶部按钮
const [showBackTop, setShowBackTop] = useState(false)
useEffect(() => {
const handleScroll = () => setShowBackTop(window.scrollY > 300)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const leagueName = leagues.find(l => l.code === league)?.name ?? league
// 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled'
@@ -90,20 +79,46 @@ export default function Matches() {
}
}
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
const leagueNavRef = useRef<HTMLDivElement>(null)
const [canScrollRight, setCanScrollRight] = useState(false)
useEffect(() => {
const el = leagueNavRef.current
if (!el) return
const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8)
update()
el.addEventListener('scroll', update, { passive: true })
window.addEventListener('resize', update)
return () => {
el.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
}
}, [leagues])
return (
<div className="space-y-5">
{/* ── 联赛版面切换 ── */}
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
<div className="relative">
<nav
ref={leagueNavRef}
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)}
aria-current={league === l.code ? 'true' : undefined}
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
>
{l.name}
</button>
))}
</nav>
{canScrollRight && (
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
)}
</div>
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
@@ -120,7 +135,7 @@ export default function Matches() {
/>
</span>
<span className="ml-auto inline-flex items-center gap-3">
<span className="ml-auto inline-flex items-center gap-3 border-l border-ink-200 pl-4">
<span className="tabular-nums">
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
? `未来3天 ${visibleMatches.length} / 共 ${matches.length}`
@@ -203,9 +218,9 @@ export default function Matches() {
<>
<p className="empty-state-title"></p>
<p className="empty-state-sub"> {leagueName} </p>
<a href="/admin/collection" className="empty-state-action">
<Link to="/admin/collection" className="empty-state-action">
<span aria-hidden="true"></span>
</a>
</Link>
</>
)}
</div>
@@ -265,18 +280,8 @@ export default function Matches() {
)}
</section>
{/* 回到顶部按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
{/* ── 回到顶部(共享组件,方角纸片风) ── */}
<BackTop />
</div>
)
}
+16 -36
View File
@@ -6,6 +6,7 @@
*/
import { useEffect, useState, useCallback } from 'react'
import BackTop from '../components/BackTop'
import { fetchStandings } from '../admin/dal'
import type { StandingsLeague, StandingRow } from '../admin/dal'
import { useLeagues } from './matches/hooks/useLeagues'
@@ -13,24 +14,24 @@ import { Spinner } from '../admin/components'
const ZONE_META: Record<string, { label: string; cls: string }> = {
// 欧战资格
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
// 升级
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
// 降级
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
// 附加赛
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
}
@@ -44,7 +45,7 @@ function zoneBadge(zone?: string | null) {
/** 近期走势串(W/D/L) → 彩色圆点 */
function FormDots({ form }: { form?: string | null }) {
if (!form) return <span className="text-2xs text-ink-400"></span>
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
return (
<span className="inline-flex gap-0.5">
{form.slice(0, 5).split('').map((c, i) => (
@@ -62,17 +63,6 @@ export default function StandingsPage() {
const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false) // 切换联赛中
const [error, setError] = useState<string | null>(null)
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
// 监听滚动,超过 300px 显示回到顶部按钮
useEffect(() => {
const handleScroll = () => setShowBackTop(window.scrollY > 300)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const load = useCallback(async (code?: string) => {
setLoading(true)
setError(null)
@@ -134,7 +124,7 @@ export default function StandingsPage() {
</div>
{error && (
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
<div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
{error}
</div>
)}
@@ -202,7 +192,7 @@ export default function StandingsPage() {
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
<td className="text-center py-2 text-ink-500">{r.lost}</td>
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
</td>
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
@@ -221,18 +211,8 @@ export default function StandingsPage() {
</div>
)}
{/* 回到顶部按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
{/* 回到顶部(共享组件,方角纸片风) */}
<BackTop />
</div>
)
}
@@ -4,7 +4,7 @@
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../types'
import { AGENT_LABELS } from '../types'
@@ -82,7 +82,7 @@ function PredictionPanel({
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
<p className="text-center text-2xs text-ink-500">
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
@@ -161,7 +161,8 @@ function PredictProgress() {
</ul>
<p className="mt-6 text-center text-2xs text-ink-400">
, 30-90 ; token,使
, 30-90 ; token,使
,
</p>
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
@@ -185,17 +186,52 @@ export function PredictModal({
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
// ── 无障碍与滚动锁定 ──
const panelRef = useRef<HTMLDivElement>(null)
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => {
previouslyFocused.current = document.activeElement as HTMLElement | null
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
panelRef.current?.focus()
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'Escape') {
onClose()
return
}
if (e.key === 'Tab') {
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (!focusables || focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', h)
document.body.style.overflow = prevOverflow
// 关闭后把焦点还给触发元素
previouslyFocused.current?.focus()
}
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`}
@@ -203,7 +239,11 @@ export function PredictModal({
if (e.target === e.currentTarget) onClose()
}}
>
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
<div
ref={panelRef}
tabIndex={-1}
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
>
{/* 弹窗报头 */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
+27
View File
@@ -30,6 +30,33 @@ export default {
dark: '#7C1414',
wash: '#F7E9E4',
},
// ── 语义状态色:成功/警告/负面 ──
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
// 点/条级(500) ≥ 3:1;色相降饱和以贴近墨色印刷感,不使用 tailwind 原色。
ok: {
50: '#EAF3ED',
100: '#D5E8DC',
300: '#A3C9B1',
500: '#43925F',
600: '#2E7A4C',
700: '#1F6B45',
},
warn: {
50: '#FBF3E4',
100: '#F5E5C8',
300: '#E2C48E',
500: '#D97706',
600: '#A15C0B',
700: '#8F5109',
},
bad: {
50: '#FAEDED',
100: '#F6E3E3',
300: '#E4AFAF',
500: '#C24A4A',
600: '#A83B3B',
700: '#8F3030',
},
},
fontFamily: {
// 毛体草书(国内 CDN)+ 粗楷体回退
+2 -2
View File
@@ -124,8 +124,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
def create_app() -> FastAPI:
from src.core.log_buffer import setup_memory_logging
setup_memory_logging(settings.LOG_LEVEL)
from src.core.log_buffer import setup_logging
setup_logging(settings.LOG_LEVEL, settings.LOG_FILE)
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
+4
View File
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
# --- app ---
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
# 日志持久化:空(默认)只输出 stdout + Admin 内存日志页(重启清零)。
# 填文件路径(如 /app/logs/app.log)后额外写入滚动文件(单文件 10MB × 5 份),
# 进程/容器重启不丢。容器部署需配合 volume 挂载该目录,否则重建仍会丢。
LOG_FILE: str = ""
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
+46 -6
View File
@@ -65,14 +65,54 @@ def get_entries(
return out
def setup_memory_logging(level: str = "INFO") -> None:
"""挂载内存 handler 到 root logger(幂等),并确保 root 级别不低于 INFO。"""
def setup_logging(level: str = "INFO", log_file: str = "") -> None:
"""配置应用日志:stdout(容器收集) + 内存环形缓冲(Admin 日志页) + 可选滚动文件(持久化)。
幂等:重复调用不会重复挂 handler(文件 handler 按 abspath 判重,相对/绝对
路径指向同一文件视为同一个)。文件写入基础设施失败只记 warning,绝不
影响启动与业务 —— 与 _safe_write_ingest_failure 同级约束:可观测性
基础设施不许拖垮主流程。
"""
import os
from logging.handlers import RotatingFileHandler
root = logging.getLogger()
if any(isinstance(h, MemoryLogHandler) for h in root.handlers):
return
if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), logging.INFO))
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
handler = MemoryLogHandler()
handler.setLevel(logging.INFO)
handler.addFilter(_SQLNoiseFilter())
root.addHandler(handler)
if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), logging.INFO))
if not log_file:
return
# 滚动上限:单文件 10MB × 当前+5 份 ≈ 60MB,足够回溯数周的关键事件,
# 又不会吃满磁盘。格式含时间/级别/logger 名,便于事后 grep 排查。
target = os.path.abspath(log_file)
if any(
isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", None) == target
for h in root.handlers
):
return
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
file_handler = RotatingFileHandler(
target,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
)
file_handler.setLevel(logging.INFO)
file_handler.addFilter(_SQLNoiseFilter())
root.addHandler(file_handler)
logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
except Exception:
logging.getLogger(__name__).warning(
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
)
+127
View File
@@ -0,0 +1,127 @@
"""日志持久化(LOG_FILE)测试:setup_logging 启用滚动文件后日志必须落盘。
背景: 此前应用日志只进 stdout(docker json-file 收集,不可控) + Admin 内存
日志页(环形缓冲 2000 条,进程重启清零),没有任何应用层持久化 —— 排查
「昨晚采集为什么失败」这类问题时无据可查。本测试守护:
1. 传 log_file → root logger 挂 RotatingFileHandler,日志写入文件
2. 幂等:重复调用不重复挂 handler
3. log_file 为空 → 不挂文件 handler(保持旧行为)
4. 目录不存在 → 自动创建
5. 文件打开失败(如路径是已存在的目录) → 只降级不炸,stdout/内存日志仍在
范式: 直接操作 root logger + tmp_path;fixture 保存/恢复 root 状态,
并关闭新增 handler 的文件句柄(Windows 上句柄不关会锁住 tmp 目录)。
"""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
import pytest
from src.core.log_buffer import MemoryLogHandler, setup_logging
@pytest.fixture(autouse=True)
def _restore_root_logger():
"""保存/恢复 root logger;关闭测试期间新挂 handler 的句柄。"""
root = logging.getLogger()
saved_handlers = list(root.handlers)
saved_level = root.level
yield
for h in root.handlers:
if h not in saved_handlers and hasattr(h, "close"):
try:
h.close()
except Exception:
pass
root.handlers[:] = saved_handlers
root.setLevel(saved_level)
def _file_handlers():
return [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)]
def _memory_handlers():
return [h for h in logging.getLogger().handlers if isinstance(h, MemoryLogHandler)]
class TestFileHandlerAttached:
def test_attaches_rotating_file_handler(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
fhs = _file_handlers()
assert len(fhs) == 1
assert fhs[0].baseFilename == str(log_file)
# 内存日志页照常工作,两者并存
assert len(_memory_handlers()) == 1
# 滚动参数与文档口径一致: 单文件 10MB × 5 份
assert fhs[0].maxBytes == 10 * 1024 * 1024
assert fhs[0].backupCount == 5
def test_log_written_to_file(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
logging.getLogger("persist-test").info("hello-persist-12345")
content = log_file.read_text(encoding="utf-8")
assert "hello-persist-12345" in content
assert "persist-test" in content # logger 名可追溯
assert "INFO" in content # 级别在行首可过滤
class TestIdempotent:
def test_repeated_call_does_not_duplicate_handlers(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
setup_logging("INFO", str(log_file))
setup_logging("INFO", str(log_file))
assert len(_file_handlers()) == 1
assert len(_memory_handlers()) == 1
def test_same_file_via_relative_and_absolute_path_counts_as_one(self, tmp_path, monkeypatch):
"""相对/绝对路径指向同一文件时不得重复挂(幂等按 abspath 判重)。"""
monkeypatch.chdir(tmp_path)
setup_logging("INFO", "app.log")
setup_logging("INFO", str(tmp_path / "app.log"))
assert len(_file_handlers()) == 1
class TestDisabled:
def test_empty_log_file_keeps_old_behavior(self):
setup_logging("INFO", "")
assert _file_handlers() == []
assert len(_memory_handlers()) == 1
class TestAutoMkdir:
def test_creates_missing_directories(self, tmp_path):
log_file = tmp_path / "deep" / "nested" / "app.log"
setup_logging("INFO", str(log_file))
assert len(_file_handlers()) == 1
assert log_file.parent.is_dir()
class TestGracefulDegradation:
def test_unopenable_path_degrades_without_raising(self, tmp_path):
"""路径是已存在的目录 → 打开必然失败;只降级,不炸启动。"""
dir_as_file = tmp_path / "occupied"
dir_as_file.mkdir()
# 不应抛异常(文件日志是可观测性基础设施,失败只 warning)
setup_logging("INFO", str(dir_as_file))
assert _file_handlers() == []
# 降级后 stdout/内存日志路径仍在
assert len(_memory_handlers()) == 1