Compare commits

..
150 Commits
Author SHA1 Message Date
shangfangjian ae05833de3 feat: 实现 AnimatedBadge + CustomSelect 并接入 Collection/Dashboard 页
AnimatedBadge(状态标签):
- 七种语义(neutral/info/loading/success/warning/danger)+三种尺寸
- loading 状态脉冲点动画
- 接入 Collection 页(当前任务状态 + 最近任务历史)

CustomSelect(自定义下拉选择器):
- 带键盘交互(↑↓/Enter/Esc)+分组+图标+动画
- 命名避让原生 Select(CustomSelect)

Dashboard 页接入 InsightCard/InsightGrid/LoaderGrid 替代旧布局。
2026-09-23 02:13:25 +08:00
shangfangjian eedfc50bbc fix+integrate: 5 新组件接入 Dashboard/Logs 页面
Dashboard:
- 数据概览用 InsightGrid + InsightCard 展示预测/比赛/统计指标
- 数据源/已入库联赛卡片改用自定义布局(去除旧 Card 依赖)
- 加载态用 LoaderGrid

Logs:
- 日志列表改用 FilterTable(列排序 + 级别/关键字筛选)
- 加载态用 LoaderGrid row 模式
- 级别/Badge 筛选保留原有交互

FilterTable 泛型约束放宽为 Record<string, any>(兼容 interface)。
2026-09-23 01:48:30 +08:00
shangfangjian f33a1d91ff feat(P1): 新增 5 个数据展示组件(灵感来自 beautifului.dev)
- FilterTable  带列筛选+排序的增强表格
- InsightCard  指标洞察卡片(趋势+对比+状态色)
- SearchList   即时搜索列表(过滤+选中态)
- EventList    事件流(时间线+类型图标+展开详情)
- LoaderGrid   网格骨架屏(卡片/表格加载态)

均适配 Profeto 报纸风(衬线/纸色/细线),统一收口 components/ui。
2026-09-23 01:30:32 +08:00
shangfangjian e8e626e3e4 fix: Admin Logo 引用毛体字体子集
font-serif → font-brush,应用 Liu Jian Mao Cao 毛体子集
(local subset woff2,仅「先知」两字),与前台报头统一字体。
2026-09-23 00:37:20 +08:00
shangfangjian 812d19c9ed fix: Admin 侧栏 Logo 加大并居中
text-xl→text-3xl,添加 justify-center 与 text-center,
font-brush→font-serif font-bold tracking-wide 提升辨识度。
2026-09-23 00:29:31 +08:00
shangfangjian d863982527 fix: 移除冲突的 @eslint/js 直接依赖 + 更新 lockfile
远程新增 eslint 相关 devDependencies 但 package-lock.json 未同步,
@eslint/js@10 与 eslint@9 .peer 冲突导致 npm ci 失败(ERESOLVE)。

- 移除未使用的 @eslint/js 直接依赖(仅传递依赖保留)
- 用 npmmirror 镜像重新生成 package-lock.json

前端构建恢复成功。
2026-09-23 00:09:09 +08:00
shangfangjian e6373d3c9a chore(quality): 接入 ESLint 与 GitLab CI,开启严格未用检查,构建期设计令牌守卫
- eslint.config.mjs(flat config):react-hooks/react-refresh/tseslint;自定义 no-restricted-syntax
  规则禁止 className 手拼基元类名(error 级,JSXAttribute 选择器精确限定,探针双向验证)与硬编码色值
- tsconfig:noUnusedLocals/noUnusedParameters 置 true
- package.json 新增 typecheck/lint/test/verify:tokens 脚本;build 串联令牌校验(verify-tokens.sh
  防止 <alpha-value> 类静默失效回归);pnpm-lock.yaml 入库供 CI --frozen-lockfile
- .gitlab-ci.yml:lint/typecheck/test/build 四作业,MR 与默认分支触发,dist 产物留存一周
2026-09-22 23:45:23 +08:00
shangfangjian f71f29b4cb fix(frontend): 按审计报告修复全部17项问题,数据层迁至 src/api/
P0(4): 假数据清除(avg_latency_ms:2400→null,无端点字段改null)、假进度条改诚实的不确定态、
  公共页不再反向依赖 admin(api/public.ts)、PredictProgress 重写
P1(6): 23处 any 归零(对齐后端 Pydantic 契约新增 PredictionMatchRef/HealthProbe 等)、
  a11y(aria-live 0→4,htmlFor 1→17,aria-describedby/invalid 补齐)、App.tsx 抽 SiteLayout、
  路由级 lazy+代码分割(首屏 315KB→238KB)、index.html 补 SEO/favicon/OG、死代码清理(AdminIcon 抽出)
P2(7): Login/index.css 裸色值令牌化、groupByDate useMemo、滚动监听统一、原生控件基元化、
  useLeagues 静默失败补告警、路由级 ErrorBoundary

另修复审计未列问题:
- Monitoring todos 过滤器 t!==false 放行 null 导致整页崩溃 → Boolean(t) 真值过滤
- Collection 渲染期 Date.now()(react-hooks/purity 捕获)→ 计时器 effect
- bg-press-wash/60 透明度修饰符静默失效 → RGB 三元组 + 构建期令牌守卫(下个提交接入)

工程化:数据层 dal/api/types(1047行)git mv 至 src/api/,admin 留 @deprecated 兼容壳,
  21 个引用方直指新路径;tsconfig 开启 noUnusedLocals/noUnusedParameters(清理9处存量)
2026-09-22 23:45:06 +08:00
shangfangjian 2cfc9ccc0f refactor(ui): 合并双组件库为 components/ui 基元层,设计令牌变量化,报头字体子集自托管
- 新建 components/ui/(Button/Input/Select/Modal/Tabs/Spinner/Skeleton 等),变体用联合类型约束,根治幻影变体
- admin/components.tsx 与 matches/ui.tsx 改为再导出壳,消除 Spinner 三份重复
- 日期工具收口到 lib/date.ts,滚动监听收口到 lib/useScroll.ts
- index.css 引入 :root RGB 三元组令牌,tailwind.config 接 <alpha-value>(修复透明度修饰符静默失效)
- 刘建毛草 5MB 全字库(jsDelivr 未锁版) → 1KB 自托管子集(仅「先知」二字,unicode-range 限定)
2026-09-22 23:44:46 +08:00
shangfangjian a7e8b75503 Merge pull request 'feat(monitoring): 监控页三分区仪表 + 上游探针' (#22) from feat-monitoring-enrich into main
Reviewed-on: #22
2026-09-22 20:46:17 +08:00
shangfangjian 575507e44f Merge branch 'main' into feat-monitoring-enrich 2026-09-22 20:46:08 +08:00
WorkBuddy 5001093c3b feat(monitoring): 监控页三分区仪表 + 上游探针(信息丰度增强)
此前监控页 6 张卡只有 2 个布尔有效(版本/运行时间/检查项三张死卡,
后端 /health 从未返回对应字段),与顶栏健康点重复。

后端:
- /health 补 version(importlib.metadata,失败降级 None)与
  uptime_seconds(monotonic,不受时钟跳变影响) —— 激活两张死卡
- 新增 GET /admin/monitoring/upstream:bzzoiro 可达性探针,
  轻量 GET 根端点,不携带 Key、不消耗采集配额;HTTP<500 视为可达
  (DNS/网络/TLS 层面),业务失败由死信与日志上报

前端: Monitoring 重排为三分区仪表
- 基础设施: 存活 / DB 就绪 / 版本+运行时间 / 上游探针(延迟)
- 数据管线: 最近采集成功率 / 死信待处理 / 数据缺口 / 统计覆盖
- LLM: 预测总数 / 平均延迟 / 有效率(<80% 警示色)
- 顶部「需要关注」聚合条: 任一异常亮红并直达处理页,全清显示状态行
- 各信号独立容错(Promise.allSettled),单项失败只降级对应卡片

测试: 新增 test_monitoring_endpoints.py 5 例(ASGITransport 真路由 +
mock 探针外呼);全量 331 passed 8 skipped
2026-09-22 20:44:16 +08:00
shangfangjian 45535aa921 Merge pull request 'fix(admin): 数据完整性页补齐自动刷新(文案承诺但未实现)' (#21) from fix-completeness-refresh into main
Reviewed-on: #21
2026-09-22 19:54:51 +08:00
shangfangjian d7d903b35b Merge branch 'main' into fix-completeness-refresh 2026-09-22 19:54:39 +08:00
WorkBuddy a6740f6140 fix(admin): 数据完整性页补齐自动刷新(文案承诺但未实现)
页面描述写着「每 5 秒自动刷新」,实际只有挂载时加载一次 + 手动按钮
—— 采集完成后停在页面上的数字永远不动。

- 补 5s 自动轮询(可开关,复用 Logs 页的 checkbox 样式);已有数据时
  刷新不闪骨架(loading && !data 条件已具备)
- 采集页任务终态广播 profeto:ingest-done 事件,完整性页监听后
  立即刷新,不必等下一个 5s 周期
2026-09-22 19:52:39 +08:00
shangfangjian 38e1c6c31f Merge pull request 'fix(quality): 质量检查引用已删除的 MatchStats.id 必然 500 + 检查无日志' (#20) from fix-data-quality-check into main
Reviewed-on: #20
2026-09-22 18:20:49 +08:00
WorkBuddy 0742eef52e fix(quality): 质量检查引用已删除的 MatchStats.id 必然 500 + 检查无日志
生产实测: POST /admin/data-quality/run 100% 失败,且日志无任何记录。

根因链:
1. P0-02 把 MatchStats 主键改为 match_id(无 id 属性),但质量检查
   查询仍写 MatchStats.id.is_(None) → AttributeError → 500
2. handler 无 logger 调用;未捕获异常走 uvicorn.error(默认
   propagate=False),不经过 root handler → 内存日志页/文件日志
   都看不到 traceback,排障无据可查

修复:
- MatchStats.id → MatchStats.match_id(与 P0-02 模型对齐)
- 成功路径记 info 日志(检查名=通过/未通过(计数))
- setup_logging 打开 uvicorn/uvicorn.error 的 propagate,
  未捕获异常 traceback 进入内存缓冲与滚动文件
- 回归测试 3 例(mock session):修复前红(3 failed),修复后绿;
  全量 329 passed 8 skipped 零回归
2026-09-22 18:03:20 +08:00
WorkBuddy f00bf71e8f Merge pull request 'design: 全站设计打磨(语义色 token/弹窗动效与无障碍/报头收敛/交互小项)' (#19) from design-polish into main 2026-09-22 17:46:06 +08:00
WorkBuddy 246b06379d Merge pull request 'fix(admin): 日志页默认定位到最新而非最早' (#18) from fix-logs-order into main 2026-09-22 17:45:50 +08:00
WorkBuddy 3fc3e91bd3 Merge pull request 'fix(admin): /health 被误加 /api/v1 前缀,健康状态永远「系统异常」' (#17) from fix-healthcheck-url into main 2026-09-22 17:45:48 +08:00
WorkBuddy 007a276fb7 Merge pull request 'fix(docker): 预创建 /app/logs 属主,修复文件日志 PermissionError' (#16) from fix-log-volume-permission into main 2026-09-22 17:45:47 +08:00
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
WorkBuddy 77ecb078a3 fix(admin): 日志页改为保持倒序,打开自动定位到顶部(最新)
按用户预期调整方案:列表保持最新在最上面(后端倒序),打开页面/
自动刷新时 scrollTop=0 定位顶部;向下滚动回看历史时暂停定位,
拉回顶部即恢复。替代上一版的时间正序+贴底方案。
2026-09-22 15:39:19 +08:00
WorkBuddy 51fbc1828e fix(admin): 日志页默认定位到最新而非最早
渲染顺序(最新在前)与滚动逻辑假设(正序+贴底=最新)相互矛盾:
自动刷新(默认开启)每 10s 强制贴底,而底部恰好是最早的日志,
用户打开页面被钉在最旧的一条上。

改为渲染时间正序(旧→新),与贴底滚动/上滚暂停跟随的 tail -f
行为一致:打开页面即定位最新。
2026-09-22 15:35:59 +08:00
WorkBuddy 1422afd6f6 fix(admin): /health 请求被误加 /api/v1 前缀,健康状态永远「系统异常」
build_url 对所有路径强制加 /api/v1,但后端 /health 与 /health/ready
挂在根路径(app.py,与 compose healthcheck 一致)→ 前端请求 /api/v1/health
404 → fetchHealth 吞错返回 status=unknown → 右上角/监控页永远「系统异常」。

- build_url 对 /health 与 /health/* 豁免前缀
- http.test.ts 补 URL 豁免用例(该测试此前因 ApiError 参数属性不被
  strip-types 支持而完全无法运行,顺手改为显式赋值使基建可用)
2026-09-22 14:22:51 +08:00
WorkBuddy 7c4a244ce9 fix(docker): 预创建 /app/logs 并赋属主,修复卷挂载点 root 权限拒绝
生产实测: applogs 命名卷挂到 /app/logs 时挂载点由 Docker 以 root:root
创建,而非 root 进程(profeto)写入 app.log 时 PermissionError(Errno 13),
best-effort 降级生效但文件日志实际未启用。

- Dockerfile: 镜像内预创建 /app/logs 并 chown(chown -R 已有,覆盖新目录);
  命名卷为空且首次挂载时 Docker 会复制镜像目录属主,重建镜像即可修复
- log_buffer: PermissionError 单独捕获,warning 给出可操作的排查方向
  (镜像预建目录/chown/重建空卷),其余异常仍保持原通用降级
2026-09-22 14:14:53 +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 db3c64d49b test: 修复 standings 约束断言的 flaky(匿名约束 name=None)
与 log-persistence 分支同款修复:constraints 集合含匿名约束(name=None),
any() 子串匹配撞上 None 直接 TypeError,集合顺序不定导致间歇失败。
先判真值再匹配。
2026-09-22 12:46:43 +08:00
WorkBuddy d013407aa1 feat(admin): 采集任务历史列表 + 图标语言统一 + 存量样式修复
- dal 新增 fetchIngestJobs(接已有 GET /admin/ingest/jobs,此前前端未用)
- Collection 新增「最近任务」卡:最新 10 条,状态点/任务类型/结果摘要,
  任务终态后自动刷新 —— 补齐采集失败后的回看入口
- 任务选择按钮修复:brand-* 色在 tailwind 中不存在(选中态样式失效),
  改用全站 press/press-wash;去掉与报刊风方角语言不符的 rounded-lg
- 任务图标 🏆📊 → ◈◇▤⇉,与全站几何字符一致,不混用 emoji
- Logs 页描述补充 LOG_FILE 持久化边界说明
2026-09-22 12:44:29 +08:00
WorkBuddy 0ab838258c feat(admin): Settings 分区 tab 化 + Dashboard 改待办驱动
- Settings(496 行五块内容单页)拆为 4 个 tab:数据源/LLM/定时任务/登录认证;
  tab 状态写入 URL(?tab=llm)可深链;数据加载与 API 调用不变
- Dashboard 改待办驱动:死信待处理/联赛数据缺口/可结算预测,
  有待办才显示,每项直达处理页;全部清零时显示状态行
- 三步工作流卡仅在库里无比赛时显示(首次引导,老用户不再占版面)
- 修文案漂移:「数据源健康」卡不再引用已不存在的「数据源」页;
  「运行预测」链接语义修正(预测在前台比赛详情页发起)
- 数据源单源硬编码列表简化为一行;未配 Key 直达设置分区
- <a href> 全改 <Link>,不再整页刷新
2026-09-22 12:44:12 +08:00
WorkBuddy 9cedb874f7 refactor(admin): 导航按心智模型三组重排(数据/预测/系统)
- 预测历史/回测/评估移出「数据流水线」,组成「预测」组(生命周期闭环)
- 「评估与监控」拆开:评估归预测,监控归系统
- 删除 SIDEBAR_GROUP_TITLES 别名映射(分组名与面板名统一)
- 预测历史图标改 target(靶心),数据完整性改 eval(核对文档),不再与仪表盘共用 chart
2026-09-22 12:34:18 +08:00
WorkBuddy 1cdc6411f5 chore(admin): 删除合并后遗留的 4 个死代码页面(~1280 行)
DataSources/LLMConfig/Config 已合并进统一 Settings 页,
Predictions 已被 PredictionHistory 取代,均无任何路由或 import 引用。
2026-09-22 12:33:15 +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
shangfangjian 8fef411ced fix: 清理 LLM prompt 中的过时引用(confidence/injuries)
- 全部 agent/match_prediction prompt: deprecated "confidence" → "subjective_confidence"
- aggregator prompt: 移除已删除的 injuries 专家(→standings),修正专家中文名(阵容完整性→联赛排名)

消除预测日志中的 Deprecated field / 未知专家警告。
2026-09-22 11:05:07 +08:00
shangfangjian 197641b9f7 fix(P1-async): 预测改为异步 POST+轮询,避免网关超时(Cloudflare 524)
后端:POST /predict 立即返回 job_id,后台 asyncio.create_task 执行;
新增 GET /predict/jobs/{job_id} 轮询状态(running/success/failed)。

前端: useMatchPredict 改为 POST 拿 job_id → 每 3s 轮询直到终态;
整体超时 5 分钟不变。避免多专家预测 60-180s 触发 Cloudflare 100s 超时(HTTP 524)。
2026-09-22 11:00:40 +08:00
shangfangjian 1a9dc63edd fix: Standing 无 xgd 属性,用 xg_for - xg_against 计算 xG差
standings_slice._fmt 误用 row.xgd(Standing 无此列),预测时报 AttributeError。
修正:直接用 xg_for - xg_against 计算 xG差,去掉对 goal_diff 的冗余判断。
2026-09-22 10:52:26 +08:00
shangfangjian b15678b3f6 fix: http client URL 构造必须统一加 /api/v1 前缀
公开页调用 /matches(无 /api/v1 前缀),被 nginx 当 SPA 回退返回 index.html,
前端 JSON 解析报 "Unexpected token <"。

修复:build_url 函数确保所有相对路径统一走 /api/v1,
已含前缀或绝对 URL 保持不变。

首页加载比赛恢复正常。
2026-09-22 10:44:12 +08:00
shangfangjian 28ecf85da8 fix: MatchStats 主键是 match_id 非 id
scheduler._run_checks 误用 MatchStats.id(MatchStats 无此列,主键为 match_id),
导致数据质量检查定时任务抛 AttributeError。修正为 MatchStats.match_id.is_(None)。
2026-09-22 10:18:00 +08:00
shangfangjian b749b1c621 fix: 前端构建修复 + P3-1 子组件路径校正
- tsconfig 排除 *.test.ts(生产构建不编译测试)
- OutcomePanel TeamSideTag 导入路径 ../../ → ../../../
- AgentsPanel/OutcomePanel/ReasoningPanel types 导入 ../../ → ../
- MatchPredictPanel JSDoc 注释 **. → /** (P3-1 编辑损坏)

前端构建成功,容器 healthy。
2026-09-22 10:09:48 +08:00
shangfangjian 69ec19e646 fix: api healthcheck 改用 python urllib(生产镜像无 curl)
python:3.11-slim 生产镜像不含 curl,原 healthcheck 始终失败;
改为 python urllib.request,与 Dockerfile 非 root 用户兼容。
2026-09-22 09:59:50 +08:00
shangfangjian 5f4075be22 fix(P1 follow-ups): 恢复误删 set_runtime_value + 假 DB 适配 P1-K UPSERT
- 恢复 P1-G 编辑时误删的 clear_runtime_value/set_runtime_value
- 修复 bronze 测试 patch 目标(pipeline_write 替代门面 bz.*)
- 增强假 DB:处理 pg_insert(Insert 语句)+ Team name 回退 + League import

全量 319 通过。
2026-09-22 09:43:48 +08:00
shangfangjian eae88f4cd9 fix(P1-G/H/I/K): 运行时配置解密/Redis JSON/eval summary/UPSERT
P1-G runtime_config:DB 故障回落 env;解密失败在生产环境抛出(ValueError),
  非生产回落;DB 异常与解密异常不再共用 except Exception。
P1-H Redis 预测缓存:去掉 pickle 改用 JSON + dataclasses.asdict,
  datetime 字段 ISO 序列化。
P1-I eval get_eval_summary:默认 limit=None(不冒充全集);新增 run_type(默认 live)
  与 season 过滤;_build_filters 同步扩展。
P1-K Team/League get_or_create:INSERT 改为 PG UPSERT(ON CONFLICT DO NOTHING)
  防并发重复插入。

测试 test_p1_g_runtime_config(4/4) + test_p1_k_upsert(4/4);全量绿。
2026-09-22 09:08:35 +08:00
shangfangjian 2b52478b8f test(P1-F): BIGINT 主键表 INSERT 不带 id 自动生成验证
真 PG 测试(独立 engine,避免事件循环污染):
- data_lineage / raw_event 未指定 id → DB 自动生成递增唯一 id
- 连续 INSERT id 递增且唯一

3/3 通过。
2026-09-22 08:57:20 +08:00
shangfangjian b30ad56319 fix(P1-E): 启动时标 failed 残留 pending/running ingest_jobs
lifespan 加 _fail_stale_ingest_jobs():UPDATE ingest_jobs SET status=failed
WHERE status IN ('pending','running');异常仅记 warning 不阻断启动。
顺手修复 0021(op.text→字符串)/0023(索引名 leason→league)/0024(type_unique→type_)
三个待执行迁移 bug。

测试 test_p1_e_stale_ingest_jobs(2/2 mock);全量 315 通过。
2026-09-22 08:49:30 +08:00
shangfangjian b997c06ede fix(P1-D): 全局 LLM 并发限制 + 删除未接线的 provider 字段
- 删除 PredictRequest.provider(未接线,路由从未传入,符合禁令 #7)
- 新增 _GLOBAL_LLM_SEMAPHORE(默认 4) + _predict_with_concurrency:
  公开 /predict 所有模式汇总受全局并发限制,与 orchestrator 内 match 级
  Semaphore(8) 并存。

测试 test_p1_d_concurrency(4/4);全量 313 通过。
2026-09-22 03:44:09 +08:00
shangfangjian 1ddf697c97 fix(P1-C): 公开预测仅 live+success,且不含 reasoning/agent_outputs
GET /matches/{id}:查询加 WHERE run_type='live' AND status='success';
recent_predictions 不再输出 reasoning/agent_outputs(避免泄露内部推理)。
MatchOut.recent_predictions schema 放宽为 list[dict]。

测试 test_p1_c_public_predictions(2/2);全量 309 通过。
2026-09-22 03:39:36 +08:00
shangfangjian 5ff43d4984 fix(P1-A/P1-B): ingest 联赛级计数修正 + 非法 cursor 400
P1-A: ingest 联赛级 inserted/updated 读 r["leagues"][code] 而非顶层 r.get("inserted");
抽 _accumulate_ingest_result 纯函数 + 合约测试(4/4)。

P1-B: 非法 cursor 不再静默忽略,返回 400 + detail.code=INVALID_CURSOR;
抽 _parse_cursor 纯函数 + 测试(8/8)。

全量 307 通过。
2026-09-22 03:31:40 +08:00
shangfangjian 41cb2edd47 fix(P1-A): ingest 联赛级计数读 leagues[code],抽为纯函数
旧代码读 r.get("inserted")(顶层无此 key)导致联赛级计数总为 0;
改为读 r["leagues"][code].inserted/updated,与顶层 total_* 分离。
抽 _accumulate_ingest_result 纯函数 + 4 合约测试(全绿)。
2026-09-22 03:23:41 +08:00
shangfangjian 64ae8e663a fix(P0-03): Prediction 幂等指纹——只追加,不覆盖
_upsert_prediction 改为 _insert_or_find_by_fingerprint:
- 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs)
- 不同 input_hash → INSERT 新行

input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version,
prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature,
context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束,
改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。

三条路径(single/multi/baseline)统一传足指纹字段。
迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
2026-09-22 03:13:32 +08:00
shangfangjian 49d78136a1 fix(P0-02): 积分榜改为追加快照(append-only) + available_at cutoff
去掉 uq_standings_league_season_team,改为 (league, season, team, available_at) 唯一;
每次采集 INSERT 新行(available_at=now),ON CONFLICT DO NOTHING,不覆盖旧行。

standings_slice(before):DISTINCT ON (team_id) WHERE available_at<=cutoff
ORDER available_at DESC;before=None → cutoff=now()。
公开 list_standings 取每队最新可用快照(子查询 max available_at)。

迁移 0023 + 切片/路由/docs 同步;测试 test_p0_standings_cutoff(5/5)。
284 测试全绿。
2026-09-22 02:45:50 +08:00
shangfangjian 63caa6736c fix(P0-01): missing score 不得变 0:0 — score_status + CHECK 约束
新增 matches.score_status(known/missing/unknown):
- 替换 ck_matches_finished_has_score 为 ck_matches_score_integrity:
  known → 必须有分; missing/unknown → goals 必须 NULL(不伪造 0:0)
- normalize: 完赛缺分不再静默降级为 scheduled,改设 score_status=missing
- events ingest: 创建/更新 Match 同步 score_status(比分由缺变 known / 确认缺分 missing)
- slices(form/h2h/home_away)/backtest: 显式加 score_status='known' 过滤完赛样本
- 迁移 0022 回填现有数据(绝不 UPDATE goals=0)

测试 tests/test_p0_score_status.py(9/9):约束存在性/Match 构造/normalize 行为。
54 相关测试全绿。
2026-09-22 02:32:53 +08:00
shangfangjian f563d5cc99 fix(P0-00): HTTP client 必须传递 method/body,仅 body 存在时设 Content-Type
request() 此前忽略 options.method(默认 GET)与 options.body,导致 POST/PUT/DELETE
全部以 GET 空 body 发出;且无条件设置 Content-Type 误污染无 body 请求。

修复: 显式传递 method(默认 GET) 与 body; 仅当 body 非空时加 Content-Type。

测试 frontend/src/lib/http.test.ts(5/5):GET/POST/PUT/DELETE 的 method/body/headers。
运行: node --experimental-transform-types frontend/src/lib/http.test.ts
2026-09-22 02:24:50 +08:00
shangfangjian 7d2eabf750 chore: 死代码与重复逻辑清理
删除未引用/未调用符号:
- PredictionRepository(无引用)
- is_correct_1x2(无调用者)
- LeagueOut(路由用 list[dict])
- IngestResponse(IngestBzzoiroResponse 已替代)
- SecurityCheckError(从未 raise,assert_security_on_startup 用 sys.exit)
- short_write(仅自引用,全仓库无外部调用)
- fetchIngestJobs(列表函数无页面使用,单数 fetchIngestJob 仍保留)
- clear_prompt_cache(无入口)

去重:
- eval._actual_1x2 改为委托 utils.actual_1x2(单一权威源)

全量测试 270 通过,业务行为不变。
2026-09-22 01:55:52 +08:00
shangfangjian a00364d4a7 perf+unify: matches 列表查询优化 + Standings 统一数据源
matches 列表:selectinload 加 load_only 限定列(League.code/Team.name,name_zh/
MatchStats.home_xg,away_xg),补 stats 加载消除 N+1(m.stats 此前懒加载)。
详情接口保持完整 options;游标分页/响应字段/空值语义不变。

Standings 改用 useLeagues() 统一数据源(API 优先,失败回退本地常量),
LEAGUES 常量扩展 CL/EL;无数据联赛显示虚线 tab + 空态(非隐藏)。
2026-09-22 01:35:06 +08:00
shangfangjian 92f50fa5d4 feat: 采集任务状态跟踪 ingest_jobs
新增 ingest_jobs 表(UUID/task/params/status/result/error/时间戳),
POST /ingest/bzzoiro 启动前插入 job(pending)→ 后台 running → success/failed,
响应新增 job_id(兼容原 message)。

Admin GET /admin/ingest/jobs/{id} 与 /admin/ingest/jobs?limit= 只读查询;
Collection 页提交后轮询 job 至终态,展示真实 result/error 汇总。
迁移 0019_ingest_jobs + 分批 get_uow/BzzoiroSource/IngestFailure 不变。

全量测试 270 通过。
2026-09-22 01:34:55 +08:00
shangfangjian 44816794d3 refactor: context_builder 按 slice 拆到 src/llm/slices/ 包
单文件拆分(仅搬迁无逻辑修改):
- common.py    共享类型/头信息/_outcome/_is_stats_available
- form.py      form_slice + _get_form
- h2h.py       h2h_slice + _get_h2h
- stats.py     stats_slice(复用 form._get_form)
- home_away.py home_away_slice + _get_home_away
- standings.py standings_slice
- aggregate.py build_context

context_builder.py 改为纯 re-export 门面,公开签名不变。
同步修复测试 patch 目标(p0_home_away/h2h_perspective/multi_agent_cutoff)
与 regressions 源码断言(读 slices/*.py)。

全量测试 270 通过。
2026-09-22 01:33:14 +08:00
shangfangjian f1016b610a docs: predict.py docstring 补落库层级表格
明确 single/multi/baseline 均在服务层(_upsert_prediction)落库,
路由层永不写入 predictions,仅做 result → PredictOut 映射。
2026-09-22 01:19:19 +08:00
shangfangjian 03727bda00 refactor: 管线写入依赖解耦——pipeline_write 直接 import
bzzoiro_events / bzzoiro_standings / bzzoiro_stats 直接 import
src.data.pipeline_write(_write_raw_event/_write_lineage/_safe_write_ingest_failure),
删除 bzzoiro.py 门面中的 pipeline_write 转发胶水。

保留 fetch_*/_fetch_json_async/REQUEST_INTERVAL 经 bz. 门面调用(测试 monkeypatch 入口);
测试 best-effort 改为 patch 管线模块自身命名空间(from-import 绑定语义)。
函数语义与「失败不拖垮主流程」不变;source_record_id/transform_name 约定不变。

全量测试 270 通过。
2026-09-22 01:15:45 +08:00
shangfangjian 3a56ee17e1 feat: 预测缓存可选 Redis 后端(PREDICT_CACHE_URL)
PREDICT_CACHE_URL 为空=进程内 LRU+TTL dict(默认,行为不变);
填 redis:// 启用 Redis,失败自动降级内存并 warning,不中断预测。

- _CacheBackend 接口 + _MemoryCache/_RedisCache 两个后端
- 同键格式(predict:{match}:{provider}:{model}:{version}:{tpl_hash[:12}])
- 同 TTL(300s);Redis 用 pickle 序列化
- redis 包未安装/连接失败 → 降级内存;不强制依赖 redis 启动
- clear_prompt_cache 同步清空内存预测缓存

全量测试 270 通过;缓存后端单测 4/4。
2026-09-22 00:53:27 +08:00
shangfangjian 7593b99e39 docs+feat: matches 唯一键语义明确化 + source_event_id partial unique
docs/05-data.md:业务唯一=同联赛同主客同自然天;
source_event_id 用于统计回填与血缘,新增部分唯一索引说明与 upsert 查找顺序。

新增 ix_matches_source_event_id_unique(WHERE IS NOT NULL),
兼容存量空值历史行;MatchRepository.find_by_source_event_id;
events upsert 优先按 event_id 定位,回退自然键。
迁移 0021 + 回归测试修复(find_by_source_event_id 方法调用误判)。
2026-09-22 00:41:54 +08:00
shangfangjian e15b554ba3 feat: 球队实体一致性 — 归一化咽喉 + team_aliases 别名机制
events/standings 创建 Team 前均经 team_names.normalize(已有,确认),
TeamRepository.get_or_create 收敛为归一化唯一咽喉 + info 日志。

新增 team_aliases 表(NFKD 归一别名 → teams.id FK CASCADE),
定位三步链:normalize(name) → teams.name → team_aliases → insert。
不自动合并历史重复队;提供 POST /api/v1/admin/teams/aliases 显式添加。

迁移 0020_team_aliases + Admin 别名管理端点(admin_teams.py)。
全量测试 270 通过。
2026-09-22 00:27:53 +08:00
shangfangjian 4b0d6ee58a chore(P3): baseline 落库下沉 + MatchPredictPanel 拆分 + 多 worker/CSRF 文档
P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。

P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。

P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
2026-09-21 23:29:09 +08:00
shangfangjian 45497d2112 refactor: admin_settings.py 按职责拆分为 4 个模块
681 行单文件拆为(保留 admin_settings 为 include_router 聚合入口):
- admin_datasources  数据源列表/连通性测试/KeyRing/ingest status (5 路由)
- admin_config       settings CRUD + 运行日志 (4 路由)
- admin_llm          LLM agents/models/ping (3 路由)
- admin_quality      stats/data-completeness/data-quality (4 路由)

所有路由仍挂 /api/v1/admin 且带 dependencies=[Depends(require_admin)]。
app.py 注册方式不变(仍 import admin_settings.router)。
2026-09-21 23:28:58 +08:00
shangfangjian 66f0844798 feat: standings Bronze 血缘补齐 + 队名归一 MVP
standings 成功 upsert 后写入 RawEvent(source_record_id=standings:{league}:{season})
+ DataLineage(target_table=standings),与 events/stats 管线对称。

队名归一化收敛到 TeamRepository.get_or_create 唯一咽喉点,
创建新 Team 时 info 日志打出原始名与归一后名;
Admin 新增 GET /api/v1/admin/team-name-duplicates 只读接口,
启发式列出近似重名候选(大小写变体/子串/前缀碰撞),不做自动合并。
2026-09-21 23:28:46 +08:00
shangfangjian 317a5e338a refactor: bzzoiro.py 按管线拆分为 5 个模块
单文件 852 行按职责拆分,保持 BzzoiroSource 与 get_source("bzzoiro") 行为不变:
- bzzoiro_common  HTTP 抓取(多 key 轮换) + 字段转换原语
- bzzoiro_events   fetch_bzzoiro_events + BzzoiroSource.ingest + Bronze 补写
- bzzoiro_standings  standings 管线
- bzzoiro_stats     stats 回填
- pipeline_write    RawEvent/IngestFailure/DataLineage 写入助手

子模块运行期经聚合门面 src.data.bzzoiro 解析可替换协作者,
单文件时代的 bz.* monkeypatch 语义完全保留。
路由 import 已指向新模块(ingest.py / schedules.py)。
2026-09-21 23:27:48 +08:00
shangfangjian 7a5c695b89 Merge pull request '公开只读 API+ 全套文档对齐(P1-1)' (#11) from docs-public-api-fixes into main
Reviewed-on: #11
2026-09-21 21:38:58 +08:00
WorkBuddy 6bdb1f8ae6 docs(deploy): 补充生产上线检查清单(README + docs/06)
- docs/06 新增「生产上线检查清单」10 项:APP_ENV / SECRET_KEY 强随机 /
  管理鉴权二选一 / 数据库强密码 / HTTPS+Cookie Secure /
  TRUST_PROXY_HEADERS+X-Forwarded-For / Nginx limit_req 限流前置
  (应用内限流与 KeyRing 单进程限制) / uvicorn 单 worker /
  /health 与 /health/ready / alembic 迁移已内置
- README 在部署说明后加同名精简清单,链接到 docs/06 详情
- 全部条目均对齐真实实现(security_check 弱值黑名单、auth.py
  secure=production、deps.py TRUST_PROXY 解析、app.py 双健康检查、
  compose/Dockerfile 内置迁移);纯文档,未改业务逻辑
2026-09-21 21:02:16 +08:00
WorkBuddy 80616cf459 docs: 全套文档与代码对齐(P1-1,数据源收敛 bzzoiro 后的存量漂移清理)
- 数据源:全部文档统一为 bzzoiro 唯一来源,删除 understat/api-football
  作为现行数据源的表述(仅保留历史注记);.env.example/docs/06 移除
  失效的 API_FOOTBALL_KEY
- 多 Agent:injuries 阵容完整性 → standings 联赛排名
  (README/01/04 的专家表、数据流图、no_data 示例、agent_weights、
  prompt 清单全部对齐 form/stats/home_away/standings/h2h)
- 数据库:6 张表 → 12 张表,补 standings/app_settings/schedules 与
  4 张治理表说明(标注 raw_events/data_quality_checks/data_lineage
  为预留未启用,ingest_failures 已启用),predictions 补 agent_weights 列
- API 文档(03):新增鉴权模型三档表;context/standings/leagues 公开端点
  补全;predictions/eval/backtest 标注需管理员;ingest/bzzoiro 补
  task/limit/season 参数契约,删除 understat/injuries 端点小节
- 目录树(README/01/07):与真实 src/、frontend/src 结构一致
  (core 7 文件、data 7 文件、移除不存在的 retry.py)
- 采集命令(02/05):understat 回填 → task=stats/standings;
  补管理员凭据提示;docs/08 文首标注历史/过时
2026-09-21 20:55:48 +08:00
WorkBuddy a24017eb69 feat(api): 比赛上下文与联赛列表改公开只读(P1-2/P1-3)
- GET /matches/{id}/context 移除 require_admin:公开站详情页「近况/交锋」
  数据来源,匿名 401 会导致前端静默空态;响应结构不变,不触发 LLM
- GET /leagues 改公开只读:公开站联赛筛选动态加载;仅返回
  id/code/name/country 四个展示字段,不含配置或密钥信息
- 前端新增 useLeagues hook:优先请求 /api/v1/leagues,失败/空回退
  本地五大联赛常量(已知代码保留中文标签,新联赛按 API 名称追加)
- 新增 tests/test_public_readonly_api.py(6 项):匿名 200/404、响应形状、
  路由依赖声明检查(matches 路由无 require_admin)+ 判别力守卫
  (ingest 路由必须检出 require_admin,防检测器恒真)
2026-09-21 20:55:26 +08:00
shangfangjian 3056a95ef4 Merge pull request '工程债清理(Bronze 血缘/预测类型统一/Matches 拆分/Repository 治理/导航单源等)'
Reviewed-on: #10
2026-09-21 19:57:48 +08:00
WorkBuddy d9aeff2114 debt(D7): 限流/KeyRing 单进程限制说明 + 生产启动一次性告警
- app.py lifespan:APP_ENV=production 时 logger.warning 提醒
  进程内 rate-limit/KeyRing 仅单进程有效(不 sys.exit、不引入 Redis)
- key_ring.py docstring 补充多 worker 不共享的后果说明
- deps.py 注释已具备(单进程有效,生产前置 Nginx),无需改动
2026-09-21 19:51:41 +08:00
WorkBuddy 17e99a4f9a debt(D5): agent_outputs 类型标注对齐真实 JSON 形状(list[dict]|dict|None)
- Mapped[list[dict] | dict | None]:multi 模式存专家报告列表,
  历史数据/兼容路径可能存 dict;加注释说明形状来源
- 仅类型标注修正,列类型(JSONB)与数据、迁移均不变
2026-09-21 19:51:38 +08:00
WorkBuddy 4514ef4e92 debt(D4): bzzoiro Team/League/Match 查找经 Repository 层,纠正空壳表述
- repositories.py 新增 MatchRepository.find_by_league_and_date_range /
  find_finished_with_stats(含 stats 预加载),TeamRepository.get_or_create
  支持 name_zh
- bzzoiro events/standings/stats 三条管线的联赛、球队、比赛查找与批量预载
  全部改走 Repository;事务提交仍由调用方 UnitOfWork 控制
- Standing/RawEvent/Lineage 管线内私有读写保留在本模块(不强行 Repository 化)
- 模块 docstring 纠正为准确表述,移除「已全面 Repository 化」的误导性声明
2026-09-21 19:48:56 +08:00
WorkBuddy 18c89111d8 debt(D6): Admin 导航单源 —— NAV_ITEMS 派生侧栏/命令面板/面包屑
- 新增 admin/nav.ts: 唯一配置源 NAV_ITEMS(to/label/group/icon/hideFromSidebar)
- COMMAND_PALETTE_PAGES / ROUTE_LABELS / NAV_SECTIONS 全部由其派生
- 删除 AdminLayout 中三份平行清单(已出现漂移: data-pipeline 不在侧栏、
  「系统」vs「系统设置」命名不一致、monitoring/eval 顺序两处相反)
- 已知微差: 命令面板「评估与监控」组内顺序统一为侧栏口径(eval 在前)
- 行为不变: 路由/标签/分组标题均保持现状;data-pipeline 保持仅面板可达
2026-09-21 19:34:31 +08:00
WorkBuddy 52d67863d6 debt(D3): 拆分 Matches.tsx(~1400 行)为 hooks + 组件 + 共享层
- pages/matches/types.ts: Match/Prediction/AgentReport + 常量(LEAGUES/STATUS_META/AGENT_LABELS 等)
- pages/matches/ui.tsx: Spinner/SkeletonRows/Switch/日期分组工具(Switch 自组件体内提升到模块级)
- pages/matches/hooks/useMatchesList.ts: 列表筛选/游标分页/进行中比赛,竞态防护语义不变
- pages/matches/hooks/useMatchPredict.ts: 预测状态机(连点/竞态/中止/超时)+ 可读错误文案
- pages/matches/components/MatchPredictPanel.tsx: 预测弹窗全套(过程可视化/结果/专家意见)
- pages/matches/components/MatchDetailSection.tsx: MatchRow(原内联行 JSX 抽出)+ 详情面板
- Matches.tsx 组装页 279 行(< 300);error state 共享行为与拆分前一致
- 验证: tsc + vite build 通过,渲染逻辑原样搬迁
2026-09-21 19:31:42 +08:00
WorkBuddy 5c7fdce0a3 debt(D2): 统一预测结果类型为 PredictResult,路由去 dict 分支
- PredictResult 扩展可选字段 mode/agent_outputs/agent_weights/prompt_tokens/completion_tokens
- MultiPredictResult 变为 PredictResult 别名(保留 R4 守卫标记与 re-export)
- baseline 改返回 PredictResult(修复 backtest 对 baseline AttributeError 的潜伏 bug)
- 预测路由单一属性映射,删除全部 isinstance(result, dict) 分支
- _persist_baseline 属性化,baseline upsert 语义不变(prompt_version/token/latency 同前)
- TDD: 5 新测试 + test_baseline.py 属性化;全量 257 passed
2026-09-21 19:23:50 +08:00
WorkBuddy 3a9f3f5a0e debt(D1): events 成功路径补写 Bronze 层(RawEvent + DataLineage)
- 新增 _events_record_id: 上游 id 缺失时用 (league:home:away:date) 合成稳定幂等键
- 新增 _write_events_bronze: best-effort 写 RawEvent(幂等) + Lineage(matches/events_ingest)
- ingest 插入与变更更新后触发;同批 seen 集合防重复;基础设施失败只 warning
- TDD: 6 测试(插入/合成键/幂等跳过/变更更新/无变化不写/失败不拖垮),双变异验证通过
2026-09-21 19:23:37 +08:00
shangfangjian f6c0145c32 Merge pull request 'fix: 死信表真正接线 + 前端 HTTP 收敛与状态修正' (#9) from fix-deadletter-frontend-cleanup into main
Reviewed-on: #9
2026-09-21 18:39:08 +08:00
WorkBuddy b6a251407d fix(pipeline): 死信表真正接线 + 前端 HTTP 收敛与状态修正
- bzzoiro events/standings/stats 抓取失败写入 IngestFailure 死信(尽力而为,
  写入失败不影响主流程);顺带修复 standings 失败路径 league_r 缺 errors 键
  的 KeyError —— 该路径此前从未跑通,一旦失败会顶掉原始异常
- admin/api.ts 收敛为 lib/http.ts 薄门面,消除第二套 HTTP 实现;
  UNAUTHORIZED_EVENT 定义移至共享层,断开 lib→admin 反向依赖
- STATUS_META 死键 live 改为 in_play(对齐 normalize.py 口径),
  补 paused/postponed/cancelled/suspended;移除无人消费的
  DashboardStats.total_matches(items.length 近似,上限 100)
- 新增 tests/test_ingest_deadletter.py(6 例,变异验证判别力)
2026-09-21 18:36:43 +08:00
shangfangjian b5f7e682f0 Merge pull request 'fix: 批量修复一些问题
Reviewed-on: #8
2026-09-21 18:04:54 +08:00
shangfangjian 06e973d34d fix(concurrency): 消除 model 覆盖的模块级变量中转(并发串味)
R4 首版实现用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model override,
理由是不想改动 run_specialists 的签名(既有测试会 mock 它)。

但 backtest.py:225 会 asyncio.gather 并发 8 场预测(Semaphore(8)),
每场都调用 predict_match_multi —— 模块级变量会被并发调用互相覆盖,
导致 A 场的预测用上 B 场的模型。这是静默的正确性缺陷。

改为: run_specialists 新增 model_override 形参,一路显式下传;
删除模块级变量。同步更新 6 处 mock 签名。

新增 3 个守卫并做变异验证:
  - test_r4_no_module_level_model_override_global (源码级,可判别)
  - test_r4_run_specialists_accepts_model_override_parameter
  - test_r4_dispatch_passes_override_to_specialists
变异测试: 重新引入全局变量方案 → 两个守卫变红;还原 → 全绿。

注: 曾尝试写并发行为测试,但真实 run_specialists 会访问数据库,
测试环境下不稳定(ConnectionRefusedError),会是 flaky 的假证据,
故改用清晰的源码级判别守卫,并在注释中说明原因。

240 passed / 1 skipped; tsc 0 error; vite build 成功。
2026-09-21 17:52:45 +08:00
shangfangjian 50a6753ff8 test: 修正 selectin 守卫的假阳性(变异测试发现)
原断言对「关系声明到下一注解」的整段(含注释)做 lazy="selectin"
子串匹配;而 Match.league 声明正下方的注释里恰好也写着该字样,
导致移除真实 kwarg 后测试依旧通过(变异测试确认)。

改为只匹配 relationship(...) 括号内内容,并加注释说明该假阳性。
验证: 干净代码通过 → 移除 league 的 lazy 后失败 → 还原后又通过。
全套 238 passed / 1 skipped。
2026-09-21 17:36:07 +08:00
shangfangjian 951faf31df test: 修复 13 个腐化用例,套件恢复全绿且顺序无关
按「测试腐化(a)/ 源码缺陷(b)/ 测试污染(c)」逐项定性,仅改 tests/:

- 外机绝对路径(4): 迁移文件路径改为相对仓库根解析(沿用
  test_regressions._read 约定),断言内容保持不变。
- cutoff 用例(4+1): mock 未生效的真因是 _agent_provider 被换成同步
  lambda,await 抛 TypeError 被生产代码吞掉后 IndexError;改为 async
  mock 并按 run_specialists/load_match_header/_agent_provider 的真实契约
  打补丁。degraded 用例再加 _upsert_prediction 顶层 kwargs(model)采集。
- 陈旧断言(3): agent 键按现契约断言中文映射;h2h mock 改 async;
  Match.stats 按设计为 lazy="select",从 MATCH_RELATIONS 移出并单独
  固化该设计决定。
- P0-3 守卫(1): seg 越界扫到下游 stats 管线导致误报,改为按缩进收口;
  合法形状含经 raw 派生变量中转的写法,并补元测试确保守卫仍能抓到回归。
- 交叉污染(6): test_multi_agent_cutoff 用 patch.object 精确还原,消除
  裸赋值泄漏的同步 mock;现已验证顺序无关。
2026-09-21 17:31:36 +08:00
shangfangjian 11da4d3631 fix(critical): 修复评审确认的 R1-R5 五处缺陷
R1 429 key 轮换路径调用不存在的 _km → NameError(且位于凭证脱敏日志行):
   改用 src/data/key_ring._mask,全树不再有 _km 调用。
   [注: 该改动已随另一工作流提交 9ccda4b 一并入库]
R2 ingest_bzzoiro_standings 被截断(return 前无 upsert 逻辑,standings 表
   永不写入、total_upserted 恒为 0):从 f05dc1a 移植完整实现,按
   (league_id, season, team_id) upsert,保留逐联赛错误隔离,不加 db.commit()。
   [注: 同上,已随 9ccda4b 入库]
R3 src/llm/agents/orchestrator.py 完成日志把 list 喂给 %d → logging TypeError:
   改为 len(ok_reports),与同文件降级日志行写法一致。
R4 mode=multi 静默丢弃调用方传入的 model:predict_match_multi 新增
   model 关键字参数,经 _ACTIVE_MODEL_OVERRIDE 下传至 _agent_provider,
   显式 model 优先级最高;override 生效时跳过 provider 缓存读写以免串味,
   并在 predict.py 派发点透传。
R5 回测把字符串日期直接与 timestamptz 列比较:新增 _parse_date_bound 助手,
   支持 YYYY-MM-DD / 完整 ISO / datetime / None,裸日期按 UTC 锚定,
   结束日取当天末刻(闭区间,避免最后一天被静默排除),非法输入抛 ValueError。

新增 tests/test_review_required_fixes.py 覆盖 R1-R5(R2/R4 为行为测试),
20 项全通过。
2026-09-21 17:19:24 +08:00
shangfangjian b9d7b6423e test(c2): 补齐导入次序回归,注明环状导入为预期且可自愈
test_bzzoiro_source_registry.py 增至 11 项:
- 新增 TestImportOrderSelfHeal:用独立子进程验证两种真实导入次序
  (sources 先 / bzzoiro 先)下注册表均可用。该缺陷只在真实模块初始化
  时序下成立,mock 照不出来,故不 mock。
- 新增未知名字仍抛 ValueError 的契约守卫。

src/data/sources.py:补全 _load_sources 文档,说明首次装载失败属正常的
环状导入(bzzoiro 仍在初始化),失败时保持 _loaded=False 使后续
get_source()/list_sources() 调用可重试装载(自愈)。

验证:
- tests/test_bzzoiro_source_registry.py:11 passed
- 两种导入次序实测均输出 bzzoiro ['bzzoiro']
2026-09-21 17:13:57 +08:00
shangfangjian 9ccda4bf51 fix(critical): 恢复 bzzoiro events 采集管线,消除数据源注册表静默失效
C2 数据丢失修复。重构提交 6a49940 删除了 src/data/bzzoiro.py 中的
fetch_bzzoiro_events 与 BzzoiroSource 后未做替换,同时 sources.py 用
`try/except Exception: pass` 吞掉了 ImportError,导致:

  - _SOURCES 注册表恒为空
  - get_source("bzzoiro") 恒抛 ValueError("未知数据源: bzzoiro")
  - src/api/routes/ingest.py 与 schedules.py 运行时全线失效,
    且日志中无任何导入错误痕迹

修复内容:
1. 从 f05dc1a 恢复并移植 events 管线:
   - fetch_bzzoiro_events():分页抓取 /events/(纯异步,复用现有 _fetch_json_async)
   - @register class BzzoiroSource + ingest():保留原签名
     (db, *, leagues, date_from, date_to, status),保持「不 commit,
     事务由调用方 UnitOfWork 控制」的契约,并保留单联赛抓取失败的
     错误隔离(记录 error 后 continue)
   - 复用现有 _to_date / _to_int_or_none / _match_key,未重复定义
   - 血缘字段继续使用与 nm 配对的 raw(避免 P0-3 回归)
2. sources.py:导入失败改为 logger.exception 记录,新增 _loaded 标记
   保证 _load_sources() 至多执行一次;失败时不置位以便后续重试。
   get_source() 对真正未知的名字仍抛 ValueError。

验证:
   - 新增 tests/test_bzzoiro_source_registry.py(8 项):注册表非空、
     get_source/list_sources 可用、ingest 签名契约、不再静默吞异常
   - test_regressions.py:3 failed -> 2 failed
   - 全量:14 failed -> 13 failed,通过数 190 -> 199

注意事项:未改动任何 caller(routes/ingest.py、routes/schedules.py 原样
可用,证明签名保持正确)。
2026-09-21 17:10:17 +08:00
shangfangjian 8c87b9d024 fix(critical): 修复调度器静默失效、数据源缺失与前端类型错误
C1 定时任务静默失效(4 层缺陷):
  - scheduler.py: croniter(expr, datetime.now) 未调用 now(),
    导致 TypeError 被吞、next_run 恒为 None(最深一层)
  - scheduler.py: _calc_next 非法 cron 静默吞异常 -> 改为构造期抛 ValueError
  - scheduler.py: _run_loop 一次异常即永久停摆 -> 增加异常隔离
  - scheduler.py: sleep(min(wait,60)) 使运行期 cron 变更最长 60s 才生效
    -> 改为固定 SLEEP_TICK 轮询
  - app.py / schedules.py: 3 处 register() 第 2 参误传 task 类型名

C3 EvalPage.tsx: 引用未导入的 EmptyState -> 改为已导入的 EmptyText
  (tsc --noEmit 由 1 error 变为 0 error)

新增 tests/test_scheduler_registration.py: 9 项行为回归测试
2026-09-21 17:04:04 +08:00
Profeto Agent f04e0df9e8 预测结果展示增强:status 徽章 + 专家权重条形图
- types.ts: 添加 agent_weights / status 字段
- components.tsx: 添加 AgentWeightsBar 纯 CSS 权重分布条 + EmptyText 组件
- Predictions.tsx: 摘要行 degraded/failed 状态徽章、展开区 AgentWeightsBar、
  预测中防连点、中文错误映射
- EvalPage.tsx: 修复 EmptyText 导入

构建验证:tsc && vite build 通过
2026-09-21 05:56:03 +00:00
shangfangjianandnew-provider/LongCat-2.0 < 6a4994097a feat: 采集管线基础设施全面接线
后端:
- bzzoiro events 采集成功后写入 RawEvent(Bronze 层)
- 采集失败写入 IngestFailure(死信队列)
- 写入 DataLineage(ETL 血缘追踪)
- stats 回填成功后写入 RawEvent + DataLineage
- 新增 DataQualityScheduler(每小时自动质量检查)
- 新增 /api/v1/admin/data-quality 端点
- 新增 /api/v1/admin/ingest-failures 端点(失败记录 + 重试)
- 新增 /api/v1/admin/llm/ping 端点(LLM 连通性测试)
- lifespan 启动 quality_scheduler
- Match 表补充 CHECK 约束(完赛必须有比分、状态枚举、半场≤全场)
- Prediction FK 加 ondelete="CASCADE" 与迁移对齐
- PredictRequest mode 加 pattern 校验

前端:
- 新增「数据管线」管理页(/admin/data-pipeline)
- 采集失败记录列表(状态/重试次数/错误类型 + 重试按钮)
- 数据质量检查结果(通过/未通过/严重度)
- 手动触发质量检查按钮
- 预测历史:比赛信息内嵌(日期/队名/主客徽标/赛果自动填充)
- 导航统一为 React Router Link
- AdminStats 类型扩展(matches/stats/standings 真实计数)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 10:28:02 +08:00
shangfangjianandnew-provider/LongCat-2.0 < ae89d0f04f feat: 数据采集管线基础设施接线 + 数据管线管理页
后端:
- bzzoiro 采集成功后写入 RawEvent(Bronze 层原始事件存档)
- 采集失败写入 IngestFailure(死信队列,支持重试)
- 写入 DataLineage(ETL 血缘追踪)
- 新增 DataQualityScheduler(每小时自动质量检查)
- 新增 /api/v1/admin/data-quality 端点(质量检查 + 手动触发)
- 新增 /api/v1/admin/ingest-failures 端点(失败记录 + 重试)
- 新增 /admin/llm/ping 端点(LLM 连通性测试,不依赖比赛)
- lifespan 启动 quality_scheduler

前端:
- 新增「数据管线」管理页(/admin/data-pipeline)
- 采集失败记录列表(状态/重试次数/错误类型)
- 数据质量检查结果(通过/未通过/严重度)
- 手动触发质量检查按钮
- 失败记录重试按钮
- 预测历史:比赛信息内嵌(日期/队名/主客徽标/赛果自动填充)
- 导航统一为 React Router Link
- AdminStats 类型扩展(matches/stats/standings 真实计数)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 10:04:26 +08:00
shangfangjianandnew-provider/LongCat-2.0 < b6e367a640 Code Review 第二轮修复:前端 HTTP 统一 + 数据采集可靠性
F1: 统一前端 HTTP 客户端
- 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理)
- Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端
- 公开站与 Admin 行为一致

D2: events 采集按联赛分批提交
- _run_bzzoiro 改为 per-league 独立事务,避免超长事务
- 单联赛失败不影响其他联赛

F3: 仪表盘真实计数
- admin_stats 补充 matches/stats/standings 真实聚合
- Dashboard 展示比赛总数/已完赛/统计行/积分榜

F4: LLM 连通性测试不依赖 match_id=1
- 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛)
- testLLMConnection 优先调用 ping,失败回退旧方式

F5: 导航改用 React Router Link
- App.tsx 中 <a href> 全部替换为 <Link to>

D5: 删除 away_possession 死代码
- bzzoiro.py 中移除计算与注释

D1: 管线基础设施表文档化
- RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 03:06:52 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 675e176603 fix: 修复 Code Review 发现的 4 个 High + 1 个 Medium 问题
High-1/2: baseline mode 违反 DB CHECK 约束
- ck_mode_enum 扩展为 ('single','multi','baseline')
- baseline 的 run_type 从 'baseline' 改为 'live'(符合现有约束)
- 新增迁移 0017_mode_baseline

High-3: 限流日志 NameError: ip 未定义
- deps.py:190 的 logger.warning 中 ip → client_ip

High-4: 生产 Cookie 缺少 Secure 标志
- auth.py 登录时根据 APP_ENV 设置 secure=True(生产)

Medium-5: 降级日志参数类型错误
- orchestrator.py:266 ok_reports(list) → len(ok_reports)(int)

附加: PredictRequest mode 字段加 pattern 校验,与 DB 约束同源

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 02:44:32 +08:00
shangfangjian 0a6af0657a 预测历史:比赛信息内嵌到预测API + 实际比分自动填充
后端:
- PredictionOut 新增 match 字段(内嵌比赛信息)
- list_predictions 返回完整比赛数据(日期/队名/赛果/主客徽标)
- 新增 _match_dict() 辅助函数序列化比赛对象

前端:
- 移除独立的 fetchMatches 调用,直接使用 p.match
- 实际比分自动从比赛赛果填充
- 显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 赛后一键结算(使用比赛实际赛果)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:54:30 +08:00
shangfangjian a9e85a5d62 修复定时任务更新接口:支持部分更新
PUT /schedules/{id} 原来要求完整 ScheduleIn(所有字段必传),
前端只传 {enabled} 时因缺少 task/cron 校验失败。
→ 新增 ScheduleUpdate schema(所有字段可选)
→ update_schedule 仅更新提供的字段
→ 使用最终值同步调度器

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:45:19 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 7a4e61ebc4 定时任务操作添加点击反馈和文案提示
- 启用/禁用/立即执行/删除按钮添加 loading 状态(Spinner)
- 操作成功后显示绿色提示(如「已启用任务 xxx」)
- 操作失败后显示红色错误提示
- 按钮禁用期间防止重复点击

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:42:17 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 0a5a14cbbb 采集任务支持手动触发 + 定时调度(cron)
后端:
- 新增 Scheduler + ScheduledTask 核心模块(croniter 解析 cron 表达式)
- 新增 Schedule 数据模型(alembic 0016)
- 新增 /api/v1/admin/schedules CRUD 接口
- lifespan 启动时注册默认定时任务并加载数据库配置
- 默认任务:每日比赛/积分榜/30分钟统计回填(默认禁用)

前端:
- 设置页新增「定时任务」管理区块
- 支持:新建/启用禁用/立即执行/删除定时任务
- 显示:任务ID、类型、cron表达式、上次执行时间/状态

依赖:新增 croniter>=2.0

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:36:54 +08:00
shangfangjian 1fc799a5b0 预测历史:实际比分自动填充 + 显示比赛日/队伍/主客徽标
- 实际比分自动从比赛赛果填充,无需手动输入
- 比赛列显示:比赛日(M/D) + 主客队徽标 + 队伍中文名
- 状态分三类:待赛果(灰)/可结算(蓝)/已结算(绿)
- 一键结算按钮:仅当比赛有赛果且未结算时显示
- 结算后1X2自动判断命中(绿✓/红✗)
- 顶部新增统计概览:已结算数/命中数/1X2准确率

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:21:05 +08:00
shangfangjian f08fef8a22 预测管理改为预测历史页面
- 删除 Predictions.tsx,新建 PredictionHistory.tsx
- 不再支持触发预测(从主站比赛列表操作)
- 支持查看所有预测记录(全部/待标注/已结算筛选)
- 支持标注实际比分:行内编辑 + 保存,自动判断1X2是否命中
- 展开查看详情:预测比分/置信度/终裁意见/专家意见
- 侧边栏更名为「预测历史」,图标改为 logs

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 01:17:14 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 857a21910b 侧边栏图标统一为线条SVG + hover品牌色过渡
- 新增 Icon 组件:8种线条风格 SVG 图标(collection/chart/target/repeat等)
- 统一 stroke-width:1.5,hover 加粗到 2
- hover 时颜色平滑过渡到品牌色(press red)
- 添加 .nav-icon 和 .nav-icon-wrap CSS 类
- 活跃/悬停态:opacity 0.5→1,stroke-width 1.5→2

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:01:01 +08:00
shangfangjian ad184e1b82 修复定位按钮无反应 + 增强高亮效果
- scrollIntoView 改用 requestAnimationFrame 确保 DOM 已更新
- 滚动位置改为 block: 'start',更可靠
- 高亮增加背景色变化(bg-press-wash/40),更明显
- 高亮持续时间延长至 5 秒

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:48:39 +08:00
shangfangjian 83cc24e54b 健康摘要与联赛卡片联动
- 问题项新增「定位」按钮,点击滚动到对应联赛卡片
- 被定位的联赛卡片高亮显示(ring + scale),3秒后自动消失
- 健康摘要 → 联赛详情形成闭环操作

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:44:19 +08:00
shangfangjian 7025338226 移除活跃Tab下划线渐变动画
- 删除 .nav-tab-active CSS 及 ::after 伪元素
- 移除 NavLink 中的 nav-tab-active 类名
- 活跃Tab仅保留左侧彩色竖条 + 背景高亮

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:38:06 +08:00
shangfangjian 4ae61a5683 回到顶部按钮:滚动淡入 + 平滑滚动
- 滚动超过 300px 后按钮优雅淡入(translate-y + opacity 过渡)
- 点击后平滑滚动到顶部(behavior: smooth)
- 首页和积分榜页均添加
- 按钮隐藏时 pointer-events-none 防止误触

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:23:56 +08:00
shangfangjian a2dc2b07c5 Tab切换动画:横向轻扫+淡入 + 活跃Tab下划线渐变
- 活跃Tab下方添加墨色渐变下划线动画(从中心向两侧展开)
- 页面内容切换时横向轻扫+淡入效果(0.3s)
- 使用 key={location.pathname} 触发重新渲染动画

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-21 00:19:31 +08:00
shangfangjian 930dfd184d 侧边栏恢复先知刊头,去掉ADMIN
- 顶部通栏不再显示先知,改为纯功能栏(面包屑/日期/状态/登出)
- 侧边栏顶部显示「先知」毛笔字
- 移除 ADMIN 标识

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:47:44 +08:00
shangfangjian 9fb8e24be3 后台管理页面顶部改为通栏样式
- 顶部报眉通栏显示:先知(毛笔字) + 面包屑 + 日期 + 系统状态 + 登出
- 移动端汉堡按钮移至顶部通栏,用于切换侧边栏
- 侧边栏不再重复显示先知标题,仅保留 ADMIN 标识
- 布局从 sidebar+main 横向排列改为 header 通栏 + sidebar/main 下方排列

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:44:21 +08:00
shangfangjian 81b1f48382 积分榜补全剩余分区翻译
新增:
- Champions League Qualification → 欧冠资格
- Qualification Playoffs → 资格附加赛

现已覆盖数据库中所有8种分区类型。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:34:52 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 730bc0de00 积分榜:补全所有分区中文翻译 + 防止换行
新增分区翻译:
- Europa Conference League → 欧协杯
- Championship / Promotion → 升级区
- Promotion Group → 升级组
- Relegation Group → 降级组
- Playoffs → 附加赛
- Championship Playoffs → 升级附加赛
- Qualification → 资格赛

修复:zoneBadge 添加 whitespace-nowrap,4字以上不换行

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:32:05 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 078fc0c5dd 修正3队翻译为主流媒体通用译法
- Cardiff City: 加的夫城 → 加的夫
- Linfield FC: 林斯菲尔德 → 连菲尔德
- Shamrock Rovers: 沙姆罗克流浪者 → 沙姆罗克流浪

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:26:16 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 5cbfe84ae6 修复队名翻译缺失:补全规范化队名映射
问题:normalize()返回的规范化名(带变音)在TEAM_NAME_ZH中找不到对应中文。
修复:补全以下规范化队名映射:
- 德语:Bayern München, FC Köln, Union Berlin, Mainz 05,
  VfL Bochum, Werder Bremen
- 西语:Atlético Madrid, Leganés
- 法语:Olympique Marseille
- 诺维奇城 → 诺维奇(主流译法)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 23:22:07 +08:00
shangfangjian 05dd0a6dc4 积分榜分区标签英译中
- Champions League → 欧冠区
- Europa League → 欧联区
- Conference League / Conference League Qualification → 欧协杯
- Relegation → 降级区
- Relegation Playoffs → 降级附加赛

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:11:23 +08:00
shangfangjian 95be59bc9f 去掉刊头下方副标题文案
- 首页移除「足球比分预测 · 五路专家 · 终裁汇总」
- 积分榜页移除「联赛积分榜 · 积分 / 净胜 / xG差 / 近期走势」
- 刊头仅保留「先知」毛笔字,更简洁

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 23:01:13 +08:00
shangfangjian 8a8b71083e 毛笔字刊头加粗:笔触更粗更醒目
- 新增 .font-brush CSS 类:text-stroke + text-shadow 模拟粗笔触
- font-weight:700 叠加,无论 CDN 字体还是回退楷体都显粗
- 移动端 + PC 端刊头均应用

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:57:29 +08:00
shangfangjian 9a21edf5c0 首页刊头改用毛体草书(刘建毛草)
- 引入国内可访问的 jsDelivr CDN 字体:Liu Jian Mao Cao(毛体草书)
- 通过 @font-face 注册,font-display:swap 避免阻塞
- 离线回退到系统楷体(KaiTi)

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:33:09 +08:00
shangfangjian 130f41a31e 修复 CSS 加载超时:移除 Google Fonts 依赖
国内网络无法访问 Google Fonts,导致首页 CSS 加载超时(ERR_CONNECTION_TIMED_OUT)。
→ 移除 Google Fonts 外部字体链接
→ font-brush 回退到系统楷体(KaiTi / STKaiti),仍有书法韵味且零依赖

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:25:03 +08:00
shangfangjianandnew-provider/LongCat-2.0 < d28203ac54 去掉首页和后台的 Profeto 字样
- 首页刊头仅保留「先知」毛笔字
- 后台侧边栏仅保留「先知」毛笔字
- 版本号简化为 v1.0

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 22:20:53 +08:00
shangfangjian 4cda662d6e 首页「先知」刊头改用毛笔字艺术字体
- 引入 Google Fonts「Ma Shan Zheng」(马善政毛笔楷书)
- Tailwind 新增 font-brush 字体族
- 首页 + 积分榜页 + 后台侧边栏的「先知」均改为毛笔字
- 字号加大(text-5xl / text-6xl),去除 tracking,突出书法韵味

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:18:05 +08:00
shangfangjian 9a65e81ab0 移动端比赛行布局放宽,保持比分绝对居中
- 水平内边距 px-1 → px-4
- 垂直内边距 py-3.5 → py-5
- 行间距 gap-2 → gap-3
- 主客队列间距 gap-2 → gap-4
- 队名字号 13px → text-sm, VS 字号 xs → text-sm
- 比分字号 lg → xl,更醒目
- 网格 grid-cols-[1fr_auto_1fr] 确保比分绝对居中

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:12:16 +08:00
shangfangjian 53b638d282 修复移动端比赛行比分严格居中
从 flex + flex-1 改为 grid grid-cols-[1fr_auto_1fr]:
- 主队/客队各占 1fr,比分 auto 宽度居中
- 无论队名长短,比分始终在视觉中心
- 桌面端 sm:contents 恢复 grid 分列布局

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
)
2026-09-20 22:06:45 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 41109402a3 修复移动端比赛行队伍和比分居中
移动端原布局:主队/比分/客队垂直堆叠,比分区域仅 12px 宽,导致不居中。
改为:flex 单行居中布局,gap-3 均匀间距,主队右对齐 + 客队左对齐 + 比分居中。
桌面端保持原有 grid 布局不变。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 22:04:46 +08:00
shangfangjianandnew-provider/LongCat-2.0 < aaf9d38056 修复统计回填 ExecutionOption 错误
ingest_bzzoiro_event_stats 中 .options(select(Match.stats)) 用法错误:
.options() 期望 loader option(如 selectinload),不能传 select() 对象。
→ 改用 selectinload(Match.stats) 并补充 import。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 21:33:32 +08:00
shangfangjianandnew-provider/LongCat-2.0 < fb27f6e2d2 UI/UX 全面改进:22项优化落地(报纸风前端 + 管理后台)
P0 严重问题:
- 采集任务进度反馈:Collection 页新增任务状态跟踪(运行中/完成/失败) + 轮询 ingest 状态
- 积分榜加载态:切换联赛显示 spinner + 禁用 tab 防重复点击
- 数据完整性可操作:问题列表每项加「去修复」按钮,跳转采集页并预填参数
- 面包屑导航:顶部显示 仪表盘 > 当前页

P1 重要问题:
- 侧边栏当前页指示器:1.5px 圆点 → 4px 左侧彩色竖条 + 背景高亮
- Key Ring 重置确认:点击「重置冷却」前弹窗确认
- 预测比赛选择器:只显示未开赛 + 联赛筛选 + 显示可预测数量
- 表格移动端溢出:评估页表格加 overflow-x-auto
- 设置页合并:数据源 + LLM + 系统配置 → 统一「设置」页
- 预测按钮去重:移动端/桌面端合并为一个响应式按钮

P2 体验优化:
- 日志滚动保持:自动滚动仅当用户未向上滚动时
- 评估结果预览:显示「共 N 组」
- ⌘K 命令面板:键盘导航跳转所有管理页面
- 专家意见折叠:默认折叠,可展开(已存在)
- 版本信息:侧边栏底部显示 v1.0
- 积分榜表格:min-w 防止挤压

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 21:24:21 +08:00
shangfangjianandnew-provider/LongCat-2.0 < dd538d66d7 首页:去掉日期选择器,顶部增加「进行中」比赛区块
- 移除日期选择器(今日按钮 + date input + 清除按钮)及相关 date 状态
- 顶部新增「进行中 · 实时比分」独立区块:红色脉冲圆点 + 主客比分
- 进行中区块仅在默认「未开赛」视图显示,切换已完赛/全部时隐藏
- 进行中数据独立请求(status=in_play,limit 20),不影响主列表逻辑
- 清理 todayStr(),改用 dateKey()统一日期格式化

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:57:27 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 9d7f8dfeee 简化首页加载按钮:合并「显示后续」与「载入更多」为单一智能按钮
- 3 天视图且有折叠比赛时 →「显示后续 N 场未开赛」(展开)
- 展开全部后 / 非 3 天视图 →「载入更多赛程」(从服务器拉取下一页)
- 移除重复按钮,保留「收起」文字链接回 3 天视图

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:52:25 +08:00
shangfangjianandnew-provider/LongCat-2.0 < acac856b61 修复积分榜入库 UniqueViolationError
根因:ingest_bzzoiro_standings 批量处理时逐行 SELECT 检查 Standing 是否存在,
但新 INSERT 的 Standing 未 flush 就被同批下一条 SELECT 判定为不存在,
导致同一 (league_id, season, team_id) 插入两次 → UniqueViolation。

修复:
- 预加载该 league+season 的所有已有 standings team_id 到内存 set
- 同批内按 team.id 去重,同一 team 仅处理一次
- 命中已有 standing 时 UPDATE,否则 INSERT 并立即加入 set 防止同批重复

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:35:53 +08:00
shangfangjianandnew-provider/LongCat-2.0 < 19f17145cc feat: 首页未开赛默认展示未来 3 天,支持展开更多
- 新增 showAllUpcoming 状态,默认 false,仅渲染未来 3 天(今/明/后天)的未开赛
- 列表头显示「未来3天 N / 共 M 场」计数
- 「显示后续 N 场未开赛」按钮:展开全部已加载的未开赛
- 展开后提供「收起,仅显示未来 3 天」回退
- 切换联赛/状态时自动重置为 3 天视图
- 空态区分「未来3天暂无比赛」与「暂无赛程」
- 已完赛/全部状态不受影响,展示全部

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:29:12 +08:00
shangfangjianandnew-provider/LongCat-2.0 < c586a38b3a 修复积分榜"body stream already read"错误
两个根因:
1. src/api/routes/standings 端点使用 func.max() 但未导入 func → NameError(500)
   → 在 matches.py 的 sqlalchemy 导入中加入 func
2. 前端 api.ts 错误处理中先调 res.json() 失败后回退 res text(),
   但 Response body 流只能读一次 → "body stream already read"
   → 改为先 res.text() 再 JSON.parse(),避免重复读取

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 20:05:40 +08:00
shangfangjianandnew-provider/LongCat-2.0 < f52ec8b963 feat: bzzoiro 多 API Key 轮换,遇限流自动切换
新增 KeyRing 轮换环(src/data/key_ring.py):
- 支持逗号/分号/换行分隔多个 key,单 key 场景零开销
- 遇到 429 自动标记当前 key 冷却(默认 60s)并立即切换到下一个 key
- 全部 key 冷却时等待最早恢复的 key,避免无谓重试
- 热更新 key 列表(增删 key 无需重启)
- 进程级单例,按 base URL 隔离

后端集成:
- bzzoiro._fetch_json_async 接入 KeyRing,429 立即轮换(不等待)
- 新增 /admin/keyring/status 端点展示 key 环状态
- 新增 /admin/keyring/cooldown/reset 紧急重置冷却
- BZZOIRO_KEY 配置描述提示多 key 支持
- mask_value 多 key 显示数量(如 "3 个 key(末段 …XXXX)")
- 清理已移除 injuries 的 API_FOOTBALL_KEY 配置项

前端:
- 数据源页新增 Key Ring 状态面板(每个 key 可用/冷却状态 + 重置按钮)
- 配置项脱敏展示支持多 key 计数

测试: 新增 24 个 KeyRing 单元测试 + 2 个 bzzoiro 轮换集成测试,全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 19:40:20 +08:00
shangfangjianandnew-provider/LongCat-2.0 < f05dc1ae15 refactor: 以 bzzoiro 为唯一数据源的全面重构
数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-20 19:13:07 +08:00
shangfangjian ec8f36abb2 fix: 主站页面修复 + 新增文件
- App.tsx: 路由和布局修复
- index.css: 样式修复
- Matches.tsx: 比赛列表页修复
- .zcodeignore: 新增忽略配置
- tests/ui_audit.py: UI 审计测试脚本
2026-09-20 08:45:35 +08:00
shangfangjian 78375d6e39 fix: 管理后台页面修复
- AdminLayout: 侧边栏布局和导航修复
- dal.ts: 数据访问层 API 调用修复
- Dashboard: 仪表盘统计卡片和数据加载修复
- Backtest: 回测配置和结果展示修复
- LLMConfig: LLM 配置页面修复
- Predictions: 预测管理页面修复
2026-09-20 08:45:27 +08:00
shangfangjian 7b1457f0db fix: 后端 API 修复
- deps.py: 依赖注入优化
- matches.py: 比赛列表查询修复
- predict.py: 预测接口参数校验修复
- orchestrator.py: 多 Agent 编排逻辑修复
2026-09-20 08:45:19 +08:00
shangfangjian 8e6ad5394e fix:批量修复了一些问题 2026-09-19 22:51:35 +08:00
Profeto Agent 835d7217d0 完善评估能力:筛选参数 + degraded 排除 + 前端评估页
后端:
- settle_prediction 拒绝 degraded/failed(明确错误信息)
- get_eval_summary 支持 provider/model/prompt_version/mode 筛选
- 返回 filtered_settled/evaluated/skipped_degraded 等计数
- matches 游标分页方向修复(scheduled ASC 用 > 条件)
- available_at 加 2h 缓冲(近似完赛时间)
- bzzziro 统计字段映射注释(待真实响应验证)
- injuries 区分 no_local_data 与 success 空名单

前端:
- 新增 EvalPage(筛选控件 + 汇总卡片 + 准确率表格)
- 挂载 /admin/eval 路由与导航

测试:
- test_matches_cursor.py:游标方向
- test_available_at.py:2h 缓冲与回测防泄漏
- test_bzzoirot_stats.py:统计字段映射
- test_injuries_no_local_data.py:no_local_data vs success
- test_injuries_inserted_count.py:失败批不计入
- test_eval_excludes_degraded.py:degraded 排除准确率
2026-09-19 09:40:24 +00:00
Profeto Agent c2c4752856 docs: 补充内存限流与登录防爆破多进程局限及部署建议 2026-09-19 08:49:50 +00:00
Profeto Agent c0bcf1d851 orchestrator: 全专家失败时不调用 aggregator provider 2026-09-19 08:42:09 +00:00
Profeto Agent 6c24672e87 安全加固 + 数据管线修复 + 质量改进(第2批)
安全加固:
- /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造
- 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed
- 生产环境管理接口未配置鉴权时拒绝(503),不再放行

数据管线修复:
- injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次
- injuries 空名单 vs 未配置语义区分(has_data 精确标记)
- bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽
- available_at 语义收紧(回测防泄漏)
- team_names NFKD 去变音双重查找 + 补齐变体键

预测系统改进:
- multi-agent 全专家失败时 status=degraded 跳过终裁
- agent_weights 独立持久化到 predictions 表

新增迁移:
- 0014_predictions_agent_weights.py

新增测试(8个文件):
- test_ip_spoofing.py: 限流防伪造
- test_require_admin_fail_closed.py: 生产 fail-closed
- test_injuries_integrity_rollback.py: SAVEPOINT 隔离
- test_injuries_slice_semantic.py: 空名单 vs 未配置
- test_available_at.py: 回测防泄漏
- test_bzzoirot_stats.py: 统计字段映射
- test_team_names_normalize.py: NFKD 变体
- test_multi_agent_degraded.py: 全失败 degraded
- test_agent_weights_persist.py: 权重持久化
2026-09-19 08:02:57 +00:00
Profeto Agent bee330f31f 全量修复:预测系统正确性、安全性与部署问题
P0 严重问题修复:
- 修复 form_slice/stats_slice 主客身份反转(历史比赛视角错误)
- 修复 understat.py httpx 未导入导致的 NameError
- 修复 LLM 解析失败时静默产生假成功预测(0-0 平局+置信度0.5)

预测路径修复:
- multi-agent 路径增加 backtest cutoff 透传,回测防泄漏生效
- H2H 切片汇总统计改为从当前主队视角计数
- 预测唯一约束增加 mode+run_type 维度,防止回测覆盖实盘预测

伤停管线修复:
- IntegrityError 后不再整批回滚丢数据(改用逐条 flush)
- return_date 正确解析并写入
- retrieved_at 比较统一用 date() 避免当天数据不可见
- 唯一索引改为 partial unique index(排除 NULL 重复)
- HTTP 缓存 TTL 从 7 天改为 6 小时

安全与连接管理:
- /api/v1/predict 增加内存滑动窗口限流(10次/分钟/IP)
- 预测路由改用短 session 模式,LLM 调用期间不持有 DB 连接

Docker 部署修复:
- 修复 .dockerignore 排除 *.md 导致 COPY README.md 失败
- 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
- 启动时自动执行 alembic upgrade head
- 前端改用多阶段构建(Dockerfile.frontend)

新增测试(5个文件,24+用例):
- test_p0_home_away.py: 主客身份反转回归测试
- test_p0_parse_failure.py: LLM 解析失败回归测试
- test_multi_agent_cutoff.py: multi-agent cutoff 透传测试
- test_h2h_perspective.py: H2H 视角测试
- test_injuries_pipeline.py: 伤停管线 5 项修复测试
- test_predict_protection.py: 限流+短 session 测试
- test_prediction_unique_constraint.py: 唯一约束测试

迁移:
- 0012_injuries_partial_unique_and_return_date.py
- 0013_predictions_unique_constraint_mode_run_type.py
2026-09-19 06:43:55 +00:00
Profeto Agent 11efe91ce9 fix: 全方位审查问题修复(P0~P3)
P0: ORM-迁移同步
- 新增 RawEvent/IngestFailure/DataQualityCheck/DataLineage 4 个模型类
- MatchStats 补充 xg_source/xg_updated_at/xg_source_record_id 字段
- 修复 server_default=_utcnow → func.now()(4 处)
- BigInteger 导入

P1:
- log_buffer.py 移除 threading.Lock(asyncio 单线程下无需锁)
- 确认 context_builder/understat/injuries 等已有修复

P2:
- Dockerfile 新增非 root 用户 + .dockerignore
- 前端 fetchDashboard 修复 total 字段(改用 items.length)
- 前端 fetchSystemConfig 改用真实 /admin/settings 端点
- 修复 validation.py return self"" → return self

P3:
- predict.py 缓存加 _CACHE_MAX_SIZE=200 淘汰
- eval.py get_eval_summary 加 limit 参数(默认 1000)+ 返回 total_settled
- orchestrator.py agent provider 配置缓存 60s
2026-09-19 04:38:53 +00:00
shangfangjian 786f10aa11 feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲
- crypto.py: API Key 加密/解密工具
- runtime_config.py: 运行时动态配置管理
- log_buffer.py: 内存日志缓冲区
- config.py: 新增加密配置项
- http_client.py: 增强重试和错误处理
2026-09-19 11:58:03 +08:00
shangfangjian b3e2c52b49 Merge pull request 'feat: 管理界面报刊风统一改造' (#4) from admin-newspaper into main
Reviewed-on: #4
2026-09-18 10:54:52 +08:00
shangfangjian cf0751d1ff feat: 管理界面报刊风统一改造 — 移除独立暗色主题,预测/回测/监控/配置页增强 2026-09-18 10:42:14 +08:00
shangfangjian 91e406f5ee feat: 管理界面优化 — 移动端自适应 + 数据源 + LLM 配置
移动端自适应:
- AdminLayout: 汉堡菜单 + 可折叠侧边栏 + 遮罩层 + ESC 关闭
- 所有页面响应式布局 (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4)
- 触摸友好按钮 (min-h-[44px])
- 表格移动端卡片视图

新增页面:
- DataSources: 数据源状态 + API Key 脱敏 + 测试连接
- LLMConfig: LLM 配置 + 测试连接 + 使用统计 + 最近预测

增强功能:
- Config: 配置列表 + 修改指南 + 快捷导航
- types: 新增 DataSourceStatus, LLMUsageStats 等类型
- dal: 新增 testDataSource, fetchDataSourceStatuses, testLLMConnection 等
2026-09-17 02:43:44 +08:00
shangfangjian 6680da7d61 fix: 修复 Admin 后台多个 bug
- api.ts: 修复 URL 拼接 bug(缺少 API_BASE) 和 204 判断逻辑
- dal.ts: 修复 fetchDashboard 类型错乱,修正 API 端点
- types.ts: 对齐后端 Pydantic 模型
- 所有页面: Badge variant→status, CardHeader children→action
- 移除不存在的后端端点调用
2026-09-17 02:11:50 +08:00
shangfangjian d3284c48c3 feat: 后台管理 Admin 仪表盘
新增完整的后台管理系统 (/admin):
- Dashboard: 系统概览、最近采集状态、预测统计
- Collection: 数据采集触发(bzzoiro/understat/injuries)
- Predictions: 预测历史查看、触发新预测
- Backtest: 回测配置与结果查看
- Monitoring: 系统健康、错误日志、死色队列
- Config: API Key 与数据源配置

技术栈: React Router + Tailwind 暗色主题 + TypeScript
文件: 11 个新文件, +21KB JS / +5KB CSS
2026-09-17 02:00:14 +08:00
shangfangjian 1219b4fd18 fix: alembic version_num 长度修复 + 前端部署配置
- env.py: 自动创建 alembic_version 表时使用 VARCHAR(255) 避免截断
- docker-compose: 添加 frontend nginx 服务 + alembic 挂载
- nginx.conf: SPA 路由 + API 代理
2026-09-17 01:26:58 +08:00
shangfangjian 312778d995 feat: 数据管线架构优化 + 前端部署配置
- Bronze 层 RawEvent 表(原始事件存档)
- IngestFailure 死信表(失败持久化+重试)
- DataQualityCheck 数据质量监控
- DataLineage 血缘追踪
- MatchStats xG 追踪字段
- Nginx 前端服务配置
2026-09-17 01:15:51 +08:00
242 changed files with 32964 additions and 3443 deletions
+43
View File
@@ -0,0 +1,43 @@
{
"permissions": {
"allow": [
"Bash(python -m pytest tests/ -v)",
"Bash(python -c \"import src.core.http_client; import src.llm.provider; import src.data.understat; import src.data.injuries; import src.llm.agents.orchestrator; import src.llm.context_builder; import src.api.app; print\\('All imports OK'\\)\")",
"Bash(python -c \"from src.data.sources import get_source, list_sources; print\\('sources:', list_sources\\(\\)\\); print\\('bzzoiro:', get_source\\('bzzoiro'\\).name\\); print\\('understat:', get_source\\('understat'\\).name\\)\")",
"Bash(python -c \"from src.api.routes.ingest import router; print\\('ingest router OK:', len\\(router.routes\\), 'routes'\\)\")",
"Bash(python -c ' *)",
"Bash(python -m pytest tests/ -q)",
"Bash(git -C /p rev-parse --git-dir)",
"Bash(git config *)",
"Bash(git init *)",
"Bash(git add *)",
"Bash(git commit -m 'feat: 足球 LLM 预测服务初始提交 *)",
"Bash(git remote *)",
"Bash(git push *)",
"Bash(git fetch *)",
"Bash(git branch *)",
"Bash(git commit *)",
"Bash(pg_isready -h localhost -p 5432)",
"Bash(python -c \"import pytest_asyncio; print\\('pytest-asyncio OK'\\)\")",
"Bash(python -c \"import pytest; print\\('pytest', pytest.__version__\\)\")",
"Bash(python -c \"import aiosqlite; print\\('aiosqlite', aiosqlite.__version__\\)\")",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(git revert *)",
"Bash(python -c \"from src.api.app import app; print\\('OK'\\)\")",
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_bad_values_forgiving -v)",
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; print\\('OK'\\)\")",
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_full_report -v)",
"Bash(python -c \"from src.db.unit_of_work import UnitOfWork, get_uow; from src.db.repositories import MatchRepository, TeamRepository; print\\('OK'\\)\")",
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; from src.data.understat import UnderstatSource; from src.data.injuries import ingest_injuries; print\\('OK'\\)\")",
"Bash(python -m compileall src tests)",
"Bash(alembic current *)",
"Bash(alembic history *)",
"Bash(python -c \"from src.db.models import MatchStats, Prediction; print\\('Models OK'\\)\")",
"Bash(git pull *)",
"Bash(ocr delegate *)",
"Bash(npm i *)",
"Bash(python -c \"from src.llm.validation import KNOWN_AGENT_NAMES; print\\(KNOWN_AGENT_NAMES\\)\")"
]
}
}
+37
View File
@@ -0,0 +1,37 @@
# Python
__pycache__
*.py[cod]
*$py.class
*.egg-info
dist
build
# 虚拟环境
.venv
venv
# 环境配置(含敏感信息,绝不能打入镜像)
.env
.env.*
# 测试与工具
tests
.pytest_cache
.mypy_cache
.ruff_cache
# IDE
.idea
.vscode
# Git
.git
.gitignore
# 文档(运行时不需要)
docs
*.md
LICENSE*
.dockerignore
docker-compose.yml
nginx.conf
+35 -4
View File
@@ -1,12 +1,33 @@
# ---- 应用 ---- # ---- 应用 ----
# 运行环境: development | production
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
APP_ENV=development APP_ENV=development
LOG_LEVEL=INFO 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. SECRET_KEY: 加密主密钥与会话签名根密钥。
# 生成: openssl rand -base64 32
# 严禁使用 changeme/secret/123456 等弱值;变更后已加密配置无法解密。
SECRET_KEY=
# 2. 管理鉴权(至少一项):
# - ADMIN_PASSWORD: 后台登录初始密码(启动后自动 scrypt 哈希入库,之后在「系统配置」页修改)
# - ADMIN_API_KEY: 脚本直连接口用的密钥(请求头 X-API-Key)
# 两者都留空 = 不启用鉴权(仅本地开发)。
ADMIN_PASSWORD=
ADMIN_API_KEY=
# ---- 数据库 ---- # ---- 数据库 ----
POSTGRES_USER=football POSTGRES_USER=football
POSTGRES_PASSWORD=football POSTGRES_PASSWORD=football
POSTGRES_DB=football POSTGRES_DB=football
POSTGRES_PORT=5432 POSTGRES_PORT=5433
# 本地开发用 localhost;Docker Compose 内会被 environment 覆盖为 postgres 服务名
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
# ---- LLM (OpenAI-compatible,必填一个) ---- # ---- LLM (OpenAI-compatible,必填一个) ----
@@ -22,12 +43,22 @@ LLM_TIMEOUT=60
# ---- 数据源 ---- # ---- 数据源 ----
BZZOIRO_KEY= BZZOIRO_KEY=
API_FOOTBALL_KEY=
# ---- CORS ---- # ---- CORS ----
CORS_ORIGINS=http://localhost:5173,http://localhost:3000 CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# ---- 加密主密钥 ----
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
# 变更后已加密配置无法解密,需在后台重新保存。
SECRET_KEY=
# ---- 管理接口鉴权 ---- # ---- 管理接口鉴权 ----
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。 # 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值 # 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
ADMIN_PASSWORD=
# 管理接口会话有效期(小时),默认 7 天
ADMIN_SESSION_TTL_HOURS=168
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
ADMIN_API_KEY= ADMIN_API_KEY=
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
.pytest_cache/ .pytest_cache/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
logs/
# AI 助手上下文文件(不入库) # AI 助手上下文文件(不入库)
CLAUDE.md CLAUDE.md
+104
View File
@@ -0,0 +1,104 @@
# Profeto CI —— 首次引入自动化质量防线。
#
# 背景:此前仓库无任何 CI。唯一的测试文件 frontend/src/lib/http.test.ts
# 从未被自动执行过,类型检查也仅靠本地手跑 tsc。本流水线把
# 「类型检查 + 单元测试 + 构建」固化为 MR 门禁。
#
# 复用项目自带的 Docker 镜像(与 docker-compose.yml 同源),
# 避免 CI 环境与本地/线上不一致。
stages:
- verify
default:
image: node:22-alpine
# ── 前端:Lint ────────────────────────────────────────────────────
# 拦截「机器能看出来、人容易漏掉」的问题:幻影 CSS 变体、
# 硬编码色值、渲染期间副作用、未使用符号。
# 当前基线为 0 error / 56 warning,故本 job 会真实失败于新增 error。
frontend-lint:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm lint
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:类型检查 ────────────────────────────────────────────────
frontend-typecheck:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm typecheck
# 缓存 pnpm store,避免每次 MR 重新下载依赖
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:单元测试 ────────────────────────────────────────────────
# 这是 http.test.ts 第一次被自动化调用。它覆盖 HTTP 层的
# method/body/Content-Type 组装与 /health 免前缀豁免 —— 后者的
# 回归会导致管理后台右上角永远显示「系统异常」。
frontend-test:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm test
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── 前端:生产构建 ────────────────────────────────────────────────
# 独立于 typecheck:构建会暴露类型检查覆盖不到的问题
# (Tailwind 配置错误、资源缺失、chunk 拆分失败等)。
frontend-build:
stage: verify
before_script:
- cd frontend
- corepack enable
- pnpm install --frozen-lockfile
script:
- pnpm build
artifacts:
name: "frontend-dist-$CI_COMMIT_SHORT_SHA"
paths:
- frontend/dist/
expire_in: 1 week
cache:
key:
files:
- frontend/pnpm-lock.yaml
paths:
- frontend/node_modules/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+47
View File
@@ -0,0 +1,47 @@
__pycache__/
*.pyc
.env
.venv/
.pytest_cache/
frontend/node_modules/
frontend/dist/
# AI 助手上下文文件(不入库)
CLAUDE.md
docs/AGENTS.md
docs/agents/
# 本地审查/预览脚手架(不入库)
.tools/
.preview/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
node_modules/
bower_components/
jspm_packages/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+25 -4
View File
@@ -2,12 +2,33 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir hatchling # 直连官方源不稳定,固定使用清华 PyPI 镜像
COPY pyproject.toml README.md ./ ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
COPY src ./src
# P2-5: 创建非 root 用户(容器安全最佳实践)
RUN groupadd --system profeto && useradd --system --gid profeto profeto
# 可复现构建:先安装锁定版本的依赖(含哈希校验),再安装本项目
COPY pyproject.toml requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
# 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
# 非 root 进程写日志会 PermissionError(Errno 13)。
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
RUN mkdir -p /app/logs
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
RUN chown -R profeto:profeto /app
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"] # 以非 root 用户运行
USER profeto
# 启动时先跑迁移,再启 uvicorn
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
+113 -47
View File
@@ -12,28 +12,30 @@
│ REST API │ REST API
┌──────────────────────▼──────────────────────────────┐ ┌──────────────────────▼──────────────────────────────┐
│ FastAPI │ │ FastAPI │
│ ├── /api/v1/matches 比赛查询 │ ├── /api/v1/matches 比赛查询(公开只读)
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │ │ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
│ ├── /api/v1/ingest/* 数据采集 │ ├── /api/v1/ingest/* 数据采集(需管理员)
│ ├── /api/v1/eval/* 评估回填 │ ├── /api/v1/eval/* 评估回填(需管理员)
│ └── /api/v1/backtest 回测 │ └── /api/v1/backtest 回测(需管理员)
└──────────┬─────────────────────────────┬────────────┘ └──────────┬─────────────────────────────┬────────────┘
│ │ │ │
┌──────────▼──────────┐ ┌─────────────▼────────────┐ ┌──────────▼──────────┐ ┌─────────────▼────────────┐
│ PostgreSQL │ │ LLM (OpenAI-compatible) │ │ PostgreSQL │ │ LLM (OpenAI-compatible) │
6 张表 │ │ OpenAI / Deepseek / │ 12 张表 │ │ OpenAI / Deepseek / │
│ leagues/teams/ │ │ Ollama / 任意网关 │ │ leagues/teams/ │ │ Ollama / 任意网关 │
│ matches/match_ │ └──────────────────────────┘ │ matches/match_ │ └──────────────────────────┘
│ stats/predictions/ │ stats/standings/
injuries predictions/
│ app_settings/ │
│ schedules + │
│ raw_events 等 4 张 │
│ 数据治理表 │
└─────────────────────┘ └─────────────────────┘
│ 采集 │ 采集
┌──────────┴─────────────────────────────────────────┐ ┌──────────┴─────────────────────────────────────────┐
│ 数据源 (DataSource 协议 + 注册表) │ │ 数据源 (DataSource 协议 + 注册表) │
── bzzoiro 比分 / 统计 / xG ── bzzoiro 比分 / 赛程 / 统计 / 积分榜
│ ├── understat xG 回填 │
│ └── injuries 伤停数据 (api-football) │
└────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────┘
``` ```
@@ -55,7 +57,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐ 比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
├─→ B 攻防数据专家 ─┤ ├─→ B 攻防数据专家 ─┤
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测 ├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
├─→ D 阵容完整专家 ─┤ ├─→ D 联赛排名专家 ─┤
└─→ E 历史交锋专家 ─┘ └─→ E 历史交锋专家 ─┘
``` ```
@@ -66,8 +68,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
### 数据正确性保障 ### 数据正确性保障
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据 - **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
- **Injury 防泄漏**: 伤停查询强制 `retrieved_at <= cutoff`
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查 - **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
- **数据库约束**: CHECK 约束作为最后一道防线 - **数据库约束**: CHECK 约束作为最后一道防线
@@ -79,55 +80,106 @@ API Route → Application Service → Repository → UnitOfWork → DB
- Docker (运行 PostgreSQL) - Docker (运行 PostgreSQL)
- LLM API Key (OpenAI / Deepseek / Ollama 等) - LLM API Key (OpenAI / Deepseek / Ollama 等)
### 1. 安装 ### 方式一:Docker Compose 部署(推荐)
```bash ```bash
# 1. 克隆仓库
git clone https://git.bilidili.cn/shangfangjian/Profeto.git git clone https://git.bilidili.cn/shangfangjian/Profeto.git
cd Profeto cd Profeto
# 后端依赖 # 2. 配置环境变量
pip install -e ".[dev]"
# 配置环境变量
cp .env.example .env cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY # 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
# 3. 启动全部服务(自动构建 + 执行迁移)
docker compose up -d --build
# 4. 验证
curl http://localhost:8000/health
``` ```
### 2. 启动数据库 启动后访问:
- API 文档: http://localhost:8000/docs
- 前端界面: http://localhost:3000
> **说明**: `api` 容器启动时自动执行 `alembic upgrade head`,无需手动运行迁移。
### 方式二:本地开发部署
```bash ```bash
# 1. 克隆 + 安装
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
cd Profeto
pip install -e ".[dev]"
# 2. 配置环境变量
cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
# 3. 启动 PostgreSQL
docker compose up -d postgres docker compose up -d postgres
alembic upgrade head # 首次运行需要执行迁移
```
### 3. 启动服务 # 4. 执行迁移
alembic upgrade head
```bash # 5. 启动后端 (终端 1)
# 后端 (终端 1)
uvicorn src.api.app:app --reload uvicorn src.api.app:app --reload
# 前端 (终端 2) # 6. 启动前端 (终端 2)
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173` 后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`
## 生产上线检查清单
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
- [ ] `APP_ENV=production`(安全校验 / Cookie `Secure` / 管理端点 fail-closed 的总开关)
- [ ] `SECRET_KEY` 强随机:`openssl rand -base64 32`,禁止弱值
- [ ] `ADMIN_PASSWORD``ADMIN_API_KEY` 至少配置其一
- [ ] 数据库强密码,禁止 `football:football` 等示例弱密码
- [ ] HTTPS(反代终结 TLS;production 下会话 Cookie 自动 `Secure`)
- [ ] 反代后设 `TRUST_PROXY_HEADERS=True`,仅可信反代可达 API,并配置 `X-Forwarded-For` / `X-Real-IP`
- [ ] 限流前置到 Nginx `limit_req`;应用内限流与 KeyRing 仅单进程有效,多 worker 会放大配额
- [ ] uvicorn 单 worker(默认);需扩容先网关统一限流再起多实例
- [ ] 启动后验证 `/health``/health/ready` 均 200
- [ ] 数据库迁移已内置:compose/Dockerfile 启动即执行 `alembic upgrade head`
## 安全与限流
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
- 登录防爆破: 进程内内存计数,同上
- 公网部署建议 Nginx 层限流 + `TRUST_PROXY_HEADERS=True`
- 生产环境必须配置 `ADMIN_PASSWORD``ADMIN_API_KEY`(否则管理接口 503)
详见 [docs/06-deployment.md](docs/06-deployment.md#安全与限流)。
## API 概览 ## API 概览
**公开只读**(无需登录;`predict` 带内存限流):
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|---|---|---| |---|---|---|
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) | | GET | `/api/v1/leagues` | 联赛列表(仅 id/code/name/country) |
| GET | `/api/v1/leagues` | 联赛列表 | | GET | `/api/v1/matches` | 比赛查询(筛选/游标分页) |
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`) | | GET | `/api/v1/matches/{id}` | 比赛详情(含统计与最近预测) |
| GET | `/api/v1/predictions` | 预测历史 | | GET | `/api/v1/matches/{id}/context` | 比赛上下文(双方近况 + 历史交锋) |
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 | | GET | `/api/v1/standings` | 联赛积分榜 |
| POST | `/api/v1/ingest/understat` | 回填 xG | | POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`/`baseline`) |
| POST | `/api/v1/ingest/injuries` | 采集伤停 | | GET | `/health``/health/ready` | 存活 / 就绪检查(含 DB) |
**需管理员**(Cookie 会话或 `X-API-Key`):
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/v1/ingest/bzzoiro` | 采集赛果/赛程/统计/积分榜 |
| GET | `/api/v1/predictions` | 预测历史(列表) |
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
| POST | `/api/v1/eval/settle` | 回填实际结果 | | POST | `/api/v1/eval/settle` | 回填实际结果 |
| GET | `/api/v1/eval/summary` | 准确率汇总 | | GET | `/api/v1/eval/summary` | 准确率汇总 |
| POST | `/api/v1/backtest` | 历史回测 | | POST | `/api/v1/backtest` | 历史回测 |
| GET | `/health` | 存活检查 | | `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
| GET | `/health/ready` | 就绪检查(含 DB) |
## 项目结构 ## 项目结构
@@ -136,36 +188,46 @@ Profeto/
├── src/ ├── src/
│ ├── api/ # FastAPI 路由层 │ ├── api/ # FastAPI 路由层
│ │ ├── app.py # 应用工厂 + lifespan │ │ ├── app.py # 应用工厂 + lifespan
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
│ │ ├── schemas.py # Pydantic 请求/响应模型 │ │ ├── schemas.py # Pydantic 请求/响应模型
│ │ └── routes/ │ │ └── routes/
│ │ ├── matches.py # 比赛查询 │ │ ├── matches.py # 比赛查询(公开只读)
│ │ ├── predict.py # 预测入口 │ │ ├── predict.py # 预测入口 + 预测历史
│ │ ├── ingest.py # 数据采集 │ │ ├── ingest.py # 数据采集(需管理员)
│ │ ├── eval.py # 评估回填 │ │ ├── eval.py # 评估回填(需管理员)
│ │ ── backtest.py # 回测 │ │ ── backtest.py # 回测(需管理员)
│ │ ├── auth.py # 登录/登出/改密
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
│ ├── core/ # 基础设施 │ ├── core/ # 基础设施
│ │ ├── config.py # pydantic-settings 配置 │ │ ├── config.py # pydantic-settings 配置
│ │ ├── crypto.py # 加密/哈希
│ │ ├── http_client.py # 共享 httpx 客户端 │ │ ├── http_client.py # 共享 httpx 客户端
│ │ ── retry.py # 重试工具(指数退避) │ │ ── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
│ │ ├── scheduler.py # 进程内 cron 调度器
│ │ └── security_check.py # 启动安全校验
│ ├── data/ # 数据层 │ ├── data/ # 数据层
│ │ ├── sources.py # DataSource 协议 + 注册表 │ │ ├── sources.py # DataSource 协议 + 注册表
│ │ ├── bzzoiro.py # bzzoiro 数据源(events/standings/stats)
│ │ ├── normalize.py # 数据规范化契约 │ │ ├── normalize.py # 数据规范化契约
│ │ ├── bzzoiro.py # bzzoiro 数据源
│ │ ├── understat.py # understat xG 数据源
│ │ ├── injuries.py # 伤停数据
│ │ ├── config.py # 联赛映射常量 │ │ ├── config.py # 联赛映射常量
│ │ ── team_names.py # 队名归一化 │ │ ── key_ring.py # API Key 轮换环(429 冷却)
│ │ ├── team_names.py # 队名归一化
│ │ └── team_names_zh.py # 队名中文映射
│ ├── db/ # 数据库 │ ├── db/ # 数据库
│ │ ├── base.py # SQLAlchemy async engine │ │ ├── base.py # SQLAlchemy async engine
│ │ ├── models.py # ORM 模型 (6 表) │ │ ├── models.py # ORM 模型 (12 表)
│ │ ├── unit_of_work.py # UnitOfWork 事务封装 │ │ ├── unit_of_work.py # UnitOfWork 事务封装
│ │ └── repositories.py # Repository 数据访问 │ │ └── repositories.py # Repository 数据访问
│ └── llm/ # LLM 预测核心 │ └── llm/ # LLM 预测核心
│ ├── predict.py # 预测服务 (缓存 + 单/多模式) │ ├── predict.py # 预测服务 (缓存 + 单/多/基线模式)
│ ├── context_builder.py # 数据切片 + 上下文拼接 │ ├── context_builder.py # 数据切片 + 上下文拼接
│ ├── baseline.py # 基线预测(均值模型)
│ ├── eval.py # 评估统计 │ ├── eval.py # 评估统计
│ ├── backtest.py # 回测框架 │ ├── backtest.py # 回测框架
│ ├── provider.py # 多提供商 LLM 抽象 │ ├── provider.py # 多提供商 LLM 抽象
│ ├── utils.py # LLM 工具函数
│ ├── validation.py # LLM 输出校验 │ ├── validation.py # LLM 输出校验
│ ├── agents/ │ ├── agents/
│ │ ├── base.py # Agent 基础设施 + 解析 │ │ ├── base.py # Agent 基础设施 + 解析
@@ -173,6 +235,11 @@ Profeto/
│ └── prompts/ # Prompt 模板 │ └── prompts/ # Prompt 模板
├── alembic/ # 数据库迁移 ├── alembic/ # 数据库迁移
├── frontend/ # React 前端 ├── frontend/ # React 前端
│ └── src/
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
│ ├── components/ # 共享组件
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
├── docs/ # 详细文档 ├── docs/ # 详细文档
├── tests/ # 单元测试 ├── tests/ # 单元测试
├── docker-compose.yml ├── docker-compose.yml
@@ -207,7 +274,6 @@ Profeto/
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | | | `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | | | `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* | | `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
| `API_FOOTBALL_KEY` | api-football Key (伤停) | |
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` | | `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
## 测试 ## 测试
+24 -1
View File
@@ -3,7 +3,7 @@ from logging.config import fileConfig
import sys import sys
from pathlib import Path from pathlib import Path
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool, text
from alembic import context from alembic import context
# 把项目根加入 pythonpath,让 alembic 能找到 src 包 # 把项目根加入 pythonpath,让 alembic 能找到 src 包
@@ -29,6 +29,23 @@ if config.config_file_name is not None:
target_metadata = Base.metadata target_metadata = Base.metadata
def _ensure_version_table(connection):
"""确保 alembic_version 表存在且 version_num 列足够长。
Alembic 默认 version_num 是 String(32),但我们的迁移名较长(如
0005_prediction_status_and_stats_provenance 有 41 个字符),会导致
StringDataRightTruncation 错误。
"""
result = connection.execute(text(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'alembic_version')"
))
if not result.scalar():
connection.execute(text(
"CREATE TABLE alembic_version (version_num VARCHAR(255) NOT NULL PRIMARY KEY)"
))
connection.commit()
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.""" """Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
@@ -52,6 +69,12 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
_ensure_version_table(connection)
# SQLAlchemy 2.0 autobegin:上面的探测 SELECT 会留下隐式事务。
# 若不结束,alembic(>=1.16)会判定处于「外部事务」而全程不提交,
# 迁移在连接关闭时被静默回滚(upgrade 退出码仍为 0)。
if connection.in_transaction():
connection.commit()
context.configure(connection=connection, target_metadata=target_metadata) context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction(): with context.begin_transaction():
@@ -0,0 +1,107 @@
"""创建 Bronze 层、死信表、质量监控、血缘追踪 4 张新表
Revision ID: 0008_raw_event_and_ingest_failure
Revises: 0007_predictions_unique_constraint
Create Date: 2026-09-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision: str = '0008_raw_event_and_ingest_failure'
down_revision: Union[str, None] = '0007_predictions_unique_constraint'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. RawEvent - Bronze 层原始事件存档
op.create_table(
'raw_events',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('source_system', sa.String(50), nullable=False),
sa.Column('source_record_id', sa.String(100), nullable=False),
sa.Column('raw_payload', JSONB(), nullable=False),
sa.Column('ingested_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('ingest_batch_id', sa.String(36), nullable=True),
sa.UniqueConstraint('source_system', 'source_record_id', name='uq_raw_event'),
)
op.create_index('ix_raw_event_batch', 'raw_events', ['ingest_batch_id'])
# 2. IngestFailure - 采集失败死信表
op.create_table(
'ingest_failures',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('source_system', sa.String(50), nullable=False),
sa.Column('entity_type', sa.String(50), nullable=False),
sa.Column('source_record_id', sa.String(100), nullable=True),
sa.Column('error_type', sa.String(50), nullable=False),
sa.Column('error_detail', sa.Text(), nullable=True),
sa.Column('raw_payload', JSONB(), nullable=True),
sa.Column('retry_count', sa.Integer(), server_default='0'),
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('status', sa.String(20), server_default='pending'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('ix_ingest_failure_status', 'ingest_failures', ['status', 'next_retry_at'])
op.create_check_constraint(
'ck_ingest_failure_status',
'ingest_failures',
"status IN ('pending', 'retrying', 'resolved', 'abandoned')",
)
# 3. DataQualityCheck - 数据质量监控
op.create_table(
'data_quality_checks',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('check_name', sa.String(100), nullable=False),
sa.Column('entity_type', sa.String(50), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=True),
sa.Column('expected_value', sa.Float(), nullable=True),
sa.Column('actual_value', sa.Float(), nullable=False),
sa.Column('passed', sa.Boolean(), nullable=False),
sa.Column('severity', sa.String(10), server_default='warning'),
sa.Column('detail', JSONB(), nullable=True),
sa.Column('checked_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_dqc_checked_at', 'data_quality_checks', ['checked_at'])
op.create_index('ix_dqc_entity', 'data_quality_checks', ['entity_type', 'entity_id'])
# 4. DataLineage - ETL 血缘追踪
op.create_table(
'data_lineage',
sa.Column('id', sa.BigInteger(), primary_key=True),
sa.Column('source_system', sa.String(50), nullable=False),
sa.Column('source_record_id', sa.String(100), nullable=False),
sa.Column('target_table', sa.String(50), nullable=False),
sa.Column('target_id', sa.Integer(), nullable=True),
sa.Column('transform_name', sa.String(50), nullable=False),
sa.Column('transform_detail', JSONB(), nullable=True),
sa.Column('batch_id', sa.String(36), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_lineage_source', 'data_lineage', ['source_system', 'source_record_id'])
op.create_index('ix_lineage_target', 'data_lineage', ['target_table', 'target_id'])
op.create_index('ix_lineage_batch', 'data_lineage', ['batch_id'])
def downgrade() -> None:
op.drop_index('ix_lineage_batch', table_name='data_lineage')
op.drop_index('ix_lineage_target', table_name='data_lineage')
op.drop_index('ix_lineage_source', table_name='data_lineage')
op.drop_table('data_lineage')
op.drop_index('ix_dqc_entity', table_name='data_quality_checks')
op.drop_index('ix_dqc_checked_at', table_name='data_quality_checks')
op.drop_table('data_quality_checks')
op.drop_index('ix_ingest_failure_status', table_name='ingest_failures')
op.drop_table('ingest_failures')
op.drop_index('ix_raw_event_batch', table_name='raw_events')
op.drop_table('raw_events')
@@ -0,0 +1,29 @@
"""为 MatchStats 添加 xG 追踪字段
Revision ID: 0009_match_stats_xg_fields
Revises: 0008_raw_event_and_ingest_failure
Create Date: 2026-09-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0009_match_stats_xg_fields'
down_revision: Union[str, None] = '0008_raw_event_and_ingest_failure'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('match_stats', sa.Column('xg_source', sa.String(30), nullable=True))
op.add_column('match_stats', sa.Column('xg_updated_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('match_stats', sa.Column('xg_source_record_id', sa.String(100), nullable=True))
def downgrade() -> None:
op.drop_column('match_stats', 'xg_source_record_id')
op.drop_column('match_stats', 'xg_updated_at')
op.drop_column('match_stats', 'xg_source')
+30
View File
@@ -0,0 +1,30 @@
"""新增 app_settings 表(后台运行时配置)
Revision ID: 0010_app_settings
Revises: 0009_match_stats_xg_fields
Create Date: 2026-09-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0010_app_settings'
down_revision: Union[str, None] = '0009_match_stats_xg_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'app_settings',
sa.Column('key', sa.String(100), primary_key=True),
sa.Column('value', sa.Text(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('app_settings')
@@ -0,0 +1,27 @@
"""predictions 增加备选比分字段
Revision ID: 0011_prediction_alt_scores
Revises: 0010_app_settings
Create Date: 2026-09-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0011_prediction_alt_scores'
down_revision: Union[str, None] = '0010_app_settings'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('predictions', sa.Column('alt_pred_home_goals', sa.Integer(), nullable=True))
op.add_column('predictions', sa.Column('alt_pred_away_goals', sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column('predictions', 'alt_pred_away_goals')
op.drop_column('predictions', 'alt_pred_home_goals')
@@ -0,0 +1,65 @@
"""修复 injuries 唯一索引允许 NULL 重复 + 添加 return_date 字段
Revision ID: 0012_injuries_partial_unique_and_return_date
Revises: 0011_prediction_alt_scores
Create Date: 2026-09-20
Fix 4: 唯一索引 (player_id, fixture_id, injury_type) 三列均可 NULL,
PostgreSQL 允许多条 NULL 重复。改为 partial unique index:
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
Fix 2: return_date 字段已在 ORM 声明,确保数据库列存在。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used Alembic.
revision: str = '0012_injuries_partial_unique_and_return_date'
down_revision: Union[str, None] = '0011_prediction_alt_scores'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# 检查现有索引
indexes = {i["name"]: i for i in inspector.get_indexes("injuries")}
# Fix 4: 删除旧的全局唯一索引(允许 NULL 重复)
if "ix_injuries_player_fixture" in indexes:
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 创建 partial unique index: 只在 player_id 和 fixture_id 都非空时强制唯一
op.execute(
"""
CREATE UNIQUE INDEX ix_injuries_player_fixture
ON injuries (player_id, fixture_id, injury_type)
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
"""
)
# Fix 2: 确保 return_date 列存在(ORM 已声明,但早期迁移可能缺失)
columns = [c["name"] for c in inspector.get_columns("injuries")]
if "return_date" not in columns:
op.add_column(
"injuries",
sa.Column("return_date", sa.Date, nullable=True),
)
def downgrade() -> None:
# 删除 partial unique index
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
# 恢复旧的全局唯一索引
op.create_index(
"ix_injuries_player_fixture",
"injuries",
["player_id", "fixture_id", "injury_type"],
unique=True,
)
@@ -0,0 +1,74 @@
"""修复预测唯一约束过粗,增加 mode + run_type 维度
Revision ID: 0013_predictions_unique_constraint_mode_run_type
Revises: 0012_injuries_partial_unique_and_return_date
Create Date: 2026-09-20
背景:
原唯一约束 (match_id, provider, model) 过粗,回测写入会覆盖未结算的实盘预测,
后续 settle 会污染评估数据。
修复:
1. 新增 run_type 列(默认 'live'),区分实盘与回测
2. 唯一约束改为 (match_id, provider, model, mode, run_type)
3. 已有数据 run_type 回填为 'live'
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0013_predictions_unique_constraint_mode_run_type'
down_revision: Union[str, None] = '0012_injuries_partial_unique_and_return_date'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. 新增 run_type 列(先 nullable,回填后再改 NOT NULL)
op.add_column(
"predictions",
sa.Column("run_type", sa.String(10), nullable=True),
)
# 2. 回填已有数据:全部标记为 'live'
op.execute("UPDATE predictions SET run_type = 'live' WHERE run_type IS NULL")
# 3. 改为 NOT NULL
op.alter_column("predictions", "run_type", nullable=False)
# 4. 删除旧唯一约束
op.drop_constraint("uq_predictions_match_provider_model", "predictions", type_="unique")
# 5. 创建新唯一约束(包含 mode + run_type)
op.create_unique_constraint(
"uq_predictions_match_provider_model_mode_run_type",
"predictions",
["match_id", "provider", "model", "mode", "run_type"],
)
# 6. 添加 check constraint
op.create_check_constraint(
"ck_run_type_enum",
"predictions",
"run_type IN ('live', 'backtest')",
)
def downgrade() -> None:
# 1. 删除 check constraint
op.drop_constraint("ck_run_type_enum", "predictions", type_="check")
# 2. 删除新唯一约束
op.drop_constraint("uq_predictions_match_provider_model_mode_run_type", "predictions", type_="unique")
# 3. 恢复旧唯一约束
op.create_unique_constraint(
"uq_predictions_match_provider_model",
"predictions",
["match_id", "provider", "model"],
)
# 4. 删除 run_type 列
op.drop_column("predictions", "run_type")
@@ -0,0 +1,39 @@
"""为 predictions 表增加 agent_weights 列
Revision ID: 0014_predictions_agent_weights
Revises: 0013_predictions_unique_constraint_mode_run_type
Create Date: 2026-09-20
背景:
multi-agent 终裁的 agent_weights 原本只在 raw_response JSON 中,
无独立列,评估不便。本迁移增加 agent_weights JSONB 列,可空,旧行保持 NULL。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0014_predictions_agent_weights'
down_revision: Union[str, None] = '0013_predictions_unique_constraint_mode_run_type'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 新增 agent_weights 列(JSONB, 可空, 旧行保持 NULL)
op.add_column(
"predictions",
sa.Column(
"agent_weights",
sa.dialects.postgresql.JSONB(),
nullable=True,
comment="multi-agent 终裁各专家权重, 格式: {expert_name: weight}",
),
)
def downgrade() -> None:
# 删除 agent_weights 列
op.drop_column("predictions", "agent_weights")
@@ -0,0 +1,102 @@
"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries
Revision ID: 0015_bzzoiro_single_source
Revises: 0014_predictions_agent_weights
Create Date: 2026-09-20
变更内容:
1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/)
2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段:
home/away_big_chances, home/away_fouls
3. 删除 injuries 表(数据源已下线,不再采集伤停)
4. Understat 无独立表(xG 写入 match_stats),无需删表;
历史 source='understat' 数据保留,不再新增。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0015_bzzoiro_single_source'
down_revision: Union[str, None] = '0014_predictions_agent_weights'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 0. matches 增加数据血缘列(bzzoiro 上游事件 ID)
op.add_column('matches', sa.Column('source_event_id', sa.BigInteger()))
op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id'])
# 1. standings 表
op.create_table(
'standings',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False),
sa.Column('season', sa.String(12), nullable=False),
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('played', sa.Integer(), nullable=False, server_default='0'),
sa.Column('won', sa.Integer(), nullable=False, server_default='0'),
sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'),
sa.Column('lost', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'),
sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'),
sa.Column('points', sa.Integer(), nullable=False, server_default='0'),
sa.Column('xg_for', sa.Float()),
sa.Column('xg_against', sa.Float()),
sa.Column('form', sa.String(20)),
sa.Column('zone', sa.String(50)),
sa.Column('updated_at', sa.DateTime(timezone=True)),
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'),
)
op.create_index(
'ix_standings_league_season_pos', 'standings',
['league_id', 'season', 'position'],
)
# 2. match_stats 扩展字段
op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer()))
op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer()))
op.add_column('match_stats', sa.Column('home_fouls', sa.Integer()))
op.add_column('match_stats', sa.Column('away_fouls', sa.Integer()))
# 3. 删除 injuries 表
op.drop_index('ix_injuries_player_fixture', table_name='injuries')
op.drop_index('ix_injuries_team_date', table_name='injuries')
op.drop_table('injuries')
def downgrade() -> None:
# 恢复 injuries 表(不含数据)
op.create_table(
'injuries',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('player_id', sa.Integer()),
sa.Column('player_name', sa.String(120), nullable=False),
sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')),
sa.Column('fixture_id', sa.Integer()),
sa.Column('league_id', sa.Integer()),
sa.Column('injury_type', sa.String(50)),
sa.Column('reason', sa.String(200)),
sa.Column('injury_date', sa.Date()),
sa.Column('return_date', sa.Date()),
sa.Column('retrieved_at', sa.DateTime(timezone=True)),
)
op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type'])
op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date'])
op.drop_index('ix_matches_source_event_id', table_name='matches')
op.drop_column('matches', 'source_event_id')
op.drop_column('match_stats', 'away_fouls')
op.drop_column('match_stats', 'home_fouls')
op.drop_column('match_stats', 'away_big_chances')
op.drop_column('match_stats', 'home_big_chances')
op.drop_index('ix_standings_league_season_pos', table_name='standings')
op.drop_table('standings')
+38
View File
@@ -0,0 +1,38 @@
"""新增 schedules 表
Revision ID: 0016_schedules
Revises: 0015_bzzoiro_single_source
Create Date: 2026-09-21
定时采集任务配置表。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0016_schedules'
down_revision: Union[str, None] = '0015_bzzoiro_single_source'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'schedules',
sa.Column('id', sa.String(50), primary_key=True),
sa.Column('task', sa.String(20), nullable=False),
sa.Column('cron', sa.String(100), nullable=False),
sa.Column('leagues', sa.Text()),
sa.Column('enabled', sa.Boolean(), server_default='true'),
sa.Column('last_run_at', sa.DateTime(timezone=True)),
sa.Column('last_status', sa.String(20)),
sa.Column('created_at', sa.DateTime(timezone=True)),
sa.Column('updated_at', sa.DateTime(timezone=True)),
)
def downgrade() -> None:
op.drop_table('schedules')
+39
View File
@@ -0,0 +1,39 @@
"""扩展 ck_mode_enum 约束支持 baseline 模式
Revision ID: 0017_mode_baseline
Revises: 0016_schedules
Create Date: 2026-09-21
Code Review High-1/2 修复:
业务支持 mode='baseline'(非 LLM 统计基线),但 DB CHECK 约束只允许
('single', 'multi'),导致 baseline 预测写入时 CheckViolation。
run_type='baseline' 改为 'live'(代码侧),约束无需扩展。
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0017_mode_baseline'
down_revision: Union[str, None] = '0016_schedules'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
op.create_check_constraint(
'ck_mode_enum', 'predictions',
"mode IN ('single', 'multi', 'baseline')",
)
def downgrade() -> None:
# 先清理 baseline 数据再恢复旧约束
op.execute("DELETE FROM predictions WHERE mode = 'baseline'")
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
op.create_check_constraint(
'ck_mode_enum', 'predictions',
"mode IN ('single', 'multi')",
)
+52
View File
@@ -0,0 +1,52 @@
"""Match 表补充 CHECK 约束:完赛必须有比分 + 状态枚举 + 半场≤全场
Revision ID: 0018_match_checks
Revises: 0017_mode_baseline
Create Date: 2026-09-21
Code Review DB-5:
- 已完赛比赛必须有比分(数据库级兜底)
- match_status 枚举约束
- 半场进球 ≤ 全场进球
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0018_match_checks'
down_revision: Union[str, None] = '0017_mode_baseline'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 先清理可能违反新约束的数据
op.execute("UPDATE matches SET match_status = 'scheduled' WHERE match_status NOT IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')")
op.execute("UPDATE matches SET home_goals = 0, away_goals = 0 WHERE match_status = 'finished' AND (home_goals IS NULL OR away_goals IS NULL)")
# 添加 CHECK 约束
op.create_check_constraint(
'ck_matches_finished_has_score', 'matches',
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
)
op.create_check_constraint(
'ck_matches_status_enum', 'matches',
"match_status IN ('finished', 'scheduled', 'in_play', 'paused', 'postponed', 'cancelled', 'suspended')",
)
op.create_check_constraint(
'ck_matches_home_ht_le_full', 'matches',
"home_ht_goals IS NULL OR home_goals IS NULL OR home_ht_goals <= home_goals",
)
op.create_check_constraint(
'ck_matches_away_ht_le_full', 'matches',
"away_ht_goals IS NULL OR away_goals IS NULL OR away_ht_goals <= away_goals",
)
def downgrade() -> None:
op.drop_constraint('ck_matches_away_ht_le_full', 'matches', type_='check')
op.drop_constraint('ck_matches_home_ht_le_full', 'matches', type_='check')
op.drop_constraint('ck_matches_status_enum', 'matches', type_='check')
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
+42
View File
@@ -0,0 +1,42 @@
"""采集任务状态表 ingest_jobs
Revision ID: 0019_ingest_jobs
Revises: 0018_match_checks
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0019_ingest_jobs'
down_revision: Union[str, None] = '0018_match_checks'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'ingest_jobs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('task', sa.String(20), nullable=False),
sa.Column('params', sa.JSON(), nullable=False, server_default='{}'),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('result', sa.JSON(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
)
op.create_index('ix_ingest_job_status_created', 'ingest_jobs', ['status', 'created_at'])
op.create_check_constraint(
'ck_ingest_job_status', 'ingest_jobs',
"status IN ('pending', 'running', 'success', 'failed')",
)
def downgrade() -> None:
op.drop_constraint('ck_ingest_job_status', 'ingest_jobs', type_='check')
op.drop_index('ix_ingest_job_status_created', table_name='ingest_jobs')
op.drop_table('ingest_jobs')
+32
View File
@@ -0,0 +1,32 @@
"""球队别名表 team_aliases
Revision ID: 0020_team_aliases
Revises: 0019_ingest_jobs
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0020_team_aliases'
down_revision: Union[str, None] = '0019_ingest_jobs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'team_aliases',
sa.Column('alias_normalized', sa.String(120), primary_key=True),
sa.Column('team_id', sa.Integer, sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=False),
sa.Column('original_alias', sa.String(120), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_team_aliases_team_id', 'team_aliases', ['team_id'])
def downgrade() -> None:
op.drop_index('ix_team_aliases_team_id', table_name='team_aliases')
op.drop_table('team_aliases')
@@ -0,0 +1,38 @@
"""matches.source_event_id 部分唯一索引
业务唯一键:同联赛同主客同自然天一条(ix_matches_unique,既有)。
source_event_id 是上游 bzzoiro 的比赛 id,用于统计回填与血缘追踪;
当它非空时应全局唯一(同一 upstream 比赛只对应一行 matches),
避免同一场比赛因自然键天级舍入差异产生重复。
partial unique(WHERE source_event_id IS NOT NULL):
- 兼容存量空 source_event_id 的历史行(不强制回填);
- 新采集行均带 source_event_id,从此具备 upstream 唯一性。
Revision ID: 0021_match_source_event_id_unique
Revises: 0020_team_aliases
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0021_match_source_event_id_unique'
down_revision: Union[str, None] = '0020_team_aliases'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
'ix_matches_source_event_id_unique',
'matches',
['source_event_id'],
unique=True,
postgresql_where='source_event_id IS NOT NULL',
)
def downgrade() -> None:
op.drop_index('ix_matches_source_event_id_unique', table_name='matches')
@@ -0,0 +1,63 @@
"""P0-01: 比分可信度——score_status + 允许完赛缺分(NULL,禁止伪造 0:0)
替换 ck_matches_finished_has_score:引入 score_status(known/missing/unknown),
完赛 + score_status=missing 时 home/away_goals 必须 NULL(不伪造比分)。
Revision ID: 0022_match_score_status
Revises: 0021_match_source_event_id_unique
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0022_match_score_status'
down_revision: Union[str, None] = '0021_match_source_event_id_unique'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1) 新增 score_status 列(默认 unknown)
op.add_column(
'matches',
sa.Column('score_status', sa.String(20), server_default='unknown', nullable=False),
)
# 2) 按现有数据回填 score_status(绝不写 goals=0):
# - 有比分(两列均非 NULL) → known
# - 无比分 + 完赛 → missing(缺分)
# - 其余 → unknown
op.execute(
"UPDATE matches SET score_status = 'known'"
" WHERE home_goals IS NOT NULL AND away_goals IS NOT NULL"
)
op.execute(
"UPDATE matches SET score_status = 'missing'"
" WHERE match_status = 'finished' AND home_goals IS NULL AND away_goals IS NULL"
)
# 3) 删除旧约束,加新约束
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
op.create_check_constraint(
'ck_matches_score_status_enum', 'matches',
"score_status IN ('known', 'missing', 'unknown')",
)
op.create_check_constraint(
'ck_matches_score_integrity', 'matches',
"match_status <> 'finished'"
" OR (score_status = 'known' AND home_goals IS NOT NULL AND away_goals IS NOT NULL)"
" OR (score_status IN ('missing', 'unknown') AND home_goals IS NULL AND away_goals IS NULL)",
)
def downgrade() -> None:
op.drop_constraint('ck_matches_score_integrity', 'matches', type_='check')
op.drop_constraint('ck_matches_score_status_enum', 'matches', type_='check')
op.create_check_constraint(
'ck_matches_finished_has_score', 'matches',
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
)
op.remove_column('matches', 'score_status')
@@ -0,0 +1,59 @@
"""P0-02: 积分榜改为追加快照(append-only) + available_at
去掉 uq_standings_league_season_team(league,season,team 唯一),
改为 (league, season, team, available_at) 唯一;
每次采集 INSERT 新行(available_at=now),支持回测还原历史榜单。
Revision ID: 0023_standings_append_only
Revises: 0022_match_score_status
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0023_standings_append_only'
down_revision: Union[str, None] = '0022_match_score_status'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
from datetime import datetime, timezone
# 1) 加 available_at 列(非空,默认 now;存量回填 retrieved_at 或 now)
op.add_column(
'standings',
sa.Column('available_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
# 存量行: available_at 取 retrieved_at(若存在)否则 now
op.execute("UPDATE standings SET available_at = COALESCE(retrieved_at, NOW())")
# 2) 去旧唯一约束,加新唯一约束(league, season, team, available_at)
op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique')
# 原始索引名拼写为 leason(历史遗留),按实际库名删除
op.drop_index('ix_standings_league_season_pos', table_name='standings', if_exists=True)
op.create_index('ix_standings_league_season_pos_v2', 'standings', ['league_id', 'season', 'position'])
op.create_unique_constraint(
'uq_standings_league_season_team_available', 'standings',
['league_id', 'season', 'team_id', 'available_at'],
)
op.create_index(
'ix_standings_league_season_team_available', 'standings',
['league_id', 'season', 'team_id', 'available_at'],
)
def downgrade() -> None:
op.drop_index('ix_standings_league_season_team_available', table_name='standings')
op.drop_constraint('uq_standings_league_season_team_available', 'standings', type_='unique')
op.drop_index('ix_standings_league_season_pos', table_name='standings')
op.create_index('ix_standings_leason_season_pos', 'standings', ['league_id', 'season', 'position'])
op.create_unique_constraint(
'uq_standings_league_season_team', 'standings',
['league_id', 'season', 'team_id'],
)
op.drop_column('standings', 'available_at')
@@ -0,0 +1,41 @@
"""P0-03: Prediction 幂等指纹——移除旧唯一约束,改为 partial unique on input_hash
input_hash 非空时唯一(同指纹返回已有行,不 UPDATE/INSERT);
兼容旧数据 NULL input_hash(不强制回填)。
Revision ID: 0024_prediction_idempotent_fingerprint
Revises: 0023_standings_append_only
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0024_prediction_idempotent_fingerprint'
down_revision: Union[str, None] = '0023_standings_append_only'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 移除旧唯一约束(match, provider, model, mode, run_type)
op.drop_constraint(
'uq_predictions_match_provider_model_mode_run_type',
'predictions', type_='unique',
)
# P0-03: partial unique on input_hash(非空时唯一)
op.create_index(
'ix_predictions_input_hash_unique', 'predictions', ['input_hash'], unique=True,
postgresql_where=sa.text('input_hash IS NOT NULL'),
)
def downgrade() -> None:
op.drop_index('ix_predictions_input_hash_unique', table_name='predictions')
op.create_unique_constraint(
'uq_predictions_match_provider_model_mode_run_type',
'predictions',
['match_id', 'provider', 'model', 'mode', 'run_type'],
)
+34 -2
View File
@@ -6,7 +6,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}
POSTGRES_DB: ${POSTGRES_DB:-football} POSTGRES_DB: ${POSTGRES_DB:-football}
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5433}:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -17,15 +17,47 @@ services:
api: api:
build: . build: .
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload # Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
# Fix 3: 启动时先跑 alembic upgrade,再启 uvicorn
command: >
sh -c "alembic upgrade head &&
uvicorn src.api.app:app --host 0.0.0.0 --port 8000"
ports: ports:
- "${API_PORT:-8000}:8000" - "${API_PORT:-8000}:8000"
environment:
# 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 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"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ./src:/app/src - ./src:/app/src
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini
- applogs:/app/logs
frontend:
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
build:
context: ./frontend
dockerfile: Dockerfile.frontend
ports:
- "${FRONTEND_PORT:-3000}:80"
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes: volumes:
pgdata: pgdata:
applogs:
+36 -28
View File
@@ -13,16 +13,15 @@
│ │ │ │
│ 数据查询 预测编排 采集(手动/cron 触发) │ │ 数据查询 预测编排 采集(手动/cron 触发) │
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │ │ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │ │ │matches│ │ orchestrator│ │ bzzoiro (唯一源) │ │
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │ │ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
│ └──┬───┘ │ │ h2h / form / stats / │ │ │ └──┬───┘ │ │ h2h / form / stats / │ │
│ │ │ │ home_away / injuries │ │ │ │ │ │ home_away / standings │ │
│ │ │ └─ aggregator 终裁(强模型) │ │ │ │ │ └─ aggregator 终裁(强模型) │ │
│ │ └────────────┘ └───────────────────┘ │ │ └────────────┘ │ events / standings │
│ │ └ understat (xG) ┌──┴──────────────┴──┐ │ /stats 三条管线 │
──────────────────┐ └ injuries (伤停) │ PostgreSQL (12 张表)│ └───────────────────┘
│ PostgreSQL (6 张表) │ httpx → 外部 API │ └────────────────────┘ httpx → 外部 API │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────┘
``` ```
@@ -31,7 +30,7 @@
1. `POST /predict {match_id}` → orchestrator 1. `POST /predict {match_id}` → orchestrator
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load) 2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
3. **5 个专家 agent 并行**(`asyncio.gather`),每个: 3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG) - 各自的数据切片函数查库(近况/交锋/积分榜聚合/射门控球/xG)
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉) - 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据) - 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON 4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
@@ -44,7 +43,7 @@
|---|---| |---|---|
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 | | **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) | | **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 | | **no_data 门控** | 无数据维度(如积分榜未采集)不调 LLM,终裁知道维度缺失,不编造 |
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 | | **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 | | **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) | | **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
@@ -57,33 +56,38 @@
Profeto/ Profeto/
├── src/ ├── src/
│ ├── api/ │ ├── api/
│ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表) │ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key) │ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
│ │ ├── schemas.py # Pydantic v2 请求/响应 │ │ ├── schemas.py # Pydantic v2 请求/响应
│ │ └── routes/ │ │ └── routes/
│ │ ├── matches.py # 联赛/比赛查询(游标分页) │ │ ├── matches.py # 联赛/比赛/上下文/积分榜(公开只读)
│ │ ├── predict.py # 预测 + 预测历史 │ │ ├── predict.py # 预测(限流)+ 预测历史(需鉴权)
│ │ ├── ingest.py # 采集触发(自管 session,需鉴权) │ │ ├── ingest.py # 采集触发(需鉴权)
│ │ ├── eval.py # 赛后回填 + 准确率汇总 │ │ ├── eval.py # 赛后回填 + 准确率汇总(需鉴权)
│ │ ── backtest.py # 历史回测(需鉴权) │ │ ── backtest.py # 历史回测(需鉴权)
│ │ ├── auth.py # 登录/登出/改密
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
│ ├── db/ │ ├── db/
│ │ ├── base.py # async engine + get_db/get_db_read │ │ ├── base.py # async engine + get_db/get_db_read
│ │ ├── models.py # 6 张表 ORM │ │ ├── models.py # 12 张表 ORM
│ │ ├── repositories.py # 仓储层 │ │ ├── repositories.py # 仓储层
│ │ └── unit_of_work.py # 事务边界 │ │ └── unit_of_work.py # 事务边界
│ ├── data/ │ ├── data/
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库 │ │ ├── bzzoiro.py # 唯一数据源:events/standings/stats 三管线 + Bronze 层
│ │ ├── understat.py # xG 回填
│ │ ├── injuries.py # 伤停采集(带文件缓存)
│ │ ├── normalize.py # NormalizedMatch 清洗契约 │ │ ├── normalize.py # NormalizedMatch 清洗契约
│ │ ├── team_names.py # 队名归一映射 │ │ ├── team_names.py # 队名归一映射
│ │ ├── team_names_zh.py # 队名中文名映射
│ │ ├── key_ring.py # 多 key 轮换(429 冷却,进程内)
│ │ ├── sources.py # 数据源注册表 │ │ ├── sources.py # 数据源注册表
│ │ └── config.py # 联赛代码映射 │ │ └── config.py # 联赛代码映射
│ ├── llm/ │ ├── llm/
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析) │ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接 │ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/standings)+ 单 agent 拼接
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存) │ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
│ │ ├── baseline.py # 基线预测(均值模型,mode=baseline)
│ │ ├── eval.py # 准确率统计 │ │ ├── eval.py # 准确率统计
│ │ ├── utils.py # LLM 工具函数
│ │ ├── validation.py # LLM 输出严格校验(Pydantic) │ │ ├── validation.py # LLM 输出严格校验(Pydantic)
│ │ ├── backtest.py # 回测执行 │ │ ├── backtest.py # 回测执行
│ │ ├── agents/ │ │ ├── agents/
@@ -91,16 +95,20 @@ Profeto/
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库 │ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
│ │ └── prompts/ │ │ └── prompts/
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板 │ │ ├── match_prediction_v1/v2.md # 单 agent 模板
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md │ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
│ └── core/ │ └── core/
│ ├── config.py # pydantic-settings │ ├── config.py # pydantic-settings
│ ├── crypto.py # 加密/哈希
│ ├── http_client.py # 共享 httpx 客户端 │ ├── http_client.py # 共享 httpx 客户端
── retry.py # 重试工具 ── log_buffer.py # 内存日志缓冲(admin 日志页)
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理) │ ├── runtime_config.py # DB 配置覆盖(app_settings)
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页) │ ├── scheduler.py # 进程内 cron 调度器
├── tests/ # 核心 + agent 测试 │ └── security_check.py # 启动安全校验
├── alembic/versions/ # 0001~0018(建表 → Bronze 层 → 单一数据源 → 基线模式等)
├── frontend/src/ # pages/(公开站) + admin/(管理后台) + lib/http.ts(唯一 HTTP 实现)
├── tests/ # 核心 + agent 测试(250+ 项,自包含)
├── docker-compose.yml # api + postgres 两容器 ├── docker-compose.yml # api + postgres 两容器
└── docs/ # 本文档 └── docs/ # 本文档
``` ```
## 技术栈 ## 技术栈
@@ -113,5 +121,5 @@ Profeto/
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) | | HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) | | LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
| 前端 | Vite + React 18 + TypeScript + Tailwind | | 前端 | Vite + React 18 + TypeScript + Tailwind |
| 测试 | pytest + pytest-asyncio(33 项,自包含) | | 测试 | pytest + pytest-asyncio(250+ 项,自包含) |
| 部署 | Docker Compose(api + postgres) | | 部署 | Docker Compose(api + postgres) |
+13 -4
View File
@@ -66,6 +66,9 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}' -d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
``` ```
> 注:采集/评估端点需管理员凭据。本地开发环境(未配置鉴权、非 production)默认放行;
> 生产环境需先 `POST /auth/login` 取 Cookie,或带 `X-API-Key` 头。
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史): 数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
```bash ```bash
@@ -74,14 +77,20 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}' -d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
``` ```
### 回填 xG(可选,让攻防数据 agent 有数据) ### 回填积分榜与统计(让攻防/排名专家有数据)
```bash ```bash
curl -X POST http://localhost:8000/api/v1/ingest/understat \ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"league":"E0","season":2025}' -d '{"task":"standings"}'
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
-H "Content-Type: application/json" \
-d '{"task":"stats","leagues":["E0"],"limit":300}'
``` ```
`task=stats` 只补空字段(xG/射门/控球等),不创建比赛。
### 查比赛 ### 查比赛
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛"; 浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
@@ -126,4 +135,4 @@ curl http://localhost:8000/api/v1/eval/summary
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) | | predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 | | 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 | | 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 | | stats 专家报无 xG/统计 | 先跑 `task=stats` 回填(bzzoiro 统计管线,只补空字段) |
+80 -34
View File
@@ -4,18 +4,30 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。 所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
## 鉴权模型
| 级别 | 端点 | 说明 |
|---|---|---|
| **公开只读** | `GET /leagues``GET /matches``GET /matches/{id}``GET /matches/{id}/context``GET /standings``GET /health*` | 无需任何凭据;公开站直接调用 |
| **公开 + 限流** | `POST /predict` | 内存滑动窗口限流(10 次/分钟/IP) |
| **需管理员** | `GET /predictions*``POST /ingest/bzzoiro``/eval/*``POST /backtest``/admin/**` | Cookie 会话(`POST /auth/login` 颁发)或 `X-API-Key` 头 |
管理端点在生产环境未配置鉴权时 fail-closed(503),不会静默放行。
--- ---
## 数据查询 ## 数据查询(公开只读)
### `GET /api/v1/leagues` ### `GET /api/v1/leagues`
列出已入库联赛。 列出已入库联赛(P1-3: 公开站联赛筛选动态加载来源)
```json ```json
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}] [{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
``` ```
仅返回 `id/code/name/country` 四个展示字段,不含任何配置或密钥信息。
### `GET /api/v1/matches` ### `GET /api/v1/matches`
比赛列表,游标分页。 比赛列表,游标分页。
@@ -47,7 +59,44 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
### `GET /api/v1/matches/{id}` ### `GET /api/v1/matches/{id}`
单场比赛详情,字段同上。 单场比赛详情,字段同上,另含 `stats`(统计)与 `recent_predictions`(最近 5 条预测摘要)
### `GET /api/v1/matches/{id}/context`
比赛上下文(公开只读,P1-2: 公开站详情页「近况/交锋」数据来源;不触发 LLM):
- `home_recent`: 主队最近 5 场已完赛
- `away_recent`: 客队最近 5 场已完赛
- `h2h`: 双方最近 5 次交手
```json
{
"home_recent": [
{"match_date": "2026-09-12T14:00:00+00:00", "home_team": "阿森纳",
"away_team": "切尔西", "home_goals": 2, "away_goals": 1}
],
"away_recent": [],
"h2h": []
}
```
数据不足时对应列表为空(前端展示空态)。比赛不存在返回 404。
### `GET /api/v1/standings`
联赛积分榜(公开只读)。参数:`league`(联赛代码,空 = 全部)、`season`(空 = 各联赛最新赛季)。
```json
{"leagues": [
{"league_code": "E0", "league_name": "Premier League", "season": "2026-2027",
"retrieved_at": "2026-09-20T08:00:00+00:00",
"rows": [{"position": 1, "team": "阿森纳", "team_en": "Arsenal",
"played": 5, "won": 4, "drawn": 1, "lost": 0,
"goals_for": 11, "goals_against": 3, "goal_diff": 8,
"points": 13, "xg_for": 9.8, "xg_against": 3.9,
"form": "WWWDW", "zone": "UEFA Champions League"}]}
]}
```
--- ---
@@ -96,13 +145,13 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null, "exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
"model": "gpt-4o-mini", "latency_ms": 2100, "model": "gpt-4o-mini", "latency_ms": 2100,
"prompt_tokens": 380, "completion_tokens": 120}, "prompt_tokens": 380, "completion_tokens": 120},
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none", {"agent": "standings", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null, "analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null, "key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
"probable_score": null, "model": "", "latency_ms": null, "probable_score": null, "model": "", "latency_ms": null,
"prompt_tokens": null, "completion_tokens": null} "prompt_tokens": null, "completion_tokens": null}
], ],
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}, "agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8},
"context": "[5 份报告的 JSON 串]", "context": "[5 份报告的 JSON 串]",
"latency_ms": 9800 "latency_ms": 9800
} }
@@ -112,11 +161,11 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。 错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
### `GET /api/v1/predictions?match_id=&limit=` ### `GET /api/v1/predictions?match_id=&limit=`(需管理员)
预测历史(倒序),含 `settled` 与实际比分回填状态。 预测历史(倒序),含 `settled` 与实际比分回填状态。
### `GET /api/v1/predictions/{id}` ### `GET /api/v1/predictions/{id}`(需管理员)
单条预测详情(含完整 `agent_outputs`)。 单条预测详情(含完整 `agent_outputs`)。
@@ -124,40 +173,34 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
## 数据采集 ## 数据采集
### `POST /api/v1/ingest/bzzoiro` ### `POST /api/v1/ingest/bzzoiro`(需管理员)
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。 从 bzzoiro(唯一数据源)采集数据并入库(幂等,重复跑安全)。任务在后台异步执行,请求立即返回。
```json ```json
{"leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"} {"task": "all", "leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
``` ```
- `status` 还可传 `scheduled` 拉未来赛程 | 字段 | 默认 | 说明 |
|---|---|---|
| `task` | `events` | 采集任务:`events`(比赛)/ `standings`(积分榜)/ `stats`(统计回填,含 xG)/ `all` |
| `leagues` | 全部已知联赛 | 联赛代码列表,如 `["E0", "SP1"]` |
| `date_from` / `date_to` | 空 | 日期范围(`YYYY-MM-DD`) |
| `status` | 空(两者都采) | `finished`(已完赛)/ `scheduled`(未来赛程) |
| `season` | 当前赛季 | standings 赛季,如 `"2026-2027"` |
| `limit` | 100 | stats 回填单次最大比赛数(1500) |
- 响应含每联赛 `inserted`/`updated`/`errors` 统计 - 响应含每联赛 `inserted`/`updated`/`errors` 统计
- `task=stats` 只补空字段、不创建比赛(xG/射门/控球等统计回填)
### `POST /api/v1/ingest/understat` > 历史版本曾有独立的 understat(xG)与 injuries(伤停)采集端点,
> 已随数据源收敛为 bzzoiro 唯一来源而移除。
回填 xG(只补空字段,不创建比赛):
```json
{"league": "E0", "season": 2025}
```
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
### `POST /api/v1/ingest/injuries`
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
```json
{"date": "2026-09-10"}
```
--- ---
## 评估 ## 评估
### `POST /api/v1/eval/settle` ### `POST /api/v1/eval/settle`(需管理员)
赛后回填实际比分: 赛后回填实际比分:
@@ -165,7 +208,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
{"prediction_id": 7, "home_goals": 2, "away_goals": 1} {"prediction_id": 7, "home_goals": 2, "away_goals": 1}
``` ```
### `GET /api/v1/eval/summary` ### `GET /api/v1/eval/summary`(需管理员)
`provider × model` 聚合已结算预测: `provider × model` 聚合已结算预测:
@@ -183,7 +226,10 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
## 基础 ## 基础
| 端点 | 说明 | | 端点 | 权限 | 说明 |
|---|---| |---|---|---|
| `GET /health` | 存活检查 | | `GET /health` | 公开 | 存活检查 |
| `GET /docs` | Swagger UI | | `GET /docs``GET /redoc` | 公开 | Swagger UI / ReDoc |
| `POST /auth/login``POST /auth/logout``GET /auth/me` | 公开 | 管理员 Cookie 会话登录/登出/当前用户 |
| `POST /auth/change-password` | 需管理员 | 修改管理员密码 |
| `POST /backtest` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
+20 -7
View File
@@ -10,7 +10,7 @@ Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 | | `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 | | `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 | | `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge``no_data` | | `standings` 联赛排名 | 结合积分榜排名、积分与分区(欧冠/欧联/降级),评估双方竞争位置 | 两队当前赛季积分榜行(排名/积分/分区/近期战绩) | `home_edge``no_data` |
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 | | `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 | | `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
@@ -25,7 +25,7 @@ POST /predict {match_id, mode: "multi"}
│ ├─ form agent ─┐ │ ├─ form agent ─┐
│ ├─ stats agent │ 每个 agent 拿到专属数据切片 │ ├─ stats agent │ 每个 agent 拿到专属数据切片
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告 │ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub) │ ├─ standings agent │ (无数据 → 跳过 LLM,返回 stub)
│ └─ h2h agent ─┘ │ └─ h2h agent ─┘
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON) ├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
@@ -39,12 +39,12 @@ POST /predict {match_id, mode: "multi"}
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。 5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
### 2. no_data 门控(省 token、防幻觉) ### 2. no_data 门控(省 token、防幻觉)
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回: 数据切片为空时(如该场比赛的积分榜尚未采集),**跳过 LLM 调用**,直接返回:
```json ```json
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none", {"agent": "standings", "status": "no_data", "data_sufficiency": "none",
"analysis": "该维度无数据,跳过分析。"} "analysis": "该维度无数据,跳过分析。"}
``` ```
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。 终裁 Agent 会看到这个 `no_data` 状态,不会编造积分榜分析。
### 3. fail-open(单专家失败不阻断) ### 3. fail-open(单专家失败不阻断)
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。 单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
@@ -89,7 +89,7 @@ POST /predict {match_id, mode: "multi"}
"1x2": "1", "1x2": "1",
"subjective_confidence": 0.68, "subjective_confidence": 0.68,
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……", "reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8} "agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8}
} }
``` ```
@@ -120,7 +120,7 @@ agents/
├── form_v1.md # 近期状态专家 ├── form_v1.md # 近期状态专家
├── stats_v1.md # 攻防数据专家 ├── stats_v1.md # 攻防数据专家
├── home_away_v1.md # 主客因素专家 ├── home_away_v1.md # 主客因素专家
├── injuries_v1.md # 阵容完整性专家 ├── standings_v1.md # 联赛排名专家
├── h2h_v1.md # 历史交锋专家 ├── h2h_v1.md # 历史交锋专家
└── aggregator_v1.md # 终裁 └── aggregator_v1.md # 终裁
``` ```
@@ -129,6 +129,19 @@ agents/
迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"` 迭代 prompt 时:复制 `h2h_v1.md``h2h_v2.md`,改内容,传 `prompt_version: "v2"`
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。 `predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
### 版本纪律(强制)
> **改 prompt 内容必须 bump 版本号(v2→v3),禁止默默修改 `*_v1.md` 内容却不改版本。**
原因:
1. **可复现性**:`predictions.prompt_version` 决定哪份 prompt 产生了历史预测;篡改 v1 会让历史预测的 prompt 来源失真,eval 对比失效。
2. **A/B 可信度**:`get_eval_summary``(provider, model, prompt_version)` 分组。若 v1 内容在不同时间指向不同 prompt,则 v1 桶内数据不可比。
3. **缓存一致性**:模板内容 hash 写入缓存键(`_prompt_template_hash`),版本不变则 hash 不变,命中旧缓存。bump 版本自动让旧缓存失效。
**流程**:改 prompt → 新建 `*_v{N+1}.md` → 新请求传 `prompt_version=v{N+1}` → 旧版本文件保持不变(供历史复现)。
代码保证:写入 DB 的 `prompt_version``_load_prompt_template(version)` / `load_agent_prompt(name, version)` 加载的文件**严格一致**,不会漂移。
## 如何新增一个专家 Agent ## 如何新增一个专家 Agent
三步: 三步:
+110 -17
View File
@@ -4,9 +4,10 @@
| 数据源 | 用途 | 必需 Key | 说明 | | 数据源 | 用途 | 必需 Key | 说明 |
|---|---|---|---| |---|---|---|---|
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 | | bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 |
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context | > 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源,
> 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。
### bzzoiro ### bzzoiro
@@ -17,11 +18,16 @@
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8} BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
``` ```
### understat > ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 API 模式推测(如 `home_shots`/`away_shots`),
> 未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取) > 请提供一份 event 样例核对以下字段:
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛** > - 射门: `home_shots` / `away_shots`
- 通过"天级日期 + 队名归一"匹配已有比赛 > - 射正: `home_shots_on_target` / `away_shots_on_target`
> - 角球: `home_corners` / `away_corners`
> - 控球: `home_possession`
> - xG: `home_xg` / `away_xg`
> - 黄牌: `home_yellow_cards` / `away_yellow_cards`
> - 红牌: `home_red_cards` / `away_red_cards`
## 数据清洗契约 ## 数据清洗契约
@@ -54,12 +60,68 @@
### 队名归一化 ### 队名归一化
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。 `src/data.team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
归一前先做 Unicode NFKD 去重音。 归一前先做 Unicode NFKD 去重音。
**唯一键是归一后英文名**:`teams.name` 带 `UNIQUE` 约束,所有入库路径均经 `TeamRepository.get_or_create` 收敛归一化
(events / standings 管线在调用前归一,仓库层再做一次幂等归一作为兜底)。创建新 Team 时打 `info` 日志记录「原始名 → 归一后名」。
> ⚠️ **`normalize` 当前大小写敏感**:仅当入参大小写与 `NORMALIZE_MAP` 键完全匹配时才触发映射
>(如 `"Man City"` → `"Manchester City"`,但 `"man city"` 原样保留)。上游 bzzoiro 返回的队名首字母大写,
>实际命中无问题;若新增数据源返回全小写/全大写队名,需先 `title()` 再归一,否则会绕过映射产生重复 Team。
#### 别名机制(`team_aliases`)
归一仍可能遗漏历史重复队(如 `"Bayern Munich"` 与 `"Bayern München"` 经 NFKD 后相同则命中,
但 `"Man United"` vs `"Manchester United"` 若漏映射)。`team_aliases` 表提供**显式别名→teams.id** 映射:
| 列 | 说明 |
|----|------|
| `alias_normalized` | PK,`normalize(别名)` 后的稳定幂等键 |
| `team_id` | FK → `teams.id`(ON DELETE CASCADE) |
| `original_alias` | 原始写法(保留供参考) |
**定位三步链**(`get_or_create`):`normalize(name)` → 查 `teams.name` → 查 `team_aliases`(以 `normalize(name)` 为 PK)→ 都没有才 insert 新 Team。别名命中即复用已有 Team,避免产生重复。
**添加别名**(不自动合并历史重复队):
- **Admin 接口**(推荐):`POST /api/v1/admin/teams/aliases {"alias": "Man United", "team_id": 42}`(require_admin,幂等)
- **直接 SQL**:
```sql
INSERT INTO team_aliases(alias_normalized, team_id, original_alias)
VALUES ('man united', 42, 'Man United')
ON CONFLICT (alias_normalized) DO UPDATE SET team_id = EXCLUDED.team_id, original_alias = EXCLUDED.original_alias;
```
> ⚠️ **别名不自动合并**:发现历史重复队 A/B 后,需人工确认归一目标(如保留 B),再为 A 的归一名添加别名指向 B。
> 合并前请确认 A 的 `matches`/`standings` 引用是否需要迁移(可先 `SELECT COUNT(*) FROM matches WHERE home_team_id = A.id OR away_team_id = A.id` 评估)。
**改名 / 合并流程**(人工):
当发现两个 `teams` 行实际是同一球队(如 `Manchester City` 与 `Man City` 因历史数据大小写差异各占一行):
1. 确定**保留行**(通常选归一后规范名、且被更多 Match 引用的那行)。
2. 将被删行的所有引用指向保留行(`UPDATE matches SET home_team_id = 保留id WHERE home_team_id = 删行id`,客场同理;
`standings` / `match_stats` 按 `team_id` 同理)。
3. 删掉多余行:`DELETE FROM teams WHERE id = 删行id`。
> 此过程引入外键约束风险,务必在事务中执行并先 `BEGIN; ... ` 验证行数后再 `COMMIT`。
> 暂不做自动合并(避免误合相似名),仅通过下方 Admin 接口列出「近似重名」候选,由人工判定。
## Admin:近似重名候选
`GET /api/v1/admin/team-name-duplicates` 只读列出启发式相似候选(大小写差异、子串包含、前缀碰撞),不做自动合并。
典型用途:定期巡检,发现候选后走上方人工 SQL 合并。启发式规则:
- **大小写变体**:`lower(name)` 相同但 `name` 不同(如 `Arsenal FC` / `arsenal fc`)。
- **子串包含**:A 是 B 的子串且长度 ≥ 5(如 `Manchester` / `Manchester City`)。
- **前缀碰撞**:前 8 个字符相同的两队。
命中任一规则即列为候选,按相似度分组返回。
## 数据库 Schema ## 数据库 Schema
6 张表: 12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。
```sql ```sql
-- 联赛 -- 联赛
@@ -133,21 +195,48 @@ CREATE TABLE predictions (
reasoning TEXT, reasoning TEXT,
raw_response JSONB, -- LLM 完整原始响应 raw_response JSONB, -- LLM 完整原始响应
agent_outputs JSONB, -- multi 模式: 5 份专家报告 agent_outputs JSONB, -- multi 模式: 5 份专家报告
agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重
created_at TIMESTAMPTZ, created_at TIMESTAMPTZ,
actual_home_goals INT, actual_away_goals INT, -- 赛后回填 actual_home_goals INT, actual_away_goals INT, -- 赛后回填
settled BOOLEAN DEFAULT FALSE settled BOOLEAN DEFAULT FALSE
); );
``` ```
其余 7 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移):
| 表 | 状态 | 用途 |
|---|---|---|
| `standings` | 已启用 | 联赛积分榜追加快照(P0-02):每次采集 INSERT 新行(available_at=now),唯一键 `(league_id, season, team_id, available_at)`;查询取每队 available_at 最新快照,支持回测还原历史榜单。含排名/战绩/进失球/积分/分区(zone) |
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
| `ingest_failures` | 已启用 | 采集失败死信:bzzoiro 三条管线(events/standings/stats)抓取失败时写入,admin 后台可查看与重试 |
| `data_quality_checks` | 预留未启用 | 数据质量检查结果;规划中定时检查比赛/统计/积分榜完整性 |
| `data_lineage` | 预留未启用 | ETL 血缘追踪;规划中记录源记录到目标表的映射 |
### 关键设计点 ### 关键设计点
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。 1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。 2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。**业务唯一:同联赛同主客同自然天一条。**
3. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。 3. **`source_event_id` 部分唯一**: `ix_matches_source_event_id_unique`(WHERE source_event_id IS NOT NULL)——上游 bzzoiro 的比赛 id,当非空时全局唯一。作用:
- 统计回填(`/events/{id}/stats/`)与 Bronze 血缘(/events/ 采集)通过它定位比赛,不依赖自然键天级舍入;
- 新采集行均带此 id,避免同一 upstream 比赛因时间戳差异绕开自然键产生重复。
- 存量空 source_event_id 历史行不受影响(不强制回填)。
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比 4. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测
5. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
## 采集 upsert 查找顺序
events 管线按以下优先级定位已有比赛,命中即复用(更新):
1. **`source_event_id`**(upstream event id,唯一索引命中)——最精确,跨自然键舍入差异;
2. **自然键**:`(league_id, home_team_id, away_team_id, match_date_date)`(内存去重,覆盖无 event id 的采集)。
两者都未命中 → insert 新比赛。
## 入库语义(幂等) ## 入库语义(幂等)
@@ -159,7 +248,9 @@ CREATE TABLE predictions (
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程 - 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
- stats:只补空(`home_xg` 已有值时不覆盖) - stats:只补空(`home_xg` 已有值时不覆盖)
`ingest_understat` 只回填 xG(也只补空),不创建比赛。 `task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
`task=standings` 追加快照(available_at=now,ON CONFLICT DO NOTHING);公开接口与切片均取每队 available_at 最新快照,支持回测还原历史榜单。
## 采集建议 ## 采集建议
@@ -172,9 +263,11 @@ curl -X POST /api/v1/ingest/bzzoiro \
curl -X POST /api/v1/ingest/bzzoiro \ curl -X POST /api/v1/ingest/bzzoiro \
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}' -d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
# 3. xG 回填(可选,提升 xg agent 质量) # 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量)
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}' curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}'
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}' curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}'
# 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同
``` ```
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。 建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
+176 -12
View File
@@ -13,22 +13,45 @@
cp .env.example .env cp .env.example .env
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY # 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
# 2. 启动(自动建表) # 2. 启动(自动执行数据库迁移)
docker compose up -d --build docker compose up -d --build
# 3. 验证 # 3. 验证
curl http://localhost:8000/health curl http://localhost:8000/health
``` ```
`docker-compose.yml` 仅 2 个服务: `docker-compose.yml` 包含 3 个服务:
| 服务 | 端口 | 说明 | | 服务 | 端口 | 说明 |
|---|---|---| |---|---|---|
| `postgres` | 5432 | PostgreSQL 16 | | `postgres` | 5433 | PostgreSQL 16 |
| `api` | 8000 | FastAPI 应用 | | `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
数据卷 `pgdata` 持久化数据库,重启不丢数据。 数据卷 `pgdata` 持久化数据库,重启不丢数据。
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
## 生产上线检查清单
公网上线前逐项勾选。第 14 项由 `src/core/security_check.py``APP_ENV=production`
启动时**强制校验**,不满足直接拒绝启动(开发环境仅告警);管理鉴权另有请求期 fail-closed(503)。
- [ ] **1. `APP_ENV=production`** — 安全校验、Cookie `Secure`、管理端点 fail-closed 均以它为总开关
- [ ] **2. `SECRET_KEY` 强随机** — 用 `openssl rand -base64 32` 生成;禁止弱值/短值(弱值黑名单会拒绝启动,含把生成指令原样粘进去的情况)
- [ ] **3. 管理鉴权至少其一**`ADMIN_PASSWORD``ADMIN_API_KEY`(后台改过密码后以数据库哈希优先);两者皆空时管理接口 503
- [ ] **4. 数据库强密码** — 禁止 `football:football` 等示例弱密码(启动校验会拒绝);compose 的 `POSTGRES_PASSWORD` 必填,缺失时容器拒绝启动
- [ ] **5. HTTPS** — 由反代(Nginx/Caddy)终结 TLS;`APP_ENV=production` 下会话 Cookie 自动 `Secure`(且 HttpOnly + SameSite=Lax)
- [ ] **6. 反代信任头**`TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
> ⚠️ **多 worker 陷阱**:应用内限流(`_RateLimiter`)与 KeyRing 均为**进程内纯内存状态**,多 worker 部署(如 `uvicorn --workers 4`)时各进程**各自独立计数、互不共享**——实际限流配额会被放大 N 倍、KeyRing 限流状态也不同步。
> 若确需多 worker,必须前置 Nginx/网关做**全局限流**(见[安全与限流](#安全与限流)),并设环境变量 `STRICT_SINGLE_WORKER=True`(见下)在启动期强制拒绝多 worker,避免静默配额漂移。
- [ ] **9. 启动后健康检查**`curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
## 本地开发部署 ## 本地开发部署
```bash ```bash
@@ -63,7 +86,14 @@ cd frontend && npm install && npm run dev
|---|---|---|---| |---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` | | `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 | | `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `DATABASE_URL` | | — | PostgreSQL 连接 URL | | `LOG_FILE` | | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
| `POSTGRES_PASSWORD` | ✅ | — | PostgreSQL 密码 |
| `POSTGRES_DB` | ❌ | `football` | PostgreSQL 数据库名 |
| `POSTGRES_PORT` | ❌ | `5433` | PostgreSQL 端口映射 |
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL(Docker 内会被覆盖) |
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) | | `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
| `LLM_API_KEY` | ✅ | — | API Key | | `LLM_API_KEY` | ✅ | — | API Key |
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) | | `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
@@ -71,10 +101,30 @@ cd frontend && npm install && npm run dev
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) | | `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) | | `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) | | `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key | | `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 | | `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
| `STRICT_SINGLE_WORKER` | ❌ | `False` | `True` 时若以多 worker 启动则拒绝(防限流配额漂移) |
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
## 同站部署 vs 跨站 CSRF
Profeto 管理鉴权使用 **HttpOnly Cookie 会话**(登录后服务端写入),`allow_credentials=True` 的 CORS 配置允许浏览器跨域携带 Cookie——这也引入了 CSRF 面。部署拓扑决定风险等级:
**同站部署(推荐)**: 前端与 API 同域(反代把 `/``/api` 都转发到同一后端,或同源端口)。
- 浏览器视为 **same-origin**,CORS 不触发;`SameSite=Lax` 会话 Cookie 天然阻断跨站请求携带。
- 风险最低。`CORS_ORIGINS` 可设为空或同域来源,仅作兜底。
**跨站部署**: 前端与 API 不同域(如前端 `app.example.com`、API `api.example.com`,或开发时 `localhost:3000``localhost:8000`)。
- 必须把 API 域名列入 `CORS_ORIGINS`,且 `allow_credentials=True` 才能携带 Cookie。
- 此时任何被允许域下的页面都能构造带 Cookie 的请求 → **CSRF 面**:
- 状态变更接口(采集/回测/改密等写操作)要求**管理员 Cookie + 同域**,攻击者无法从第三方站点读取 Cookie,但可构造跨域表单/请求——`SameSite=Lax` 会阻断跨站 POST 表单提交(顶级导航 GET 仍放行),这是当前主要防线。
- `GET /api/v1/admin/*` 只读接口受 `SameSite=Lax` 下顶级导航可能被利用,但攻击者无法读取响应(CORS 不匹配时浏览器拦截)。
- **加固建议**:
1. 反代层加 `Origin`/`Referer` 校验,仅放行 `CORS_ORIGINS` 列表中的来源(即便 FastAPI CORS 已通过,反代校验是多一层纵深)。
2. 写操作要求自定义请求头(如 `X-Requested-With: XMLHttpRequest`),第三方站点无法在无预检下添加自定义头,天然阻断简单跨站 POST。
3. 生产强制 HTTPS(`APP_ENV=production` 下 Cookie 自动 `Secure`),防中间人窃 Cookie。
## LLM 提供商配置示例 ## LLM 提供商配置示例
@@ -127,9 +177,16 @@ alembic revision --autogenerate -m "描述"
alembic revision -m "描述" alembic revision -m "描述"
``` ```
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
无需手动运行。本地开发时需手动执行迁移。
已有迁移: 已有迁移:
- `0001_initial`: 初始 5 张表,0003 增加 injuries,0004 增加约束,0005 增加时间语义 - `0001_initial`: 初始 5 张表
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs` - `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
- `0003_injuries`: 增加 injuries 表
- `0004_snapshot_and_constraints`: 增加约束
- `0005_prediction_status_and_stats_provenance`: 增加时间语义
- `0006-0012`: 后续 schema 调整、约束命名对齐、partial unique index 等
## 备份与恢复 ## 备份与恢复
@@ -143,6 +200,113 @@ cat backup.sql | docker exec -i profeto-postgres psql -U football football
## 监控 ## 监控
- `/health`: 存活检查 ### 健康检查
- 日志:容器 stdout(`docker compose logs -f api`)
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度) | 端点 | 含义 | HTTP 状态码 |
|---|---|---|
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
### 探针配置
#### Docker Compose
`docker-compose.yml` 已为 `api` 服务配置 readiness:
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
```
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
#### Kubernetes
```yaml
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /health/ready
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
```
**两探针必须区分**:
- `livenessProbe``/health`:仅在进程死锁/崩溃时重启,避免误杀。
- `readinessProbe``/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
#### 验证
```bash
# 宿主机直接运行(经本地 8000 端口)
python3 tests/test_health_ready.py
```
### 评估
## 安全与限流
### 内存限流(按进程)
`/api/v1/predict` 与登录防爆破均使用**进程内内存**计数:
| 机制 | 位置 | 局限 |
|------|------|------|
| `/predict` 限流 | `_RateLimiter`(内存) | 每 worker 独立计数,不共享 |
| 登录防爆破 | `_fail_times`(内存) | 同上 |
**多 worker 部署时**(如 `uvicorn --workers 4`),每进程各自计数,实际限额为 `N × 单进程限制`
### 公网部署建议
```
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Client │────▶│ Nginx │────▶│ API │
│ │ │ 限流层 │ │ 内存限流 │
└─────────┘ └──────────┘ └──────────┘
```
**推荐配置**:
1. **Nginx 层限流**(第一道防线):
```nginx
limit_req_zone $binary_remote_addr zone=predict:10m rate=10r/m;
location /api/v1/predict {
limit_req zone=predict burst=20 nodelay;
proxy_pass http://api:8000;
}
```
2. **TRUST_PROXY_HEADERS=True** 时必须由可信反代设置 `X-Forwarded-For`:
```nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
- 若为 `False`: 仅用 `request.client.host`,忽略 `X-Forwarded-For`,防伪造
- 若为 `True`: 解析 `X-Forwarded-For` 第一个 IP,反代后方可信
3. **生产环境必须配置管理鉴权**:
```bash
APP_ENV=production
REQUIRE_ADMIN_AUTH=True
ADMIN_PASSWORD=your_secure_password
```
未配置时 `/admin` 等管理接口返回 503。
### 参数调优
| 参数 | 默认 | 说明 |
|------|------|------|
| `_predict_limiter.max_requests` | 10 | 每分钟每 IP 最大请求数 |
| `_predict_limiter.window_seconds` | 60 | 滑动窗口时长 |
| `ADMIN_SESSION_TTL_HOURS` | 168 | 管理会话有效期(天) |
+56 -14
View File
@@ -2,32 +2,70 @@
## 本地开发环境搭建 ## 本地开发环境搭建
### 锁定依赖策略
项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 锁定依赖版本,确保本地、CI、Docker 三端一致:
| 文件 | 用途 | 生成命令 |
|---|---|---|
| `requirements.txt` | 生产依赖锁定(含 SHA256 哈希) | `pip-compile pyproject.toml --generate-hashes` |
| `requirements-dev.txt` | 开发+CI 依赖锁定(含哈希) | `pip-compile pyproject.toml --extra dev --generate-hashes` |
### 安装步骤
```bash ```bash
# 1. 克隆并进入项目 # 1. 克隆并进入项目
cd Profeto cd Profeto
# 2. 安装依赖(含 dev) # 2. 创建虚拟环境
pip install -e ".[dev]" python3.11 -m venv .venv
source .venv/bin/activate
# 3. 启动 PostgreSQL # 3. 安装 pip-tools(用于同步锁定依赖)
pip install pip-tools
# 4. 同步生产+开发依赖到当前环境(严格按 lock 文件版本,含哈希校验)
pip-sync requirements-dev.txt
# 5. 启动 PostgreSQL(或在 .env 配置外部库)
docker run -d --name profeto-pg \ docker run -d --name profeto-pg \
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \ -e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
-p 5432:5432 postgres:16-alpine -p 5432:5432 postgres:16-alpine
# 4. 配置环境变量 # 6. 配置环境变量
cp .env.example .env cp .env.example .env
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY # 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
# 5. 建表 # 7. 建表
alembic upgrade head alembic upgrade head
# 6. 启动 API(热重载) # 8. 启动 API(热重载)
uvicorn src.api.app:app --reload uvicorn src.api.app:app --reload
# 7. 启动前端(另一个终端) # 9. 启动前端(另一个终端)
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```
> **注意**:不要用 `pip install -e ".[dev]"` 直接安装——它按 pyproject 下界约束解析,版本可能与 lock 文件不一致。统一用 `pip-sync requirements-dev.txt` 保证三端一致。
### 变更依赖时
```bash
# 1. 编辑 pyproject.toml(调整依赖或版本约束)
# 2. 重新生成 lock 文件(含哈希)
pip-compile pyproject.toml --generate-hashes --output-file=requirements.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
pip-compile pyproject.toml --extra dev --generate-hashes --output-file=requirements-dev.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
# 3. 同步到本地环境
pip-sync requirements-dev.txt
# 4. 提交 lock 文件
git add requirements.txt requirements-dev.txt pyproject.toml
```
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
## 项目结构 ## 项目结构
``` ```
@@ -45,16 +83,16 @@ Profeto/
│ │ └── app.py # FastAPI 工厂 │ │ └── app.py # FastAPI 工厂
│ ├── db/ │ ├── db/
│ │ ├── base.py # SQLAlchemy async engine + session │ │ ├── base.py # SQLAlchemy async engine + session
│ │ ├── models.py # 6 张表 ORM │ │ ├── models.py # 12 张表 ORM
│ │ ├── repositories.py # 仓储层(查询封装) │ │ ├── repositories.py # 仓储层(查询封装)
│ │ └── unit_of_work.py # 事务边界 │ │ └── unit_of_work.py # 事务边界
│ ├── data/ │ ├── data/
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库 │ │ ├── bzzoiro.py # bzzoiro 采集 + 入库(唯一数据源)
│ │ ├── understat.py # understat xG 回填
│ │ ├── injuries.py # 伤停采集
│ │ ├── normalize.py # 数据清洗契约 │ │ ├── normalize.py # 数据清洗契约
│ │ ├── team_names.py # 队名归一化映射 │ │ ├── team_names.py # 队名归一化映射
│ │ ├── team_names_zh.py # 队名中文名映射
│ │ ├── sources.py # 数据源注册表 │ │ ├── sources.py # 数据源注册表
│ │ ├── key_ring.py # 数据源 Key 读取(DB 设置优先于 env)
│ │ └── config.py # 联赛映射常量 │ │ └── config.py # 联赛映射常量
│ ├── llm/ │ ├── llm/
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible) │ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
@@ -72,13 +110,17 @@ Profeto/
│ │ ├── form_v1.md │ │ ├── form_v1.md
│ │ ├── stats_v1.md │ │ ├── stats_v1.md
│ │ ├── home_away_v1.md │ │ ├── home_away_v1.md
│ │ ├── injuries_v1.md │ │ ├── standings_v1.md
│ │ ├── h2h_v1.md │ │ ├── h2h_v1.md
│ │ └── aggregator_v1.md │ │ └── aggregator_v1.md
│ └── core/ │ └── core/
│ ├── config.py # pydantic-settings 配置 │ ├── config.py # pydantic-settings 配置
│ ├── http_client.py # 共享 httpx 客户端 │ ├── http_client.py # 共享 httpx 客户端
── retry.py # 重试工具 ── crypto.py # 对称加密(Fernet)与密码哈希
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
├── frontend/ # React 单页前端 ├── frontend/ # React 单页前端
├── alembic/ # 数据库迁移 ├── alembic/ # 数据库迁移
│ └── versions/ │ └── versions/
@@ -156,7 +198,7 @@ cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
### 3. 新增数据源 ### 3. 新增数据源
1.`src/data/` 写采集模块(参考 `understat.py`) 1.`src/data/` 写采集模块(参考 `bzzoiro.py`)
2.`normalize.py` 加清洗函数 2.`normalize.py` 加清洗函数
3.`context_builder.py` 加切片函数 3.`context_builder.py` 加切片函数
4.`api/routes/ingest.py` 加端点 4.`api/routes/ingest.py` 加端点
+6
View File
@@ -1,3 +1,9 @@
> ⚠️ **历史文档(已过时)**:伤停(injuries)数据源与 api-football 集成已移除,
> 数据源收敛为 bzzoiro 唯一来源。本文仅作历史决策记录保留,
> 现状请见 [05-data.md](05-data.md) 与 [01-architecture.md](01-architecture.md)。
---
"""检查 injuries 数据源 api-football 的响应结构。""" """检查 injuries 数据源 api-football 的响应结构。"""
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """ API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
{ {
+5
View File
@@ -0,0 +1,5 @@
# 前端构建时不需要的文件
node_modules
dist
.git
*.log
+25
View File
@@ -0,0 +1,25 @@
# Fix 4: 前端多阶段构建 —— 构建静态文件 + nginx 服务
FROM node:20-alpine AS builder
WORKDIR /app
# 安装依赖
COPY package.json package-lock.json ./
RUN npm ci
# 构建
COPY . .
RUN npm run build
# 生产阶段: nginx 服务静态文件
FROM nginx:alpine
# 从 builder 阶段复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx 配置(在 compose 中通过 volume 挂载,此处仅作备用)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+236
View File
@@ -0,0 +1,236 @@
/**
* ESLint 配置 —— 工程的自动化质量防线。
*
* 本文件的核心目的不只是「常规 lint」,而是把审查报告里
* 「只能靠人肉 review 发现」的几类问题固化成机器规则:
*
* 1. 幻影变体:此前 `btn-ghost` 被使用但从未在 CSS 中定义,
* 样式静默失效,类型检查和构建都不报错。
* 2. 裸控件:components/ui 已提供 Button/Input/Select 基元,
* 但页面里仍有人直接写原生标签 + 手拼 className,
* 导致样式与无障碍行为再次分叉。
* 3. 硬编码色值:设计令牌是唯一颜色来源,十六进制字面量
* 会让改令牌时产生「漏网之鱼」。
*
* 规则原则:宁可少而准,不要多而吵。无法修复的告警只会被忽略。
*/
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
/**
* CSS 变体类名总表 —— index.css 的 @layer components 里定义的全部类。
*
* 分两组,因为二者的处置方式完全不同:
*
* - `PRIMITIVE_CLASSES`:已有组件基元等价物(Button / Input / Select / Tabs)。
* 页面里**手拼**这些类名 = 绕过基元,样式与无障碍行为会再次分叉。
* 这类用法由 `no-restricted-syntax` 报错拦截。
*
* - `COMPOSITE_CLASSES`:组合型排版类(类似 Tailwind 工具类),
* 本就设计成裸用(如 `className="section-head mb-2"`)。
* 它们没有、也不该有组件基元,因此不拦截。
*
* 之所以要专门拦「单独出现」而非子串匹配:因为 `btn` 是 `btn-solid`
* 的前缀,`tab` 是 `tab-on` 的前缀。只有被当作独立 token 使用时才是问题。
*/
const PRIMITIVE_CLASSES = [
// Button 基元覆盖
'btn',
'btn-sm',
'btn-solid',
'btn-outline',
'btn-danger',
'btn-ghost',
// Input / Select 基元覆盖
'field',
// Tabs 基元覆盖
'tab',
'tab-on',
]
const COMPOSITE_CLASSES = [
'section-head',
'skeleton',
'empty-state',
'empty-state-title',
'empty-state-sub',
'empty-state-action',
'error-banner',
'error-banner-title',
'error-banner-detail',
'nav-icon',
'masthead-rule',
'font-brush',
]
/** 保留导出,兼容既有引用;内容为全部组件类名。 */
const CSS_VARIANT_CLASSES = [...PRIMITIVE_CLASSES, ...COMPOSITE_CLASSES]
/**
* 匹配「className 值里单独出现的某个基元类名」的正则。
*
* 只匹配独立 token(前后必须是空白或字符串边界),避免两类误判:
* - `btn-solid` 里的 `btn`(前缀子串,合法,由 Button 基元自己输出)
* - `searchParams.get('tab')` 这类**非样式**字符串
* —— 这正是不能用裸正则扫全量 Literal 的原因(第一版曾误报)。
*
* 该正则只配合下面的 AST 选择器使用:
* `JSXAttribute[name.name="className"] Literal[...]` 把匹配范围
* 严格限定在 className 属性值内,字符串里出现同名 token 不再误伤。
*/
function barePrimitivePattern() {
const names = PRIMITIVE_CLASSES.join('|')
return new RegExp(`(^|\\s)(${names})(?=\\s|$)`)
}
export default tseslint.config(
{
// 只检查 TS/TSX。CSS 由 Tailwind/PostCSS 管线负责,
// 交给 ESLint 解析只会得到 "Declaration expected" 噪声。
// 配置类文件自身不参与 lint(它们是规则的声明方,不是被约束方)。
ignores: ['dist', 'node_modules', '*.config.js', '*.config.mjs', '*.config.ts'],
},
js.configs.recommended,
...tseslint.configs.recommended,
// ── 全局:浏览器环境 ────────────────────────────────────────────
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: {
...globals.browser,
...globals.es2022,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
// ── hooks 规则调整 ───────────────────────────────────────────
// `set-state-in-effect`:加载类 effect(挂载时发起请求 → 回调里
// setState)是 React 官方文档明确认可的模式,该规则在此场景下
// 误报率过高。当前有 19 处这类写法,逐条改写属于行为等价的
// 大范围重构,不适合混在本次质量修复里。降级为 warn 保留可见性。
// 注:真正的「渲染期间副作用」已由 react-hooks/purity 单独拦下
// (Collection.tsx 里的 Date.now() 即被它捕获并已修复)。
'react-hooks/set-state-in-effect': 'warn',
// ── 死代码 ───────────────────────────────────────────────────
// tsconfig 里 noUnusedLocals/noUnusedParameters 是关的
// (存量太多,一次性打开会淹没信号)。这里先用 ESLint 的
// 同型规则,允许下划线前缀显式豁免,便于渐进清理。
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
// ── 类型安全 ─────────────────────────────────────────────────
// `any` 在数据访问层是真实的类型漏洞来源(dal.ts 曾有 14 处)。
// 用 warn 而非 error:存量需要分批处理,但不该被遗忘。
'@typescript-eslint/no-explicit-any': 'warn',
// 空接口等价于无约束类型参数,通常是想写 type 而非 interface
'@typescript-eslint/no-empty-object-type': 'warn',
// ── React ────────────────────────────────────────────────────
// 本仓库是纯 SPA(无 RSC、无服务端渲染),Fast Refresh 的粒度为
// 整个模块。兼容壳文件(admin/components.tsx、matches/ui.tsx)与
// 混装常量+组件的文件会命中此规则,但它们本就是刻意保留的
// 再导出层,热更新退化不影响开发体验。关闭以免噪声掩盖真问题。
'react-refresh/only-export-components': 'off',
},
},
// ── 组件基元层:自身必然使用原生标签,豁免相关规则 ──────────────
{
files: ['src/components/ui/**'],
rules: {
'react-refresh/only-export-components': 'off',
},
},
// ── 禁止硬编码设计令牌色值 + 禁止手拼组件基元类名 ────────────────
{
files: ['src/**/*.{ts,tsx}'],
// 组件基元层自身必然要写这些类名 —— 它是唯一的合法使用点
ignores: ['src/components/ui/**'],
rules: {
'no-restricted-syntax': [
// error 级:两类问题(硬编码色值、手拼基元类名)存量均已清零,
// 此后任何新增都应在提交前就地修正,CI 可直接阻断。
'error',
{
selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
message:
'禁止硬编码颜色字面量。请使用设计令牌(press/ink/paper 等)或 CSS 变量。',
},
{
selector: 'TemplateElement[value.raw=/rgba?\\(/]',
message:
'禁止在模板字符串中硬编码 rgb/rgba 颜色。请使用设计令牌或 CSS 变量。',
},
// ── 手拼基元类名 ────────────────────────────────────────────
// error 级:这类写法会让组件层形同虚设,且样式/无障碍分叉
// 只在运行时显形,类型检查完全沉默。合法写点(components/ui)
// 已被本块的 ignores 排除。
//
// 用 JSXAttribute 选择器把范围钉死在 className 属性值上:
// 裸 `Literal[...]` 会误伤 `searchParams.get('tab')` 这类
// 恰好含同名 token 的普通字符串。两种写法分别覆盖:
// className="field w-full" → String Literal
// className={`btn ${x ? 'btn-solid' : ''}`} → TemplateLiteral
{
selector: `JSXAttribute[name.name="className"] Literal[value=/${barePrimitivePattern().source}/]`,
message:
'禁止手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的 Button / Input / Select / Tabs 基元 —— 它们统一了变体与无障碍行为。',
},
{
selector: `JSXAttribute[name.name="className"] TemplateElement[value.raw=/${barePrimitivePattern().source}/]`,
message:
'禁止在模板字符串中手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的基元组件。',
},
],
},
},
// 色值豁免:调色板与主题定义是颜色「来源」,不是「使用点」
{
files: [
'src/index.css',
'tailwind.config.js',
'src/components/ui/**',
],
rules: {
'no-restricted-syntax': 'off',
},
},
// ── 测试文件 ────────────────────────────────────────────────────
{
files: ['src/**/*.test.{ts,tsx}'],
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
)
// 导出变体列表供后续规则扩展使用,避免魔法字符串散落
export { CSS_VARIANT_CLASSES }
+40 -1
View File
@@ -3,7 +3,46 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Profeto - 足球 LLM 预测</title>
<!--
SEO / 分享 / 移动端元信息。
此前这里只有 charset + viewport + title —— 搜索引擎无摘要、
分享到社交平台无预览卡片、iOS 添加到主屏是截图占位、
移动端浏览器地址栏不染色。品牌在浏览器层面完全裸奔。
-->
<title>Profeto · 足球赛程与 LLM 预测</title>
<meta
name="description"
content="Profeto 提供主流联赛赛程、实时积分榜,以及由多专家大语言模型生成的比分与胜平负预测。仅供研究参考,不构成投注建议。"
/>
<meta name="theme-color" content="#FDFCF8" />
<!-- 索引控制:公开站点允许收录,但不索引管理后台 -->
<meta name="robots" content="index, follow" />
<!-- 图标:SVG 优先(任意缩放清晰),PNG 作为老浏览器兜底 -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Open Graph:微信 / 飞书 / 社交平台分享卡片 -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Profeto" />
<meta property="og:title" content="Profeto · 足球赛程与 LLM 预测" />
<meta
property="og:description"
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
/>
<meta property="og:locale" content="zh_CN" />
<!-- Twitter / X 卡片 -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Profeto · 足球赛程与 LLM 预测" />
<meta
name="twitter:description"
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+42
View File
@@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
# Serve static files
root /usr/share/nginx/html;
index index.html;
# API proxy
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
# LLM 多专家预测/回测耗时长(可达数分钟),默认 60s 会掐断请求返回 504
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Health check proxy
location /health {
proxy_pass http://api:8000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
}
+1500 -709
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -5,21 +5,31 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build && sh scripts/verify-tokens.sh",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
"verify:tokens": "sh scripts/verify-tokens.sh"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.12.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.70.1",
"vite": "^5.3.4" "vite": "^5.3.4"
} }
} }
+2601
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#FDFCF8"/>
<rect x="1.5" y="1.5" width="29" height="29" fill="none" stroke="#17140F" stroke-width="2.5"/>
<text x="16" y="23" font-family="serif" font-size="20" font-weight="700" fill="#9E1B1B" text-anchor="middle"></text>
</svg>

After

Width:  |  Height:  |  Size: 334 B

+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# 设计令牌完整性守卫 —— 确认关键颜色类真的生成了 CSS。
#
# 背景:把商标色改为 CSS 变量时踩过一个坑 —— 变量若写成完整十六进制
# (#9E1B1B)而非 RGB 三元组(158 27 27),Tailwind 的透明度修饰符
# (bg-press-wash/60)会**静默不生成任何 CSS**。构建通过、类型检查通过、
# 控制台无警告,只是样式没了。这类问题只能靠检查产物发现。
#
# 用法: pnpm build && sh scripts/verify-tokens.sh
set -e
CSS=$(ls dist/assets/*.css 2>/dev/null | head -1)
if [ -z "$CSS" ]; then
echo "✗ 未找到构建产物 CSS,请先执行 pnpm build"
exit 1
fi
fail=0
check() {
if grep -q -F -- "$1" "$CSS"; then
echo "$1"
else
echo "$1 缺失"
fail=1
fi
}
echo "校验设计令牌在构建产物中的存在性:"
check '.bg-press{'
check '.text-ink-900{'
check '.bg-paper-50{'
check '.border-press{'
check 'press-wash\/60'
check '.masthead-rule{'
check '--press:'
if [ "$fail" -ne 0 ]; then
echo ""
echo "设计令牌校验失败。"
echo "最常见原因:tailwind.config.js 中颜色值未写成"
echo " 'rgb(var(--x) / <alpha-value>)'"
echo "而写成了完整十六进制或 'var(--x)' —— 前者会丢掉透明度修饰符。"
exit 1
fi
echo "设计令牌校验通过"
+100 -46
View File
@@ -1,55 +1,109 @@
import { ErrorBoundary } from './components/ErrorBoundary' /**
import Matches from './pages/Matches' * 主应用入口
*
* 页面结构:
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
*
* 导航策略:
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
*
* 代码分割:
* - 公开页(Matches/Standings)各自 lazy,后台整体懒加载。
* 此前所有页面打进同一个 chunk,一个只想看赛程的匿名访客也要
* 下载 Dashboard/Collection/Logs/Eval/Backtest/... 十个后台页面。
* - ErrorBoundary 下沉到每个路由级:某个页面渲染崩溃时降级为
* 「该页面报错」,而不是整个应用白屏。
*/
/** 报眉日期行:2026年9月15日 星期二 */ import { lazy, Suspense } from 'react'
function dateLine(): string { import type { ReactNode } from 'react'
return new Date().toLocaleDateString('zh-CN', { import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
year: 'numeric', import { ErrorBoundary } from './components/ErrorBoundary'
month: 'long', import { SiteLayout } from './components/SiteLayout'
day: 'numeric', import { adminRoutes } from './admin/routes'
weekday: 'long',
}) // ── 路由级懒加载 ────────────────────────────────────────────────
// 每个入口拆成独立 chunk,首屏只加载当前路由所需代码。
const Matches = lazy(() => import('./pages/Matches'))
const Standings = lazy(() => import('./pages/Standings'))
/**
* 路由级加载占位。
* 保持与应用一致的纸色背景与居中位置,避免切换时出现白闪。
*/
function RouteFallback() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<span className="text-xs text-ink-400" role="status" aria-live="polite">
</span>
</div>
)
}
/**
* 单个路由的错误与加载边界。
*
* 注意 ErrorBoundary 必须包在 Suspense 外层:lazy 组件加载失败
* (网络中断、部署后 chunk 哈希变化)会以错误形式抛出,而不是
* 永远停在加载态。
*/
function RouteShell({ children }: { children: ReactNode }) {
return (
<ErrorBoundary fullScreen={false}>
<Suspense fallback={<RouteFallback />}>{children}</Suspense>
</ErrorBoundary>
)
}
function HomePage() {
return (
<SiteLayout
active="home"
footerNote="预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议"
>
<RouteShell>
<Matches />
</RouteShell>
</SiteLayout>
)
}
function StandingsPage() {
return (
<SiteLayout active="standings" footerNote="数据由 bzzoiro 提供 · 仅供研究参考">
<RouteShell>
<Standings />
</RouteShell>
</SiteLayout>
)
} }
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<div className="min-h-screen bg-paper-50"> <BrowserRouter>
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */} <Routes>
<header className="masthead-rule"> <Route path="/" element={<HomePage />} />
<div className="mx-auto max-w-5xl px-5 sm:px-8"> <Route path="/standings" element={<StandingsPage />} />
<div className="border-b border-ink-900 py-5 text-center sm:py-6"> {adminRoutes.map(route => (
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900"> <Route key={route.path} path={route.path} element={route.element}>
{route.children.map(child => (
<span className="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500"> <Route
Profeto key={child.path ?? 'index'}
</span> index={child.index}
</h1> path={child.path}
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500"> element={child.element}
· · />
</p> ))}
</div> </Route>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500"> ))}
<span>{dateLine()}</span> <Route path="*" element={<Navigate to="/" replace />} />
<span className="flex items-center gap-1.5"> </Routes>
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" /> </BrowserRouter>
</span>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
<Matches />
</main>
{/* ── 版底 ── */}
<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">
· ·
</div>
</footer>
</div>
</ErrorBoundary> </ErrorBoundary>
) )
} }
+15
View File
@@ -0,0 +1,15 @@
/**
* Admin 后台 - 应用入口
*
* 独立的 Admin 应用入口,用于 createBrowserRouter。
* 也可通过 createHashRouter 直接挂载为独立应用。
*/
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { adminRoutes } from './routes'
const router = createBrowserRouter(adminRoutes)
export default function AdminApp() {
return <RouterProvider router={router} />
}
+141
View File
@@ -0,0 +1,141 @@
/**
* 后台导航图标集。
*
* 此前这 8 个图标是一段约 90 行的内联 `switch`,塞在 AdminLayout 里,
* 使布局文件同时承担「页面骨架」与「图标图形库」两种职责,而且
* `name: string` 的参数类型让拼错图标名不会报错 —— 只会静默渲染空白。
*
* 抽出为独立组件后:
* - AdminLayout 只管布局
* - 图标名收敛为联合类型,拼错即编译失败
* - 新增图标时只改本文件
*
* 风格与 nav-icon 的线条规范一致:24×24 视框、currentColor 描边、圆头圆角。
*/
import type { ReactNode } from 'react'
/** 支持的图标名。与 `admin/nav.ts` 中各导航项的 icon 字段对应。 */
export type AdminIconName =
| 'collection'
| 'chart'
| 'target'
| 'repeat'
| 'eval'
| 'monitor'
| 'settings'
| 'logs'
export interface AdminIconProps {
name: AdminIconName
/** 附加类名(尺寸/颜色覆盖用) */
className?: string
}
/** 统一的 SVG 外框属性,避免 8 份重复书写 */
const svgProps = {
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const
const PATHS: Record<AdminIconName, ReactNode> = {
// 数据采集:立方体/包裹
collection: (
<>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</>
),
// 柱状图
chart: (
<>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</>
),
// 靶心
target: (
<>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</>
),
// 循环/重试
repeat: (
<>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</>
),
// 评估:带横线的文档
eval: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</>
),
// 监控:显示器
monitor: (
<>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</>
),
// 设置:齿轮
settings: (
<>
<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" />
</>
),
// 日志:带横线的文档(与 eval 区分:行数位置不同)
logs: (
<>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="16" y2="17" />
</>
),
}
/**
* 后台导航图标。
*
* 未知名称不再静默返回 `null`(那样只会看到一个空位,难以定位),
* 而是渲染一个可见的问号方块 —— 缺失的图标应当被一眼看见。
*/
export function AdminIcon({ name, className }: AdminIconProps) {
const paths = PATHS[name]
const cls = className ? `nav-icon ${className}` : 'nav-icon'
if (!paths) {
return (
<svg {...svgProps} className={cls} aria-hidden="true" data-icon-missing={name}>
<rect x="3" y="3" width="18" height="18" />
<line x1="9" y1="9" x2="15" y2="15" />
<line x1="15" y1="9" x2="9" y2="15" />
</svg>
)
}
return (
<svg {...svgProps} className={cls} aria-hidden="true">
{paths}
</svg>
)
}
export default AdminIcon
+253
View File
@@ -0,0 +1,253 @@
/**
* Admin 后台 - 布局组件(报刊风)
*
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
*/
import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api'
import { fetchHealth } from '../api/dal'
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
import Login from './Login'
import { useCommandPalette, CommandPalette } from './useCommandPalette'
import { AdminIcon } from './AdminIcon'
// 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch,
// 让布局文件同时承担页面骨架与图标图形库两种职责。
/**
* 侧栏导航项的 className 生成器。
*
* 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项),
* 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里
* 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。
*/
const SIDEBAR_LINK_BASE = 'nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300'
const SIDEBAR_LINK_ACTIVE = 'border-press bg-press-wash/60 font-medium text-press active'
const SIDEBAR_LINK_IDLE = 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
function sidebarLinkClass({ isActive }: { isActive: boolean }): string {
return `${SIDEBAR_LINK_BASE} ${isActive ? SIDEBAR_LINK_ACTIVE : SIDEBAR_LINK_IDLE}`
}
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
// 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除,
// 新增页面/改标签只改 nav.ts 一处。
/** 报眉日期行,与前台同款式 */
function dateLine(): string {
return new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
}
export default function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation()
const palette = useCommandPalette(COMMAND_PALETTE_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
let alive = true
fetchAuthState()
.then(s => alive && setAuthed(s.authenticated))
.catch(() => alive && setAuthed(false))
const onUnauthorized = () => setAuthed(false)
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
return () => {
alive = false
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
}
}, [])
const handleLogout = useCallback(async () => {
try {
await logout()
} catch {
/* 会话可能已失效,直接切回登录页 */
}
setAuthed(false)
}, [])
const checkHealth = useCallback(async () => {
try {
const h = await fetchHealth()
setHealthOk(h?.status === 'healthy' || h?.status === 'ok')
} catch {
setHealthOk(false)
}
}, [])
useEffect(() => {
checkHealth()
const t = setInterval(checkHealth, 60_000)
return () => clearInterval(t)
}, [checkHealth])
// 路由变化时关闭移动端菜单
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
useEffect(() => {
closeSidebar()
}, [location.pathname, closeSidebar])
// ESC 键关闭菜单
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSidebarOpen(false)
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [])
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
if (authed === null) {
return (
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
</div>
)
}
if (!authed) {
return <Login onSuccess={() => setAuthed(true)} />
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-paper-50 text-ink-800">
{/* ── 通栏顶部报眉 ── */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 bg-paper-50 px-4 lg:px-6">
<div className="flex items-center gap-4">
{/* 移动端汉堡按钮 */}
<button
onClick={() => setSidebarOpen(true)}
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
aria-label="打开菜单"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 面包屑 */}
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
<a href="/admin" className="hover:text-ink-700"></a>
{location.pathname !== '/admin' && (
<>
<span aria-hidden="true">/</span>
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
</>
)}
</nav>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
<div className="flex items-center gap-4 text-2xs">
<span className="inline-flex items-center gap-1.5 text-ink-500">
<span
className={`inline-block h-1.5 w-1.5 ${
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
}`}
aria-hidden="true"
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
title="退出登录"
>
</button>
</div>
</header>
{/* ── 主体:侧边栏 + 内容 ── */}
<div className="flex flex-1 overflow-hidden">
{/* ── 移动端遮罩层 ── */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-ink-900/40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* ── 侧边栏 ── */}
<aside
className={`
fixed inset-y-0 left-0 z-50 flex w-60 flex-shrink-0 flex-col border-r border-ink-900 bg-paper-50
transform transition-transform duration-200 ease-in-out
lg:relative lg:z-auto lg:translate-x-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}
aria-label="主导航"
>
{/* 侧栏顶部 */}
<div className="flex items-center justify-center border-b border-ink-900 px-5 py-5">
<h1 className="text-center text-3xl tracking-wide text-ink-900 font-brush"></h1>
</div>
{/* 导航:分组 + 小节标题 */}
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
{/* 仪表盘独立(始终第一项) */}
<NavLink to="/admin" end className={sidebarLinkClass}>
<AdminIcon name="chart" />
</NavLink>
{NAV_SECTIONS.map(section => (
<div key={section.title} className="mt-4">
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
{section.title}
</p>
<ul className="space-y-0.5">
{section.items.map(item => (
<li key={item.to}>
<NavLink to={item.to} className={sidebarLinkClass}>
<AdminIcon name={item.icon} />
{item.label}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
{/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
<a
href="/"
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
>
<span aria-hidden="true"></span>
</a>
<p className="text-2xs text-ink-300">v1.0</p>
</div>
</aside>
{/* ── 主内容区 ── */}
<main className="flex-1 overflow-y-auto p-4 lg:p-8" key={location.pathname}>
<div className="mx-auto max-w-6xl page-content-enter">
<Outlet />
</div>
</main>
</div>
{/* 命令面板(⌘K) */}
<CommandPalette
open={palette.open}
query={palette.query}
setQuery={palette.setQuery}
items={palette.items}
onClose={() => palette.setOpen(false)}
/>
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
*
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchLLMAgents, updateSetting, clearSetting } from '../api/dal'
import type { LLMAgentConfig } from '../api/types'
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
import { Button, Input } from '../components/ui'
type FieldKey = 'model' | 'base_url' | 'api_key'
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
]
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
}
export default function AgentLLMCard() {
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setAgents(await fetchLLMAgents())
} catch {
setAgents([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function toggleExpand(agent: LLMAgentConfig) {
if (expandedId === agent.id) {
setExpandedId(null)
return
}
setExpandedId(agent.id)
setNotice(null)
// 预填非敏感覆盖值;API Key 不回填
setForm({
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
api_key: '',
})
}
async function handleSave(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
if (nonEmpty.length === 0) {
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
return
}
for (const f of nonEmpty) {
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
}
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusy(false)
}
}
async function handleReset(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
for (const f of FIELD_META) {
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
}
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
} finally {
setBusy(false)
}
}
const hasOverride = (agent: LLMAgentConfig) =>
Object.values(agent.fields).some(f => f.origin === 'db')
return (
<Card>
<CardHeader
title="专家与终裁 LLM 配置"
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
action={
<Button size="sm" onClick={load} disabled={loading}>
{loading ? (<><Spinner /> </>) : '刷新'}
</Button>
}
/>
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : agents.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
<>
{notice && (
<div className="px-4 pb-2 sm:px-5">
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
</div>
)}
{agents.map(agent => {
const expanded = expandedId === agent.id
return (
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
{/* 行:角色名 + 生效模型 + 配置按钮 */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
{hasOverride(agent) && <Badge status="success"></Badge>}
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
<Button size="sm" className="flex-shrink-0" onClick={() => toggleExpand(agent)} disabled={busy}>
{expanded ? '收起' : '配置'}
</Button>
</div>
</div>
{/* 展开的编辑表单 */}
{expanded && (
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
{FIELD_META.map(f => {
const state = agent.fields[f.key]
return (
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
<label htmlFor={`llm-${f.key}`} className="text-xs text-ink-500">{f.label}</label>
<div>
<Input
id={`llm-${f.key}`}
type={f.sensitive ? 'password' : 'text'}
value={form[f.key]}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={
f.key === 'api_key' && state.origin === 'db'
? `已覆盖(${state.masked}),留空保持不变`
: f.hint
}
autoComplete="off"
className="w-full"
/>
</div>
</div>
)
})}
<div className="flex items-center justify-between gap-2 pt-1">
<p className="text-2xs text-ink-400">
:{agent.effective_model}
</p>
<div className="flex gap-2">
{hasOverride(agent) && (
<Button size="sm" onClick={() => handleReset(agent)} disabled={busy}>
</Button>
)}
<Button variant="solid" size="sm" onClick={() => handleSave(agent)} disabled={busy}>
{busy ? (<><Spinner /> </>) : '保存'}
</Button>
</div>
</div>
</div>
)}
</div>
)
})}
</>
)}
</CardBody>
</Card>
)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Admin 后台 - 登录页(报刊风)
*
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
*/
import { useState } from 'react'
import { ApiError, login } from '../api/api'
import { Button, Input } from '../components/ui'
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!password || submitting) return
setSubmitting(true)
setError('')
try {
await login(password)
onSuccess()
} catch (err) {
setError(
err instanceof ApiError
? err.message.split('\n')[0]
: '登录失败,请检查网络连接',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
<header className="masthead-rule">
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
<div className="border-b border-ink-900 py-5 text-center">
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</h1>
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500"> · </p>
</div>
</div>
</header>
<main className="flex flex-1 items-start justify-center px-5 py-10">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm border border-ink-300 bg-paper-50 p-6 shadow-print"
>
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
</label>
<Input
id="admin-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
autoFocus
autoComplete="current-password"
/* 错误时把输入框标记为无效并关联错误文本,
屏幕阅读器才能知道「密码错了」以及错在哪 */
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'admin-password-error' : undefined}
className="mt-2 w-full"
/>
{error && (
<p
id="admin-password-error"
/* role=alert 让错误出现时立即被朗读(aria-live 的 assertive 语义) */
role="alert"
className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark"
>
{error}
</p>
)}
<Button variant="solid" className="mt-5 w-full justify-center" type="submit" disabled={!password || submitting}>
{submitting ? '验证中…' : '登 录'}
</Button>
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
.env,; 5 10
</p>
</form>
</main>
<footer className="pb-8 text-center text-2xs text-ink-400">
<a href="/" className="transition-colors hover:text-press">
</a>
</footer>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
# Profeto Admin 后台管理系统
## 概述
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
**与前台共用同一套「报刊风」设计语言**:纸色底(paper)、墨色字(ink)、印报红唯一强调(press),
宋体标题、方正边框、细线分隔,无圆角、无彩色药丸标签。
支持响应式布局(移动端 / 平板 / 桌面)。
## 文件结构
```
src/admin/
├── AdminApp.tsx # Admin 应用入口(独立路由)
├── AdminLayout.tsx # 布局(报头式侧栏 + 报眉顶栏,健康状态每 60s 复检)
├── api.ts # 统一 API 客户端(超时、错误处理、X-API-Key 自动附带)
├── dal.ts # 数据访问层(封装所有 API 端点调用)
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 模型对齐)
├── components.tsx # 通用 UI 组件(报刊风 Card/Badge/DataTable/Alert...)
├── routes.tsx # 路由定义(/admin/*)
└── pages/
├── Dashboard.tsx # 仪表盘(系统概览)
├── Collection.tsx # 数据采集(触发采集任务,结果结构化展示)
├── Predictions.tsx # 预测管理(触发预测 + 结算 + 记录展开)
├── Backtest.tsx # 回测管理(汇总指标 + 逐场明细 + 模型评估)
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
├── DataSources.tsx # 数据源管理(数据源配置与测试)
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
```
## 页面说明
### 1. 仪表盘 (`/admin`)
- 系统健康状态、联赛 / 比赛 / 预测数量统计(报纸数字版式)
- 已入库联赛列表
- 快捷操作导航
### 2. 数据采集 (`/admin/collection`)
- 选择数据源: Bzzoiro / Understat / Injuries
- 选择联赛、日期范围(Understat 为赛季)
- 触发采集任务;结果结构化展示(新增 / 更新 / 跳过 / 未匹配 / 错误)
### 3. 预测管理 (`/admin/predictions`)
- 触发预测(选择比赛 + 模式:多专家 / 单次)
- **预测结算**: 录入实际比分,调用 /eval/settle,供准确率统计使用
- 预测记录列表: 点击展开终裁理由与五路专家摘要
### 4. 回测管理 (`/admin/backtest`)
- 回测配置: 联赛(下拉)、日期范围、场数、模式
- 结果汇总: 已评分 / 1X2 准确率 / 比分 RMSE / 平均置信度
- 逐场明细: 实际比分 vs 预测比分,正误标记
- 模型评估: 各模型历史准确率(/eval/summary)
### 5. 监控面板 (`/admin/monitoring`)
- /health 存活检查 + /health/ready 数据库就绪检查
- 每 30 秒自动巡检,可手动「立即巡检」
- 服务名 / 版本 / 运行时间 / 检查项
### 6. 数据源管理 (`/admin/data-sources`)
- 数据源状态: API Key 配置状态(脱敏)
- 测试连接: 调用采集 API 验证
- 数据源说明文档
### 7. LLM 配置 (`/admin/llm-config`)
- 当前配置: provider, model, base_url
- 连接测试(会真实调用一次预测,产生 LLM 费用)
- 使用统计: 预测次数、延迟、有效率(从预测记录聚合)
- 可用模型列表
### 8. 系统配置 (`/admin/config`)
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
- 配置列表: 脱敏显示 .env 配置项
- 配置修改指南: SSH 修改 .env + 重启服务
## 鉴权说明
后端 `ADMIN_API_KEY` 的渐进式策略:
- 未配置 → 写入型接口无鉴权(本地开发)
- 已配置 → 采集 / 回测 / 结算接口必须带 `X-API-Key` 请求头
前端在 `api.ts` 统一注入该请求头;密钥在「系统配置」页设置,
仅存于本机浏览器 localStorage。401 错误会提示到该页填写密钥。
## 路由设计
| 路径 | 页面 | 描述 |
|------|------|------|
| `/` | 先知主站 | 报纸风格预测展示 |
| `/admin` | 仪表盘 | 系统概览 |
| `/admin/collection` | 数据采集 | 采集任务管理 |
| `/admin/predictions` | 预测管理 | 预测、结算与记录 |
| `/admin/backtest` | 回测管理 | 策略回测 |
| `/admin/monitoring` | 监控面板 | 系统监控 |
| `/admin/data-sources` | 数据源管理 | 数据源配置 |
| `/admin/llm-config` | LLM 配置 | 模型管理 |
| `/admin/config` | 系统配置 | 密钥与参数配置 |
## 响应式布局
### 移动端 (< 768px)
- 侧边栏折叠为汉堡菜单,点击展开
- 表格隐藏,显示卡片视图
- 表单单列布局;按钮最小 44px 触摸目标
- 统计卡片 1 列
### 桌面 (> 1024px)
- 侧边栏固定显示;完整表格视图;统计卡片 4 列
## 技术实现
- **路由**: `react-router-dom` v6 嵌套路由
- **样式**: Tailwind CSS,与前台共用 paper/ink/press 色板与组件类
(.btn / .field / .tab / .section-head / .skeleton 见 `src/index.css`)
- **API**: 统一 fetch 客户端,30s 超时,类型安全,X-API-Key 自动附带
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
- **错误处理**: ApiError 类 + 页面级 Alert 展示
- **触摸友好**: 所有可点元素 min-h-[44px]
## 启动方式
```bash
cd frontend
npm run dev
# 访问 http://localhost:5173/admin
```
## 访问入口
主站页面顶部「管理后台」链接可跳转至 `/admin`
Admin 后台侧栏底部「返回前台版面」链接可回到 `/`
+159
View File
@@ -0,0 +1,159 @@
/**
* Admin 后台 - 配置项行组件(报刊风)
*
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
* 编辑态:输入框 + 保存/取消
* 由数据源页与 LLM 配置页共用。
*/
import { useEffect, useState } from 'react'
import type { DataSourceSetting } from '../api/types'
import { Badge, Spinner } from './components'
import { Button, Input } from '../components/ui'
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
db: { text: '数据库覆盖', status: 'success' },
env: { text: '.env 默认', status: 'info' },
none: { text: '未配置', status: 'error' },
}
export default function SettingRow({
setting,
editing,
busy,
onEdit,
onCancel,
onSave,
onClear,
detectModels,
}: {
setting: DataSourceSetting
editing: boolean
busy: boolean
onEdit: () => void
onCancel: () => void
onSave: (value: string) => void
onClear: () => void
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
detectModels?: () => Promise<string[]>
}) {
const [value, setValue] = useState('')
const origin = ORIGIN_BADGE[setting.origin]
// 行内模型检测
const [detecting, setDetecting] = useState(false)
const [detected, setDetected] = useState<string[] | null>(null)
const [detectError, setDetectError] = useState('')
// 进入编辑态时清空上次的检测结果
useEffect(() => {
if (editing) {
setDetected(null)
setDetectError('')
}
}, [editing])
async function handleDetect() {
if (!detectModels || detecting) return
setDetecting(true)
setDetectError('')
try {
setDetected(await detectModels())
} catch (err) {
setDetected(null)
setDetectError(err instanceof Error ? err.message : '检测失败')
} finally {
setDetecting(false)
}
}
if (editing) {
return (
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
<span className="break-all font-mono text-ink-800">{setting.key}</span>
{setting.sensitive && <Badge status="warning"></Badge>}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type={setting.sensitive ? 'password' : 'text'}
value={value}
onChange={e => setValue(e.target.value)}
placeholder={`输入新的 ${setting.label}`}
/* 无可见 label(标题即配置项名),用 aria-label 提供可访问名称 */
aria-label={`设置项 ${setting.label} 的值`}
autoFocus
autoComplete="off"
className="flex-1"
/>
<div className="flex gap-2">
<Button variant="solid" size="sm" onClick={() => onSave(value)} disabled={!value.trim() || busy}>
{busy ? (<><Spinner /> </>) : '保存'}
</Button>
<Button size="sm" onClick={onCancel} disabled={busy}>
</Button>
</div>
</div>
{detectModels && (
<div className="mt-2 space-y-2">
<Button size="sm" onClick={handleDetect} disabled={detecting}>
{detecting ? (<><Spinner /> </>) : detected ? '重新检测' : '检测可用模型'}
</Button>
{detectError && (
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
{detectError}
</p>
)}
{detected && detected.length > 0 && (
<div className="max-h-48 overflow-y-auto border border-ink-200">
{detected.map(id => (
<button
key={id}
type="button"
onClick={() => setValue(id)}
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
value === id ? 'bg-press-wash/50' : ''
}`}
>
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
{value === id && <Badge status="success"></Badge>}
</button>
))}
</div>
)}
{detected && detected.length === 0 && !detectError && (
<p className="text-2xs text-ink-400"></p>
)}
</div>
)}
</div>
)
}
return (
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
<Badge status={origin.status}>{origin.text}</Badge>
</div>
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
{setting.configured ? setting.masked : '—'}
</div>
<div className="flex justify-end gap-2">
<Button size="sm" onClick={onEdit} disabled={busy}>
{setting.configured ? '更换' : '配置'}
</Button>
{setting.origin === 'db' && (
<Button size="sm" onClick={onClear} disabled={busy} title="删除数据库覆盖值,回落 .env">
.env
</Button>
)}
</div>
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
/**
* 【兼容壳 · 已废弃】实现已迁至 `src/api/api.ts`。
* 新代码请直接从 `../api/api` 导入。
*/
export * from '../api/api'
+31
View File
@@ -0,0 +1,31 @@
/**
* 【兼容壳】组件已合并到 `src/components/ui/`。
*
* 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。
* 二者分裂导致 Spinner 出现三份逐字节相同的副本,且前台页面要跨目录
* `import '../admin/components'` 才能拿到通用组件。
*
* 现已全部归入 `src/components/ui/`,本文件保留为再导出壳,
* 使既有 import 路径继续可用。**新代码请直接从 `components/ui` 导入。**
*
* 本文件不含任何组件实现。
*/
export { Card, CardHeader, CardBody } from '../components/ui/Card'
export { Badge } from '../components/ui/Badge'
export { StatCard, ProgressBar, AgentWeightsBar } from '../components/ui/Stat'
export { SectionHeader } from '../components/ui/SectionHeader'
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from '../components/ui/Feedback'
export { DataTable, MobileCardList, ResponsiveTable } from '../components/ui/DataTable'
export { Spinner } from '../components/ui'
/**
* 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。
* 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`),
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
*/
export { Skeleton as SkeletonBlock } from '../components/ui'
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
export { EventList, FilterTable, InsightCard, InsightGrid, LoaderGrid, SearchList, TrendingArrow } from '../components/ui'
export type { EventItem, FilterColumn, InsightCardProps } from '../components/ui'
+8
View File
@@ -0,0 +1,8 @@
/**
* 【兼容壳 · 已废弃】实现已迁至 `src/api/dal.ts`。
*
* 迁移原因见 `src/api/public.ts` 头注释:admin 目录不应承载被前台
* 反向依赖的数据层。本文件仅为不破坏旧 import 路径而保留,
* **新代码请直接从 `../api/dal`(admin 内)或 `../api`(其余位置)导入。*
*/
export * from '../api/dal'
+75
View File
@@ -0,0 +1,75 @@
/**
* Admin 导航单源(D6)。
*
* 背景: 侧栏(NAV_SECTIONS)、命令面板(NAV_PAGES)、面包屑(ROUTE_LABELS)
* 此前各维护一份平行清单,已出现漂移 —— data-pipeline 不在侧栏、
* 「系统」vs「系统设置」命名不一致、monitoring/eval 两处顺序相反。
*
* 现在只允许维护 NAV_ITEMS 一份,其余视图一律由此派生;
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
*/
import type { AdminIconName } from './AdminIcon'
export interface NavItem {
to: string
label: string
/** 命令面板中的分组名(展示原样) */
group: string
/**
* 侧栏图标名。
*
* 使用 AdminIcon 的联合类型而非 `string`:图标名拼错时
* 此前不会报任何错,只会静默渲染出一个空位,难以定位。
*/
icon: AdminIconName
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
hideFromSidebar?: boolean
}
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。
*
* 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。
* 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。
*/
export const NAV_ITEMS: NavItem[] = [
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
// ── 数据:采集、核对、管线内部视图 ──
{ to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' },
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' },
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true },
// ── 预测:历史 → 回测 → 评估闭环 ──
{ to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' },
{ to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' },
{ to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' },
// ── 系统:活着吗、发生了什么、怎么调 ──
{ to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' },
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
]
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
export const COMMAND_PALETTE_PAGES = NAV_ITEMS.map(({ to, label, group }) => ({ to, label, group }))
/** 路由 → 面包屑标签(原 ROUTE_LABELS 的唯一来源) */
export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map(i => [i.to, i.label]),
)
/**
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
*/
export const NAV_SECTIONS = (() => {
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
const titles: string[] = []
for (const i of sidebarItems) {
if (!titles.includes(i.group)) titles.push(i.group)
}
return titles.map(title => ({
title,
items: sidebarItems
.filter(i => i.group === title)
.map(({ to, label, icon }) => ({ to, label, icon })),
}))
})()
+368
View File
@@ -0,0 +1,368 @@
/**
* Admin 后台 - 回测管理页面(报刊风)
*
* 功能:
* - 回测配置: 联赛(下拉)、日期范围、场数、模式
* - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度
* - 逐场明细: 实际比分 vs 预测比分,正误标记
* - 模型评估: 各模型历史准确率(/eval/summary)
*
* 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。
*/
import { useEffect, useState } from 'react'
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../../api/dal'
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../../api/types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
import { Button, Input, Select } from '../../components/ui'
interface BacktestResultRow {
match_id: number
league_code?: string | null
home_team: string
away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date?: string | null
actual_score: string
actual_1x2?: string
pred_home?: number | null
pred_away?: number | null
pred_1x2?: string | null
subjective_confidence?: number | null
correct_1x2: boolean
}
interface BacktestResponse {
summary: BacktestSummary
results: BacktestResultRow[]
}
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
function exportCsv(rows: BacktestResultRow[]) {
const header = [
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
]
const lines = [header.join(",")]
for (const r of rows) {
lines.push([
fmtDate(r.match_date), r.league_code ?? "",
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
r.actual_score, r.actual_1x2 ?? "",
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
r.correct_1x2 ? "是" : "否",
].join(","))
}
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
a.click()
URL.revokeObjectURL(url)
}
/** CSV 字段转义:含逗号/引号/换行时加引号 */
function csvCell(v: string): string {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
}
function fmtDate(s?: string | null): string {
if (!s) return '—'
return new Date(s).toLocaleDateString('sv-SE')
}
export default function BacktestPage() {
const [leagues, setLeagues] = useState<League[]>([])
const [leagueId, setLeagueId] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [limit, setLimit] = useState(20)
const [mode] = useState<'multi'>('multi')
const [model, setModel] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BacktestResponse | null>(null)
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(null)
const [evalLoading, setEvalLoading] = useState(false)
useEffect(() => {
fetchLeagues().then(setLeagues)
loadEval()
}, [])
async function loadEval() {
setEvalLoading(true)
try {
setEvalSummary(await fetchEvalSummary())
} finally {
setEvalLoading(false)
}
}
async function handleBacktest(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
setResult(null)
try {
const req: BacktestRequest = {
league_id: leagueId ? parseInt(leagueId) : undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
limit,
mode,
model: model.trim() || undefined,
}
const res = await triggerBacktest(req as BacktestRequest)
setResult(res as unknown as BacktestResponse)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '回测失败')
} finally {
setLoading(false)
}
}
const summary = result?.summary
return (
<div className="space-y-6">
<SectionHeader
title="回测管理"
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 回测配置 */}
<Card>
<CardHeader title="回测配置" />
<CardBody>
<form onSubmit={handleBacktest} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<Select
value={leagueId}
onChange={e => setLeagueId(e.target.value)}
className="w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.id ?? l.code} value={String(l.id)}>
{l.name_zh || l.name}
</option>
))}
</Select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-from"></label>
<Input id="bt-date-from"
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-to"></label>
<Input id="bt-date-to"
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-limit"></label>
<Input id="bt-limit"
type="number"
min={1}
max={200}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-mode"></label>
<Input id="bt-mode"
type="text"
value="多专家 (5 路 + 终裁)"
readOnly
className="w-full bg-paper-100 text-ink-500"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500">
(,= <code className="font-mono">gpt-4o</code>)
</label>
<Input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="如 deepseek-chat / 留空使用默认"
className="w-full"
/>
</div>
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
{loading ? (<><Spinner /> ,</>) : '开始回测'}
</Button>
</form>
</CardBody>
</Card>
{/* 结果汇总 */}
<div className="space-y-6">
{summary && (
<Card>
<CardHeader
title="回测结果"
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit}`}
action={
result?.results?.length
? (<Button size="sm" onClick={() => exportCsv(result.results)}> CSV</Button>)
: undefined
}
/>
<CardBody>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.scored}/{summary.total}
</div>
<div className="mt-1 text-2xs text-ink-400"> / </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.success}/{summary.degraded}
</div>
<div className="mt-1 text-2xs text-ink-400">
/ <span className="text-ink-300"> (degraded)</span>
</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
? `${summary.accuracy_1x2.toFixed(1)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400">1X2 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
? summary.avg_score_rmse.toFixed(2)
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"> RMSE</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
{summary.degraded > 0 && (
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{summary.degraded} (),
</div>
)}
</div>
</CardBody>
</Card>
)}
{/* 模型评估 */}
<Card>
<CardHeader
title="模型评估"
description="已结算预测的准确率统计"
action={
<Button size="sm" onClick={loadEval} disabled={evalLoading}>
{evalLoading ? (<><Spinner /> </>) : '刷新'}
</Button>
}
/>
<CardBody>
{evalSummary && evalSummary.summary?.length > 0 ? (
<div className="space-y-2.5">
{evalSummary.summary.map((s, i) => (
<div
key={i}
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
<span className="text-2xs tabular-nums text-ink-500">
<span className="font-serif text-sm font-bold text-ink-900">
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
</span>
<span className="ml-2">{s.total} </span>
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
</span>
</div>
))}
</div>
) : (
<p className="py-4 text-center text-xs text-ink-400">
,
</p>
)}
</CardBody>
</Card>
</div>
</div>
{/* 逐场明细 */}
{result && result.results?.length > 0 && (
<Card>
<CardHeader title="逐场明细" />
<CardBody className="px-0 sm:px-0">
<div>
{result.results.map(r => (
<div
key={r.match_id}
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
>
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
{fmtDate(r.match_date)}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
<TeamSideTag side="home" />
<span className="truncate">{r.home_team_zh || r.home_team}</span>
<span className="flex-shrink-0 text-ink-300">vs</span>
<TeamSideTag side="away" />
<span className="truncate">{r.away_team_zh || r.away_team}</span>
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
<span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
<span className="mx-2 text-ink-200">|</span>
<span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
</span>
</span>
<span className="flex-shrink-0 sm:w-16 sm:text-right">
{r.correct_1x2 ? <Badge status="success"></Badge> : <Badge status="loss"></Badge>}
</span>
</div>
))}
</div>
</CardBody>
</Card>
)}
</div>
)
}
+443
View File
@@ -0,0 +1,443 @@
/**
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
*
* 三个采集任务:
* events — 比赛日程与比分
* standings — 联赛积分榜
* stats — 已完赛比赛详细统计回填
* all — 依次执行以上三项
*
* 响应式布局: 移动端单列,桌面端双列
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
import type { IngestJob, League } from '../../api/types'
import type { CollectionRequest } from '../../api/types'
import { AnimatedBadge } from '../../components/ui'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { Button, Input, Select } from '../../components/ui'
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
const TASKS = [
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
] as const
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['success', 'failed'])
export default function CollectionPage() {
const [leagues, setLeagues] = useState<League[]>([])
const [task, setTask] = useState<string>('events')
const [leagueCode, setLeagueCode] = useState('')
// 从 URL 查询参数预填充(支持从「数据完整性」页跳转)
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
const taskParam = sp.get('task')
const leagueParam = sp.get('league')
if (taskParam && ['events', 'standings', 'stats', 'all'].includes(taskParam)) {
setTask(taskParam)
}
if (leagueParam) {
setLeagueCode(leagueParam)
}
}, [])
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('')
const [limit, setLimit] = useState(100)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 任务进度反馈:跟踪真实 ingest_job 状态
const [taskStatus, setTaskStatus] = useState<TaskUIStatus>('idle')
const [jobId, setJobId] = useState<string | null>(null)
const [jobInfo, setJobInfo] = useState<IngestJob | null>(null)
const [taskStartedAt, setTaskStartedAt] = useState<number | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loadLeagues = useCallback(async () => {
const lg = await fetchLeagues()
setLeagues(lg)
}, [])
useEffect(() => { loadLeagues() }, [loadLeagues])
// 轮询采集 job 直到终态(success/failed)
const stopPolling = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
}, [])
useEffect(() => () => stopPolling(), [stopPolling])
// ── 已运行时长 ──
// 必须放在 state 里、由定时器推进,而不是在 render 里读 Date.now()。
// 后者是「渲染期间的副作用」:同一份 props/state 会渲染出不同结果,
// React 的并发特性(以及未来的编译器优化)都依赖渲染幂等。
// 采集任务在跑时每秒推进一次,既给出真实的时长,也不制造额外重渲染。
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!taskStartedAt) { setElapsedSec(0); return }
if (taskStatus !== 'running') return
const id = setInterval(() => {
setElapsedSec(Math.round((Date.now() - taskStartedAt) / 1000))
}, 1000)
return () => clearInterval(id)
}, [taskStartedAt, taskStatus])
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
const loadRecentJobs = useCallback(async () => {
try {
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
} catch { /* 历史列表失败不影响主流程 */ }
}, [])
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
const startJobPolling = useCallback((id: string) => {
stopPolling()
const tick = async () => {
try {
const job = await fetchIngestJob(id)
setJobInfo(job)
if (TERMINAL_STATUSES.has(job.status)) {
setTaskStatus(job.status === 'success' ? 'done' : 'error')
stopPolling()
loadRecentJobs() // 终态后刷新历史列表
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
detail: { jobId: job.id, status: job.status },
}))
}
} catch { /* 单次轮询失败不影响后续 */ }
}
tick()
pollRef.current = setInterval(tick, 3_000)
}, [stopPolling, loadRecentJobs])
const isEventsTask = task === 'events' || task === 'all'
// 友好汇总 job.result
const jobSummary = (j: IngestJob | null): { title: string; detail: string } | null => {
if (!j) return null
if (j.status === 'failed') {
return { title: '采集失败', detail: j.error || '采集任务异常终止,请到「系统日志」查看详细堆栈。' }
}
if (j.status !== 'success') return null
const r = j.result as Record<string, unknown> | null
if (!r) return { title: '采集完成', detail: '任务成功(无汇总数据)。' }
const ev = r.events as Record<string, unknown> | undefined
const evTotal = ev ? (ev.total_inserted as number ?? 0) + (ev.total_updated as number ?? 0) : 0
const st = r.standings as Record<string, unknown> | undefined
const stTotal = st ? (st.total_upserted as number ?? 0) : 0
const stats = r.stats as Record<string, unknown> | undefined
const statsTotal = stats ? (stats.created as number ?? 0) + (stats.updated as number ?? 0) : 0
const evErr = (ev?.errors as string[] | undefined)?.length ?? 0
const stErr = (st?.errors as string[] | undefined)?.length ?? 0
const statsErr = (stats?.errors as string[] | undefined)?.length ?? 0
const totalErr = evErr + stErr + statsErr
const parts: string[] = []
if (ev) parts.push(`比赛 +${evTotal}`)
if (st) parts.push(`积分榜 +${stTotal}`)
if (stats) parts.push(`统计 +${statsTotal}`)
const detail = parts.length
? `共更新: ${parts.join(' / ')}${totalErr ? `,错误 ${totalErr} 条(见日志)` : ''}`
: '任务成功'
return { title: '采集完成', detail }
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setJobInfo(null)
setJobId(null)
setLoading(true)
setTaskStatus('running')
setTaskStartedAt(Date.now())
try {
const body: CollectionRequest = {
source: 'bzzoiro',
leagues: leagueCode ? [leagueCode] : undefined,
task: task as CollectionRequest['task'],
limit,
season: season || undefined,
status: ingestStatus || undefined,
date_from: isEventsTask ? dateFrom || undefined : undefined,
date_to: isEventsTask ? dateTo || undefined : undefined,
}
const res = await triggerCollection(body)
const id: string | undefined = res?.job_id
if (id) {
setJobId(id)
startJobPolling(id)
} else {
// 后端未返回 job_id(旧版兼容):退化为原逻辑
setTimeout(() => { setTaskStatus('done'); }, 30_000)
}
} catch (err: unknown) {
setTaskStatus('error')
setError(err instanceof Error ? err.message : '采集触发失败')
stopPolling()
} finally {
setLoading(false)
}
}
const elapsed = elapsedSec
const summary = jobInfo ? jobSummary(jobInfo) : null
return (
<div className="space-y-6">
<SectionHeader
title="数据采集"
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 采集表单 */}
<Card>
<CardHeader title="新建采集任务" />
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 任务类型 */}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASKS.map(t => (
<button
key={t.value}
type="button"
onClick={() => setTask(t.value)}
className={`border px-3 py-2 text-left text-xs transition-colors ${
task === t.value
? 'border-press bg-press-wash text-press'
: 'border-ink-200 text-ink-600 hover:border-ink-300'
}`}
>
<span className="mr-1">{t.icon}</span>
<span className="font-medium">{t.label}</span>
</button>
))}
</div>
</div>
{/* 联赛选择 */}
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-league"></label>
<Select
id="col-league"
value={leagueCode}
onChange={e => setLeagueCode(e.target.value)}
className="w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
))}
</Select>
</div>
{/* events/all 任务专用: 比赛状态 + 日期 */}
{isEventsTask && (
<>
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-status"></label>
<Select
id="col-status"
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</Select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-from"></label>
<Input id="col-date-from"
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-to"></label>
<Input id="col-date-to"
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full"
/>
</div>
</div>
</>
)}
{/* standings 任务专用: 赛季 */}
{(task === 'standings') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-season">()</label>
<Input id="col-season"
type="text"
value={season}
onChange={e => setSeason(e.target.value)}
placeholder="如 2026-2027"
className="w-full"
/>
</div>
)}
{/* stats 任务专用: 回填数量 */}
{(task === 'stats') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">(1-500)</label>
<Input
type="number"
min={1}
max={500}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 100)}
className="w-full"
/>
<p className="mt-1 text-2xs text-ink-400">
source_event_id (), 1.2 /
</p>
</div>
)}
{/* 消息提示 */}
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
{summary && (
<Alert
kind={jobInfo?.status === 'success' ? 'ok' : 'error'}
title={summary.title}
message={summary.detail}
/>
)}
{/* 提交按钮 */}
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
{loading ? (<><Spinner /> </>) : '触发采集'}
</Button>
</form>
</CardBody>
</Card>
{/* 任务状态 + 数据源说明 */}
<div className="space-y-6">
{/* 任务进度 */}
<Card>
<CardHeader title="任务状态" />
<CardBody>
{taskStatus === 'idle' && (
<p className="text-xs text-ink-400"></p>
)}
{taskStatus === 'running' && (
<div className="space-y-3">
<AnimatedBadge status="loading" size="md">
{jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
</AnimatedBadge>
<p className="text-2xs text-ink-400">
{elapsed}s · ,, 3
</p>
</div>
)}
{taskStatus === 'done' && (
<div className="space-y-2">
<AnimatedBadge status="success" size="md">
{jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
</AnimatedBadge>
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
</div>
)}
{taskStatus === 'error' && (
<div className="space-y-2">
<AnimatedBadge status="danger" size="md">
{jobId ? ` · job ${jobId.slice(0, 8)}` : ''}
</AnimatedBadge>
{jobInfo?.error && (
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
)}
</div>
)}
{jobInfo?.created_at && (
<p className="mt-2 text-2xs text-ink-400">
{new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })}
{jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}
</p>
)}
</CardBody>
</Card>
{/* 最近任务历史 */}
<Card>
<CardHeader
title="最近任务"
description="后台采集任务执行历史(最新在前)"
action={<Button size="sm" onClick={loadRecentJobs}></Button>}
/>
<CardBody className="px-0">
{recentJobs === null ? (
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5"></p>
) : recentJobs.length === 0 ? (
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">,</p>
) : (
<div>
{recentJobs.map(j => (
<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">
<Badge status={j.status === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
<AnimatedBadge
status={j.status === 'failed' ? 'danger' : j.status === 'success' ? 'success' : j.status === 'running' ? 'loading' : 'neutral'}
size="sm"
/>
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
</span>
</div>
))}
</div>
)}
</CardBody>
</Card>
{/* 数据源说明 */}
<Card>
<CardHeader title="采集任务说明" />
<CardBody>
<div className="space-y-3">
{TASKS.map(t => (
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<span>{t.icon}</span>
<Badge status="info">{t.label}</Badge>
<p className="text-xs text-ink-600">{t.desc}</p>
</div>
</div>
))}
</div>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
(401 )
线 bzzoiro (Understat / injuries )
线 source_event_id,
</p>
</CardBody>
</Card>
</div>
</div>
</div>
)
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Admin 后台 - 仪表盘(报刊风·待办驱动)
*
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
* 而不是扫描一堆常驻数字。
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
* - 数据概览合并为一卡:预测活动 + 各表数据量
*/
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
import { InsightGrid, InsightCard, LoaderGrid } from '../components'
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
const STEPS = [
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
]
interface TodoItem {
key: string
count: number
label: string
to: string
/** 无上限确认时数字近似,展示为 N+ */
approx?: boolean
}
export default function Dashboard() {
const [stats, setStats] = useState<AdminStats | null>(null)
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
const [s, i, d, f, c, p] = await Promise.allSettled([
fetchAdminStats(),
fetchIngestStatus(),
fetchDashboard(),
fetchIngestFailures(),
fetchDataCompleteness(),
fetchPredictions(100),
])
if (s.status === 'fulfilled') setStats(s.value)
if (i.status === 'fulfilled') setIngest(i.value.sources)
if (d.status === 'fulfilled') setDash(d.value)
if (f.status === 'fulfilled') setFailures(f.value)
if (c.status === 'fulfilled') setCompleteness(c.value)
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
// ── 待办计算 ──
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
const missingStatsLeagues = completeness?.leagues.filter(
l => l.matches.finished > 0 && l.stats.rows === 0,
).length ?? 0
const missingStandingsLeagues = completeness?.leagues.filter(
l => l.matches.total > 0 && l.standings.rows === 0,
).length ?? 0
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
const settleable = recentPreds.filter(
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
).length
const todos: TodoItem[] = [
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
].filter((t): t is TodoItem => t !== false)
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro']
const hasMatches = (stats?.matches?.total ?? 0) > 0
return (
<div className="space-y-6">
{/* ── 待办行:只有真有待办才出现 ── */}
{loading ? (
<div className="h-14 w-full animate-pulse bg-paper-100" role="presentation" />
) : todos.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-3">
{todos.map(t => (
<Link
key={t.key}
to={t.to}
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
>
<span className="font-serif text-2xl font-bold tabular-nums text-press">
{t.count}{t.approx ? '+' : ''}
</span>
<span className="text-xs text-ink-700">{t.label}</span>
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true"></span>
</Link>
))}
</div>
) : (
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
流水线无待办:没有失败记录
</p>
)}
{/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
{!loading && !hasMatches && (
<div className="grid gap-4 sm:grid-cols-3">
{STEPS.map(s => (
<Link
key={s.to}
to={s.to}
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
{s.step}
</span>
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span>
</div>
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
</Link>
))}
</div>
)}
{/* ── 数据源:单源一行即足,不装成列表 ── */}
<div className="border border-ink-200 bg-paper-50">
<div className="flex items-center justify-between border-b border-ink-200 px-4 py-2 sm:px-5">
<h2 className="text-sm font-medium text-ink-700"></h2>
<span className="text-xs text-ink-400">bzzoiro</span>
</div>
<div className="px-4 py-3 sm:px-5">
{loading ? (
<LoaderGrid mode="row" count={1} cols={2} />
) : (() => {
const st = bzzoiro
const hasData = st && st.recent_count > 0
const keyOk = st?.key_configured !== false
return (
<div className="flex items-center justify-between text-xs">
<span className="text-ink-700">Bzzoiro</span>
<span className="flex items-center gap-3 text-2xs">
{hasData ? (
<>
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} </span>
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
</>
) : keyOk ? (
<span className="text-ink-400"></span>
) : (
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2"> Key,</Link>
)}
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
</span>
</div>
)
})()}
</div>
</div>
{/* ── 数据概览:InsightCard 指标卡 ── */}
{stats ? (
<div className="space-y-4">
<InsightGrid>
<InsightCard label="预测 · 近 24 小时" value={stats.predictions.last_24h} status="info" />
<InsightCard label="预测 · 近 7 天" value={stats.predictions.last_7d} status="info" />
<InsightCard label="预测 · 累计" value={stats.predictions.total} status="positive" />
</InsightGrid>
<InsightGrid>
<InsightCard label="比赛总数" value={stats.matches?.total ?? 0} subtitle={`已完赛 ${stats.matches?.finished ?? 0}`} status="info" />
<InsightCard label="统计行" value={stats.stats?.total ?? 0} status="info" />
<InsightCard label="积分榜" value={stats.standings?.total ?? 0} status="info" />
</InsightGrid>
</div>
) : (
<LoaderGrid mode="card" count={6} />
)}
{/* ── 已入库联赛 ── */}
{dash && dash.leagues.length > 0 && (
<div className="border border-ink-200 bg-paper-50">
<div className="border-b border-ink-200 px-4 py-2 sm:px-5">
<h2 className="text-sm font-medium text-ink-700"></h2>
</div>
<div className="px-4 py-3 sm:px-5">
<div className="flex flex-wrap gap-2">
{dash.leagues.map(l => (
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
{l.name_zh || l.name}
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
</span>
))}
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,267 @@
/**
* Admin 后台 - 数据完整性分析页
*
* 回答三个问题:
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
* 2. 字段是否齐全(每张统计表各字段非空率)
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchDataCompleteness } from '../../api/dal'
import type { DataCompletenessResponse } from '../../api/dal'
import {
Card, CardBody, CardHeader, SectionHeader, Alert,
ProgressBar, Spinner,
} from '../components'
import { Button } from '../../components/ui'
const FIELD_LABELS: Record<string, string> = {
xg: 'xG 预期进球',
shots: '射门',
possession: '控球率',
corners: '角球',
fouls: '犯规',
big_chances: '绝佳机会',
cards: '红黄牌',
}
function pctColor(pct: number): string {
if (pct >= 80) return 'bg-ok-500'
if (pct >= 50) return 'bg-warn-500'
return 'bg-bad-500'
}
/** 根据问题描述生成可操作的修复链接 */
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
// 提取联赛代码(大写字母+数字,如 E0, SP1)
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
const code = codeMatch ? codeMatch[1] : ''
if (!code) return null
if (issue.includes('无已完赛比赛')) {
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
}
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
}
if (issue.includes('无积分榜')) {
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
}
return null
}
export default function DataCompletenessPage() {
const [data, setData] = useState<DataCompletenessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
// 页面文案承诺「每 5 秒自动刷新」,此前并未实现(仅挂载加载一次),
// 采集完成后数字不动 —— 现补齐:5s 轮询 + 采集完成事件即时刷新。
const [autoRefresh, setAutoRefresh] = useState(true)
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const d = await fetchDataCompleteness()
setData(d)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
// 5s 自动轮询(与页面文案一致);已有数据时刷新不闪骨架
useEffect(() => {
if (!autoRefresh) return
const t = setInterval(load, 5_000)
return () => clearInterval(t)
}, [autoRefresh, load])
// 采集页任务终态广播 → 立即刷新(不等下一个 5s 周期)
useEffect(() => {
const onIngestDone = () => load()
window.addEventListener('profeto:ingest-done', onIngestDone)
return () => window.removeEventListener('profeto:ingest-done', onIngestDone)
}, [load])
// 点击问题项 → 滚动到对应联赛卡片并高亮
const scrollToLeague = useCallback((code: string) => {
setHighlightedLeague(code)
// 使用 requestAnimationFrame 确保 DOM 已更新
requestAnimationFrame(() => {
const el = leagueRefs.current[code]
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
// 5秒后移除高亮
setTimeout(() => setHighlightedLeague(null), 5000)
}, [])
return (
<div className="space-y-6">
<SectionHeader
title="数据完整性"
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
5s
</label>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
{loading ? <><Spinner /> </> : '刷新'}
</Button>
</div>
}
/>
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && !data && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{data && (
<>
{/* 全局概览 */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
<p className="text-xs text-ink-500">()</p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">
{data.totals.stats_coverage_pct}%
</p>
<p className="text-xs text-ink-500">( / )</p>
</CardBody>
</Card>
</div>
{/* 健康问题(可操作) */}
<Card>
<CardHeader title="健康摘要" />
<CardBody>
<div className="space-y-2">
{data.issues.map((issue, i) => {
const action = getIssueAction(issue)
return (
<Alert
key={i}
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
title={issue.includes('良好') ? '数据良好' : '需要关注'}
message={issue}
action={action ? (
<div className="flex items-center gap-2">
<Button size="sm" className="whitespace-nowrap" onClick={() => scrollToLeague(action.code)}>
</Button>
<Button href={action.href} size="sm" className="whitespace-nowrap">
{action.label}
</Button>
</div>
) : undefined}
/>
)
})}
</div>
</CardBody>
</Card>
{/* 各联赛详情 */}
<div className="space-y-4">
{data.leagues.map(league => {
const finished = league.matches.finished
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
const isHighlighted = highlightedLeague === league.code
return (
<div
key={league.code}
ref={el => { leagueRefs.current[league.code] = el }}
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
>
<Card>
<CardHeader
title={league.name}
description={[
league.country,
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled}`,
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
league.standings.rows > 0 ? `积分榜 ${league.standings.rows}` : '',
].filter(Boolean).join(' · ')}
/>
<CardBody className="space-y-4">
{/* 统计覆盖率进度条 */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-ink-600"></span>
<span className="font-medium text-ink-900">
{league.stats.rows} / {finished} ({statsPct}%)
</span>
</div>
<ProgressBar value={statsPct} />
</div>
{/* 字段覆盖率矩阵 */}
{league.stats.rows > 0 ? (
<div>
<p className="mb-2 text-xs font-medium text-ink-600">( / )</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{Object.entries(league.stats.fields).map(([key, info]) => (
<div key={key} className="rounded border border-ink-100 px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
<div
className={`h-full rounded-full ${pctColor(info.pct)}`}
style={{ width: `${info.pct}%` }}
/>
</div>
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} </p>
</div>
))}
</div>
</div>
) : finished > 0 ? (
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
) : (
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
)}
</CardBody>
</Card>
</div>
)
})}
</div>
<p className="text-center text-2xs text-ink-400">
: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
</p>
</>
)}
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Admin 后台 - 数据管线管理页(报刊风)
*
* 功能:
* - 采集失败记录列表(可重试)
* - 数据质量检查结果
* - 手动触发质量检查
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataQuality,
runDataQualityCheck,
fetchIngestFailures,
retryIngestFailure,
} from '../../api/dal'
import type { IngestFailureItem, DataQualityCheckItem } from '../../api/dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { Button } from '../../components/ui'
export default function DataPipelinePage() {
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
setQuality(q)
setFailures(f)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleRunCheck = async () => {
setRunning(true)
setNotice(null)
try {
const res = await runDataQualityCheck()
const failed = res.checks.filter(c => !c.passed)
setNotice({
ok: failed.length === 0,
text: failed.length === 0
? '数据质量检查通过'
: `检查完成: ${failed.length} 项未通过`,
})
await load()
} catch {
setNotice({ ok: false, text: '质量检查执行失败' })
} finally {
setRunning(false)
}
}
const handleRetry = async (id: number) => {
setNotice(null)
try {
const res = await retryIngestFailure(id)
setNotice({ ok: true, text: res.message })
await load()
} catch {
setNotice({ ok: false, text: '重试操作失败' })
}
}
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
return (
<div className="space-y-6">
<SectionHeader
title="数据管线"
description="采集失败重试、数据质量检查与监控"
action={
<Button size="sm" onClick={handleRunCheck} disabled={running}>
{running ? <><Spinner /> </> : '运行质量检查'}
</Button>
}
/>
{notice && (
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
)}
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{!loading && (
<>
{/* 采集失败记录 */}
<Card>
<CardHeader
title="采集失败记录"
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
/>
<CardBody className="px-0 sm:px-0">
{failures.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{failures.map(f => (
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
<td className="px-4 py-2">
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
{f.status}
</Badge>
</td>
<td className="px-4 py-2">
{(f.status === 'pending' || f.status === 'retrying') && (
<Button size="sm" onClick={() => handleRetry(f.id)}>
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
{/* 数据质量检查 */}
<Card>
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
<CardBody className="px-0 sm:px-0">
{quality?.checks.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">,</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{quality?.checks.map(c => (
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
<td className="px-4 py-2">
<Badge status={c.passed ? 'success' : 'error'}>
{c.passed ? '通过' : '未通过'}
</Badge>
</td>
<td className="px-4 py-2">
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
{c.severity}
</Badge>
</td>
<td className="px-4 py-2 text-2xs text-ink-400">
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
</>
)}
</div>
)
}
+213
View File
@@ -0,0 +1,213 @@
/**
* Admin 后台 - 评估汇总页(报刊风)
*
* 功能:
* - 按 provider / model / prompt_version / mode / league_code 筛选
* - 展示汇总统计卡片(已结算/已评估/跳过数)
* - 准确率对比表格
* - 空态与加载态
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchEvalSummary, fetchLeagues } from '../../api/dal'
import type { EvalSummary, EvalSummaryRow, EvalCalibrationBucket } from '../../api/types'
import { Card, CardBody, CardHeader, StatCard, DataTable, Alert, Spinner, EmptyText } from '../components'
import { Button, Input, Select } from '../../components/ui'
interface Filters {
provider: string
model: string
prompt_version: string
mode: string
league_code: string
}
const EMPTY_FILTERS: Filters = { provider: '', model: '', prompt_version: '', mode: '', league_code: '' }
export default function EvalPage() {
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
const [leagues, setLeagues] = useState<Array<{ code: string; name: string }>>([])
const [data, setData] = useState<EvalSummary | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchLeagues()
.then(l => setLeagues(l.map(x => ({ code: x.code, name: x.name }))))
.catch(() => setLeagues([]))
}, [])
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: Record<string, string> = {}
if (filters.provider) params.provider = filters.provider
if (filters.model) params.model = filters.model
if (filters.prompt_version) params.prompt_version = filters.prompt_version
if (filters.mode) params.mode = filters.mode
if (filters.league_code) params.league_code = filters.league_code
const result = await fetchEvalSummary(params)
setData(result)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { load() }, [load])
const handleChange = (key: keyof Filters) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
setFilters(f => ({ ...f, [key]: e.target.value }))
}
const handleReset = () => setFilters(EMPTY_FILTERS)
const summary = data?.summary ?? []
return (
<div className="space-y-6">
{/* 筛选控件 */}
<Card>
<CardHeader title="筛选条件" description="按提供商 / 模型 / 版本 / 模式 / 联赛过滤评估数据" />
<CardBody>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<label className="block">
<span className="text-2xs text-ink-500"></span>
<Input
type="text"
value={filters.provider}
onChange={handleChange('provider')}
placeholder="如 openai"
className="mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<Input
type="text"
value={filters.model}
onChange={handleChange('model')}
placeholder="如 gpt-4o"
className="mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500">Prompt </span>
<Input
type="text"
value={filters.prompt_version}
onChange={handleChange('prompt_version')}
placeholder="如 v1"
className="mt-1 w-full"
/>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<Select
value={filters.mode}
onChange={handleChange('mode')}
className="mt-1 w-full"
>
<option value=""></option>
<option value="single">single</option>
<option value="multi">multi</option>
</Select>
</label>
<label className="block">
<span className="text-2xs text-ink-500"></span>
<Select
value={filters.league_code}
onChange={handleChange('league_code')}
className="mt-1 w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
))}
</Select>
</label>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={loading} onClick={load}>
{loading ? '加载中…' : '应用筛选'}
</Button>
<Button size="sm" variant="ghost" disabled={loading} onClick={handleReset}>
</Button>
</div>
</CardBody>
</Card>
{/* 错误态 */}
{error && <Alert kind="error" title="加载失败" message={error} />}
{/* 汇总统计 */}
{data && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatCard label="已结算总数" value={data.total_settled} hint="含 degraded" />
<StatCard label="筛选后已结算" value={data.filtered_settled} hint="应用筛选条件后" />
<StatCard label="实际评估" value={data.evaluated} hint="status=success 且比分齐全" />
<StatCard label="跳过 degraded" value={data.skipped_degraded} hint="不计入准确率" />
</div>
)}
{/* 准确率表格 */}
<Card>
<CardHeader
title="准确率对比"
description={!loading && summary.length > 0 ? `${summary.length}` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
/>
<CardBody>
{loading ? (
<div className="flex items-center justify-center py-12">
<Spinner />
</div>
) : summary.length === 0 ? (
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : (
<div className="overflow-x-auto">
<DataTable<EvalSummaryRow>
columns={[
{ key: 'provider', label: '提供商' },
{ key: 'model', label: '模型' },
{ key: 'prompt_version', label: '版本', render: (row: EvalSummaryRow) => (
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
) },
{ key: 'total', label: '评估条数' },
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: EvalSummaryRow) => (
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
) },
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: EvalSummaryRow) => (
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
) },
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: EvalSummaryRow) => (
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
) },
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: EvalSummaryRow) => (
row.calibration ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
{Object.entries(row.calibration).map(([name, b]: [string, EvalCalibrationBucket]) => (
<span key={name} className="inline-flex items-center gap-1">
<span className="text-ink-400">{name}:</span>
<span className="tabular-nums font-medium">
{b.hit_rate != null ? `${b.hit_rate}%` : '—'}
</span>
<span className="text-ink-300">({b.total})</span>
</span>
))}
</div>
) : '—'
) },
]}
data={summary}
rowKey={(row: EvalSummaryRow) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据"
/>
</div>
)}
</CardBody>
</Card>
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Admin 后台 - 系统日志页面(报刊风)
*
* 查看应用运行日志(内存缓冲,最新在前):
* - 级别筛选 + 关键字搜索
* - 自动刷新(10s)可开关
* - 缓冲上限 2000 条,进程重启后清零
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchLogs } from '../../api/dal'
import type { LogEntry } from '../../api/types'
import { Badge, SectionHeader, Alert, Card, CardHeader, CardBody, Spinner, FilterTable, type FilterColumn, LoaderGrid } from '../components'
import { Button, Input } from '../../components/ui'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
DEBUG: { status: 'info', text: 'DEBUG' },
INFO: { status: 'info', text: 'INFO' },
WARNING: { status: 'warning', text: 'WARN' },
ERROR: { status: 'error', text: 'ERROR' },
CRITICAL: { status: 'error', text: 'FATAL' },
}
const LOG_COLUMNS: FilterColumn<LogEntry>[] = [
{
key: 'ts',
label: '时间',
width: '160px',
sortable: true,
render: r => <span className="tabular-nums text-ink-400">{fmtTs(r.ts)}</span>,
},
{
key: 'level',
label: '级别',
width: '80px',
sortable: true,
filterable: true,
filterType: 'select',
filterOptions: [
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'INFO', label: 'INFO' },
{ value: 'WARNING', label: 'WARN' },
{ value: 'ERROR', label: 'ERROR' },
],
render: r => {
const badge = LEVEL_BADGE[r.level] ?? { status: 'info' as const, text: r.level }
return <Badge status={badge.status}>{badge.text}</Badge>
},
},
{
key: 'logger',
label: 'Logger',
width: '160px',
sortable: true,
filterable: true,
render: r => (
<span className="truncate font-mono text-ink-400" title={r.logger}>
{r.logger}
</span>
),
},
{
key: 'message',
label: '消息',
filterable: true,
render: r => <span className="break-all font-mono leading-relaxed text-ink-800">{r.message}</span>,
},
]
function fmtTs(ts: number): string {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
export default function LogsPage() {
const [entries, setEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [level, setLevel] = useState<string>('')
const [keyword, setKeyword] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
try {
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
setEntries(d.entries)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
} finally {
setLoading(false)
}
}, [level, keyword])
// 筛选条件变化 → 立即拉取
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current)
if (autoRefresh) {
timerRef.current = setInterval(load, 10_000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [autoRefresh, load])
return (
<div className="space-y-6">
<SectionHeader
title="系统日志"
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
/>
{error && <Alert kind="error" title="无法加载日志" message={error} />}
<Card>
<CardHeader
title="日志查看"
description={entries.length > 0 ? `显示最新 ${entries.length}` : undefined}
action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
10s
</label>
<Button size="sm" onClick={load} disabled={loading}>
{loading ? (<><Spinner /> </>) : '刷新'}
</Button>
</div>
}
/>
<CardBody className="px-0 sm:px-0">
{/* 筛选栏 */}
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex flex-wrap gap-1.5">
{LEVELS.map(lv => (
<Button
key={lv || 'all'}
onClick={() => setLevel(lv)}
variant={level === lv ? 'solid' : 'default'}
size="sm"
>
{lv || '全部'}
</Button>
))}
</div>
<Input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索关键字(消息 / logger)…"
aria-label="搜索日志关键字"
className="w-full sm:w-64"
/>
</div>
{/* 日志表格:FilterTable(列排序 + 筛选) */}
{loading && entries.length === 0 ? (
<LoaderGrid mode="row" count={6} cols={4} />
) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p>
) : (
<FilterTable
columns={LOG_COLUMNS}
data={entries}
rowKey={e => `${e.ts}-${e.logger}-${e.message.slice(0, 20)}`}
emptyText="暂无匹配的日志"
/>
)}
</CardBody>
</Card>
</div>
)
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Admin 后台 - 监控页(报刊风·三分区)
*
* 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
* 现在聚合全站已有信号,回答三个问题:
* 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
* 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
* 30s 自动巡检 + 手动巡检。
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../../api/dal'
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
import type { IngestJob, LLMUsageStats, HealthProbe } from '../../api/types'
import { api } from '../../api/api'
import { Alert, SectionHeader, Spinner } from '../components'
import { Button } from '../../components/ui'
type UpstreamProbe = { ok: boolean; status_code?: number; latency_ms: number; endpoint: string; error?: string }
// 直接引用 DAL 的权威类型,不要再本地手写一份 ——
// 本地别名会把 `avg_latency_ms: number | null` 悄悄收窄回 `number`,
// 使「未接入」在页面上重新退化成假数字。
type LLMStats = LLMUsageStats
interface TodoItem {
key: string
label: string
to: string
}
function MetricCard({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400">{label}</div>
<div className="mt-2">{children}</div>
</div>
)
}
export default function MonitoringPage() {
const [health, setHealth] = useState<HealthProbe | null>(null)
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
const [upstream, setUpstream] = useState<UpstreamProbe | null>(null)
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
const [llmStats, setLlmStats] = useState<LLMStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastCheck, setLastCheck] = useState<string>('')
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
// 各信号独立容错:单项失败只降级对应卡片
const [h, r, u, j, f, c, l] = await Promise.allSettled([
fetchHealth(),
api.get<{ status: string }>('/health/ready'),
fetchUpstreamProbe(),
fetchIngestJobs({ limit: 10 }),
fetchIngestFailures(),
fetchDataCompleteness(),
fetchLLMUsageStats() as Promise<LLMStats>,
])
setHealth(h.status === 'fulfilled' ? h.value : null)
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
setUpstream(u.status === 'fulfilled' ? u.value : null)
setRecentJobs(j.status === 'fulfilled' && Array.isArray(j.value) ? j.value : null)
setFailures(f.status === 'fulfilled' ? f.value : [])
setCompleteness(c.status === 'fulfilled' ? c.value : null)
setLlmStats(l.status === 'fulfilled' ? l.value : null)
if (h.status === 'rejected') {
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
}
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
setLoading(false)
}, [])
useEffect(() => {
refresh()
const t = setInterval(refresh, 30_000)
return () => clearInterval(t)
}, [refresh])
const alive = health?.status === 'healthy' || health?.status === 'ok'
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
const missingStatsLeagues = completeness?.leagues.filter(
l => l.matches.finished > 0 && l.stats.rows === 0,
).length ?? 0
const recentFailedJobs = useMemo(
() => (recentJobs ?? []).filter(j => j.status === 'failed').length,
[recentJobs],
)
// ── 「需要关注」聚合:任何一项异常即亮红 ──
//
// 过滤条件必须是「真值」而非 `t !== false`。因为这些短路表达式在条件
// 不成立时返回的**不只是 false**:`upstream && !upstream.ok && {...}`
// 在 upstream 为 null 时整条求值为 null,而 `null !== false` 为 true,
// 于是 null 会穿过过滤器,渲染时 `t.to` 直接抛错并让整页崩进 ErrorBoundary。
const todos = (
[
!alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' },
ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' },
upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' },
recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs}`, to: '/admin/collection' },
deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount}`, to: '/admin/data-pipeline' },
missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' },
] as Array<TodoItem | false | null | undefined>
).filter((t): t is TodoItem => Boolean(t))
const okJobs = (recentJobs ?? []).filter(j => j.status === 'success').length
const runJobs = (recentJobs ?? []).filter(j => j.status === 'success' || j.status === 'failed').length
const lastJobTime = recentJobs?.[0]?.created_at
return (
<div className="space-y-6">
<SectionHeader
title="系统监控"
description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
action={
<div className="flex items-center gap-2">
<span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
<Button size="sm" onClick={refresh} disabled={loading}>
{loading ? (<><Spinner /> </>) : '立即巡检'}
</Button>
</div>
}
/>
{error && (
<Alert kind="error" title="无法连接到后端" message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`} />
)}
{/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
{!loading && todos.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{todos.map(t => (
<Link
key={t.key}
to={t.to}
className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
>
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-press" aria-hidden="true" />
<span className="text-xs text-ink-800">{t.label}</span>
<span className="ml-auto text-press" aria-hidden="true"></span>
</Link>
))}
</div>
) : (
!loading && (
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
各项巡检正常:服务
</p>
)
)}
{/* ── 基础设施 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="存活状态">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} aria-hidden="true" />
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
{health ? (alive ? '正常' : health.status) : '—'}
</span>
</div>
</MetricCard>
<MetricCard label="数据库就绪">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`} aria-hidden="true" />
<span className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}>
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
</span>
</div>
</MetricCard>
<MetricCard label="版本 / 运行时间">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{health?.version ?? '—'}
</div>
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
{health?.uptime_seconds != null
? `已运行 ${Math.floor(health.uptime_seconds / 3600)}h ${Math.floor((health.uptime_seconds % 3600) / 60)}m`
: '—'}
</div>
</MetricCard>
<MetricCard label="上游 bzzoiro">
{upstream ? (
<div>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
<span className={`font-serif text-xl font-bold ${upstream.ok ? 'text-ink-900' : 'text-press'}`}>
{upstream.ok ? '可达' : '不可达'}
</span>
</div>
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
{upstream.ok
? `HTTP ${upstream.status_code} · ${upstream.latency_ms}ms`
: upstream.error?.slice(0, 40) || `HTTP ${upstream.status_code}`}
</div>
</div>
) : (
<span className="font-serif text-xl text-ink-300"></span>
)}
</MetricCard>
</div>
</section>
{/* ── 数据管线 ── */}
<section>
<h2 className="section-head mb-3">线</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="最近采集">
{recentJobs && runJobs > 0 ? (
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500"></span>
</div>
) : (
<div className="font-serif text-xl text-ink-300"></div>
)}
<div className="mt-0.5 text-2xs text-ink-400">
{lastJobTime ? `最近 ${new Date(lastJobTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}` : '暂无记录'}
</div>
</MetricCard>
<MetricCard label="死信待处理">
<div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
{deadLetterCount}
</div>
<div className="mt-0.5 text-2xs text-ink-400">,</div>
</MetricCard>
<MetricCard label="数据缺口">
<div className={`font-serif text-xl font-bold tabular-nums ${missingStatsLeagues > 0 ? 'text-press' : 'text-ink-900'}`}>
{missingStatsLeagues}
</div>
<div className="mt-0.5 text-2xs text-ink-400"></div>
</MetricCard>
<MetricCard label="统计覆盖">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{completeness ? `${completeness.totals.stats_coverage_pct}%` : '—'}
</div>
<div className="mt-0.5 text-2xs text-ink-400"> / </div>
</MetricCard>
</div>
</section>
{/* ── LLM ── */}
<section>
<h2 className="section-head mb-3">LLM </h2>
<div className="grid gap-4 sm:grid-cols-3">
<MetricCard label="预测总数">
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{llmStats ? llmStats.total_predictions : '—'}
</div>
</MetricCard>
<MetricCard label={llmStats?.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
{llmStats?.avg_latency_ms != null
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
: '—'}
</div>
</MetricCard>
<MetricCard label="有效率">
<div className={`font-serif text-xl font-bold tabular-nums ${llmStats && llmStats.success_rate < 80 ? 'text-warn-700' : 'text-ink-900'}`}>
{llmStats ? `${llmStats.success_rate.toFixed(0)}%` : '—'}
</div>
</MetricCard>
</div>
</section>
</div>
)
}
@@ -0,0 +1,311 @@
/**
* Admin 后台 - 预测历史页面(报刊风)
*
* 功能:
* - 查看预测记录列表(支持状态筛选)
* - 实际比分自动从比赛赛果填充
* - 赛后一键结算,供准确率评估
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchPredictions, settlePrediction } from '../../api/dal'
import type { Prediction } from '../../api/types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
import { Button } from '../../components/ui'
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtDate(s?: string | null): string {
if (!s) return '—'
const d = new Date(s)
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
}
export default function PredictionHistoryPage() {
const [predictions, setPredictions] = useState<Prediction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [settlingId, setSettlingId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await fetchPredictions(200)
setPredictions(list)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleSettle = async (p: Prediction) => {
const match = p.match
if (!match || match.home_goals == null || match.away_goals == null) return
setSettlingId(p.id)
try {
await settlePrediction(p.id, match.home_goals, match.away_goals)
await load()
} catch (err) {
console.error('结算失败:', err)
} finally {
setSettlingId(null)
}
}
const filtered = predictions.filter(p => {
if (filter === 'settled') return p.settled
if (filter === 'unsettled') return !p.settled
return true
})
const unsettledCount = predictions.filter(p => !p.settled).length
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
const hitCount = settledWithScore.filter(p => {
const actual = p.actual_home_goals! > p.actual_away_goals! ? '1' : p.actual_home_goals! < p.actual_away_goals! ? '2' : 'X'
return actual === p.pred_1x2
}).length
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
return (
<div className="space-y-6">
<SectionHeader
title="预测历史"
description={`${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
/>
{/* 统计概览 */}
{settledWithScore.length > 0 && (
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-press">{accuracy}%</p>
<p className="text-xs text-ink-500">1X2 </p>
</CardBody>
</Card>
</div>
)}
{/* 筛选 */}
<div className="flex gap-2">
{([
{ v: 'all', label: '全部' },
{ v: 'unsettled', label: '待结算' },
{ v: 'settled', label: '已结算' },
] as const).map(opt => (
<Button
key={opt.v}
onClick={() => setFilter(opt.v)}
variant={filter === opt.v ? 'solid' : 'default'}
size="sm"
>
{opt.label}
</Button>
))}
</div>
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{error && (
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
)}
{!loading && !error && filtered.length === 0 && (
<div className="empty-state">
<p className="empty-state-title"></p>
<p className="empty-state-sub"></p>
</div>
)}
{/* 预测列表 */}
{!loading && !error && filtered.length > 0 && (
<Card>
<CardBody className="px-0 sm:px-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium">1X2</th>
<th className="px-4 py-2 font-medium"></th>
<th className="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{filtered.map(p => {
const match = p.match
const matchDate = match?.match_date
const homeName = match?.home_team_zh || match?.home_team || '?'
const awayName = match?.away_team_zh || match?.away_team || '?'
const actualHome = match?.home_goals
const actualAway = match?.away_goals
const hasActual = actualHome != null && actualAway != null
const isExpanded = expandedId === p.id
const predHit = p.settled && hasActual
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
: null
return (
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 text-xs text-ink-800">
<TeamSideTag side="home" />
<span className="truncate max-w-[100px]">{homeName}</span>
</div>
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
<TeamSideTag side="away" />
<span className="truncate max-w-[100px]">{awayName}</span>
</div>
</td>
<td className="px-4 py-3 font-serif font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</td>
<td className="px-4 py-3">
{hasActual ? (
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
{actualHome} : {actualAway}
</span>
) : (
<span className="text-2xs text-ink-300"></span>
)}
</td>
<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-ok-300 text-ok-700 bg-ok-50' :
predHit === false ? 'border-press text-press bg-press-wash' :
'border-ink-200 text-ink-600'
}`}>
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
{predHit === true && ' ✓'}
{predHit === false && ' ✗'}
</span>
) : '—'}
</td>
<td className="px-4 py-3">
{p.settled ? (
<Badge status="success"></Badge>
) : hasActual ? (
<Badge status="info"></Badge>
) : (
<Badge status="warning"></Badge>
)}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{!p.settled && hasActual && (
<Button variant="solid" size="sm" onClick={() => handleSettle(p)} disabled={settlingId === p.id}>
{settlingId === p.id ? <Spinner /> : '结算'}
</Button>
)}
<Button size="sm" onClick={() => setExpandedId(isExpanded ? null : p.id)}>
{isExpanded ? '收起' : '详情'}
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardBody>
</Card>
)}
{/* 展开详情 */}
{expandedId && (() => {
const p = predictions.find(pr => pr.id === expandedId)
if (!p) return null
const match = p.match
const reports = p.agent_outputs ?? []
return (
<Card>
<CardHeader title="预测详情" />
<CardBody className="space-y-4">
<div className="flex items-center gap-2 text-sm text-ink-700">
<span>{fmtDate(match?.match_date)}</span>
<span className="text-ink-300">|</span>
<TeamSideTag side="home" />
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
<span className="text-ink-400">vs</span>
<TeamSideTag side="away" />
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.pred_home_goals != null && p.pred_away_goals != null
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{match?.home_goals != null && match?.away_goals != null
? `${match.home_goals} : ${match.away_goals}` : '—'}
</p>
</div>
<div className="border-t-2 border-ink-900 pt-3">
<p className="text-2xs text-ink-400"></p>
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</p>
</div>
</div>
{p.reasoning && (
<div>
<h4 className="section-head mb-2"></h4>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
</blockquote>
</div>
)}
{reports.length > 0 && (
<div>
<h4 className="section-head mb-2"></h4>
<div className="space-y-2">
{reports.map((r) => (
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
</div>
))}
</div>
</div>
)}
</CardBody>
</Card>
)
})()}
</div>
)
}
+533
View File
@@ -0,0 +1,533 @@
/**
* Admin 后台 - 统一设置页(报刊风)
*
* 合并原「数据源」「LLM 配置」「系统配置」三页:
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
* 3. 认证 — 修改密码
* 4. 系统 — 配置查看 + 修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
fetchKeyRingStatus, resetKeyRingCooldown,
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
} from '../../api/dal'
import type { ScheduleItem } from '../../api/dal'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../../api/api'
import type { LLMUsageStats, DataSourceSetting } from '../../api/types'
import type { KeyRingStatusResponse } from '../../api/dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
import { Button, Input } from '../../components/ui'
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
const TABS = [
{ id: 'datasource', label: '数据源' },
{ id: 'llm', label: '大语言模型' },
{ id: 'schedules', label: '定时任务' },
{ id: 'security', label: '登录认证' },
] as const
type TabId = (typeof TABS)[number]['id']
export default function SettingsPage() {
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
const [searchParams, setSearchParams] = useSearchParams()
const rawTab = searchParams.get('tab')
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
const setTab = (id: TabId) =>
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
// LLM
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
const [llmLoading, setLlmLoading] = useState(true)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// Key Ring
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
const [ringLoading, setRingLoading] = useState(false)
// 密码
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
const [currentPwd, setCurrentPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [confirmPwd, setConfirmPwd] = useState('')
const [pwdBusy, setPwdBusy] = useState(false)
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
// 定时任务
const [schedules, setSchedules] = useState<ScheduleItem[]>([])
const [schedulesLoading, setSchedulesLoading] = useState(true)
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(null)
// ── 数据加载 ──
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setAllSettings(all)
} catch {
setAllSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
const loadLlmStats = useCallback(async () => {
setLlmLoading(true)
try {
setLlmStats(await fetchLLMUsageStats())
} catch {
setLlmStats(null)
} finally {
setLlmLoading(false)
}
}, [])
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
setKeyRing(null)
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSettings()
loadLlmStats()
loadKeyRing()
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
}, [loadSettings, loadLlmStats, loadKeyRing])
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
// ── 操作 ──
const handleSave = async (key: string, value: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
if (key === 'BZZOIRO_KEY') await loadKeyRing()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
const handleClear = async (key: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
// ── 定时任务操作 ──
const handleToggleSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await updateSchedule(s.id, { enabled: !s.enabled })
setScheduleNotice({ ok: true, text: `${!s.enabled ? '启用' : '禁用'}任务「${s.id}` })
setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x))
} catch {
setScheduleNotice({ ok: false, text: '操作失败' })
} finally {
setScheduleBusyId(null)
}
}
const handleRunSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await runScheduleNow(s.id)
setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` })
} catch {
setScheduleNotice({ ok: false, text: '启动失败' })
} finally {
setScheduleBusyId(null)
}
}
const handleDeleteSchedule = async (s: ScheduleItem) => {
setScheduleBusyId(s.id)
setScheduleNotice(null)
try {
await deleteSchedule(s.id)
setScheduleNotice({ ok: true, text: `已删除任务「${s.id}` })
setSchedules(prev => prev.filter(x => x.id !== s.id))
} catch {
setScheduleNotice({ ok: false, text: '删除失败' })
} finally {
setScheduleBusyId(null)
}
}
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
await testLLMConnection()
setTestResult({ success: true, message: 'LLM 连接测试成功' })
} catch (err: unknown) {
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
} finally {
setTesting(false)
}
}
const handleResetCooldown = async () => {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
setRowNotice({ key: "__ring", ok: true, text: res.message })
} catch (err) {
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
}
}
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setPwdNotice(null)
if (newPwd !== confirmPwd) {
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
return
}
setPwdBusy(true)
try {
const res = await changePassword(currentPwd, newPwd)
setPwdNotice({ ok: true, text: res.message })
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
} catch (err) {
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
} finally {
setPwdBusy(false)
}
}
return (
<div className="space-y-8">
<SectionHeader
title="系统设置"
description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
/>
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
{TABS.map(t => (
<button
key={t.id}
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
tab === t.id
? 'border-press font-bold text-press'
: 'border-transparent text-ink-500 hover:text-ink-900'
}`}
>
{t.label}
</button>
))}
</div>
{/* ── 1. 数据源 ── */}
{tab === 'datasource' && (
<section>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="Bzzoiro API"
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> </> : '刷新'}</Button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
dataSourceSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
</CardBody>
</Card>
{/* Key Ring */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
action={<Button variant="outline" size="sm" onClick={handleResetCooldown} disabled={ringLoading}></Button>}
/>
<CardBody>
{keyRing && keyRing.total > 0 ? (
<div className="space-y-2">
{keyRing.keys.map((k, i) => {
const isBlocked = k.blocked_remaining > 0
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-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-warn-700' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-ink-400"> key </p>
)}
</CardBody>
</Card>
</div>
</section>
)}
{/* ── 2. LLM ── */}
{tab === 'llm' && (
<section>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="连接配置"
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> </> : '刷新'}</Button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
{testResult && (
<div className="mt-4">
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
</div>
)}
<Button size="sm" className="mt-4 w-full" onClick={handleTest} disabled={testing}>
{testing ? <><Spinner /> </> : '测试 LLM 连接'}
</Button>
<p className="mt-2 text-center text-2xs text-ink-400"> LLM ,</p>
</CardBody>
</Card>
<Card>
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<Button size="sm" onClick={loadLlmStats} disabled={llmLoading}>{llmLoading ? <><Spinner /> </> : '刷新'}</Button>} />
<CardBody>
{llmLoading ? (
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
) : llmStats ? (
<div className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-ink-900 pt-3 text-center">
{/* 延迟统计后端未接入 → 明确显示「未接入」而非留白,
免得被误读成「延迟为 0,性能极好」 */}
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{llmStats.avg_latency_ms != null
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400">
{llmStats.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}
</div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
) : (
<p className="py-6 text-center text-xs text-ink-400">使</p>
)}
</CardBody>
</Card>
</div>
<div className="mt-6">
<AgentLLMCard />
</div>
</section>
)}
{/* ── 3. 认证 ── */}
{tab === 'security' && (
<section>
<Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody>
{passwordOrigin && (
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
:
{passwordOrigin === 'db' ? <Badge status="success">(scrypt )</Badge>
: passwordOrigin === 'env' ? <Badge status="info">.env </Badge>
: <Badge status="error"></Badge>}
</p>
)}
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label htmlFor="pwd-current" className="mb-1 block text-2xs text-ink-500"></label>
<Input id="pwd-current" type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="w-full" />
</div>
<div>
<label htmlFor="pwd-new" className="mb-1 block text-2xs text-ink-500">( 8 )</label>
<Input id="pwd-new" type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="w-full" />
</div>
<div>
<label htmlFor="pwd-confirm" className="mb-1 block text-2xs text-ink-500"></label>
<Input id="pwd-confirm" type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="w-full" />
</div>
</div>
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">退</p>
<Button variant="solid" size="sm" className="flex-shrink-0" type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}>
{pwdBusy ? <><Spinner /> </> : '修改密码'}
</Button>
</div>
</form>
</CardBody>
</Card>
</section>
)}
{/* ── 4. 定时任务 ── */}
{tab === 'schedules' && (
<section>
<Card>
<CardHeader
title="采集调度"
description="配置 cron 表达式定时触发采集任务"
action={
<Button size="sm" onClick={() => { createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) .then(() => fetchSchedules().then(setSchedules)) }}>
+
</Button>
}
/>
<CardBody>
{scheduleNotice && (
<div className="mb-3">
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
</div>
)}
{schedulesLoading ? (
<SkeletonBlock className="h-10 w-full" />
) : schedules.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">,+ </p>
) : (
<div className="space-y-2">
{schedules.map(s => (
<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-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>
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
{s.last_run_at && (
<p className="text-2xs text-ink-400">
: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => handleRunSchedule(s)} disabled={scheduleBusyId === s.id}>
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
</Button>
<Button
onClick={() => handleToggleSchedule(s)}
disabled={scheduleBusyId === s.id}
variant={s.enabled ? 'default' : 'solid'}
size="sm"
>
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeleteSchedule(s)} disabled={scheduleBusyId === s.id}>
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
</Button>
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
</section>
)}
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Admin 后台 - 路由配置
*
* 所有 Admin 页面的路由定义,使用嵌套路由。
* 挂载路径: /admin/*
*
* 全部页面采用 `lazy` 动态导入。
*
* 此前这里是 10 个静态 import:App.tsx 引入 adminRoutes 就把
* Dashboard/Collection/Logs/Eval/Backtest/Settings/Monitoring/
* DataCompleteness/DataPipeline/PredictionHistory 连同它们的
* 数据访问层一次性拉进首屏 —— 一个只想看赛程的匿名访客,
* 要为完整的后台界面付出流量。改为 lazy 后,后台代码只在实际
* 访问 /admin/* 时才下载。
*
* AdminLayout 保持静态引入:它是所有后台页面的公共外壳,
* 若也 lazy 则会与页面 chunk 形成串行请求(先下载 layout,
* 再下载页面),反而更慢。
*/
import { lazy, Suspense } from 'react'
import type { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
import AdminLayout from './AdminLayout'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const CollectionPage = lazy(() => import('./pages/Collection'))
const DataCompletenessPage = lazy(() => import('./pages/DataCompleteness'))
const DataPipelinePage = lazy(() => import('./pages/DataPipeline'))
const PredictionsPage = lazy(() => import('./pages/PredictionHistory'))
const BacktestPage = lazy(() => import('./pages/Backtest'))
const MonitoringPage = lazy(() => import('./pages/Monitoring'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const LogsPage = lazy(() => import('./pages/Logs'))
const EvalPage = lazy(() => import('./pages/EvalPage'))
/**
* 页面级懒加载的兜底:后台各页共用。
* 保持极简,避免后台切换时出现大块占位跳动。
*/
function AdminPageFallback() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<span className="text-xs text-ink-400" role="status" aria-live="polite">
</span>
</div>
)
}
/** 用 Suspense 包住单个后台页面 */
function page(node: ReactNode): ReactNode {
return <Suspense fallback={<AdminPageFallback />}>{node}</Suspense>
}
export const adminRoutes = [
{
path: '/admin',
element: <AdminLayout />,
children: [
{ index: true, element: page(<Dashboard />) },
{ path: 'collection', element: page(<CollectionPage />) },
{ path: 'data-completeness', element: page(<DataCompletenessPage />) },
{ path: 'data-pipeline', element: page(<DataPipelinePage />) },
{ path: 'predictions', element: page(<PredictionsPage />) },
{ path: 'backtest', element: page(<BacktestPage />) },
{ path: 'monitoring', element: page(<MonitoringPage />) },
{ path: 'settings', element: page(<SettingsPage />) },
{ path: 'logs', element: page(<LogsPage />) },
{ path: 'eval', element: page(<EvalPage />) },
{ path: '*', element: <Navigate to="/admin" replace /> },
],
},
]
export { adminRoutes as default }
+5
View File
@@ -0,0 +1,5 @@
/**
* 【兼容壳 · 已废弃】实现已迁至 `src/api/types.ts`。
* 新代码请直接从 `../api/types` 导入。
*/
export * from '../api/types'
+157
View File
@@ -0,0 +1,157 @@
/**
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
*
* 提供全局快捷键:
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
* / — 聚焦搜索(日志页)
* r — 刷新当前页面数据(通用)
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Modal } from '../components/ui'
interface CommandItem {
id: string
label: string
group: string
action: () => void
}
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const navigate = useNavigate()
const items: CommandItem[] = pages.map(p => ({
id: p.to,
label: p.label,
group: p.group,
action: () => { navigate(p.to); setOpen(false) },
}))
const filtered = query
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
: items
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd/Ctrl+K → 命令面板
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(o => !o)
setQuery('')
}
// Escape → 关闭
if (e.key === 'Escape' && open) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
return { open, setOpen, query, setQuery, items: filtered }
}
/** 命令面板弹窗 */
export function CommandPalette({
open,
query,
setQuery,
items,
onClose,
}: {
open: boolean
query: string
setQuery: (q: string) => void
items: CommandItem[]
onClose: () => void
}) {
const [selected, setSelected] = useState(0)
// 重置选中项当列表变化
useEffect(() => { setSelected(0) }, [items.length])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, items, selected, onClose])
if (!open) return null
// 按分组聚合
const grouped: Record<string, CommandItem[]> = {}
for (const item of items) {
grouped[item.group] = grouped[item.group] || []
grouped[item.group].push(item)
}
return (
<Modal
open={open}
onClose={onClose}
label="命令面板"
animate={false}
overlayClassName="z-[60] pt-[15vh]"
panelClassName="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
>
{/* 面板内点击不冒泡到遮罩(Modal 已做 target 判断,此处仅阻止穿透) */}
<div onClick={e => e.stopPropagation()}>
{/* 搜索框 */}
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
<span className="text-ink-400"></span>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="输入页面名或路径…"
/* 搜索框无可见标签,用 aria-label 提供可访问名称 */
aria-label="搜索页面"
autoFocus
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
/>
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
</div>
{/* 结果列表 */}
<div className="max-h-[50vh] overflow-y-auto py-2">
{items.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
Object.entries(grouped).map(([group, groupItems]) => (
<div key={group}>
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
{groupItems.map(item => (
<button
key={item.id}
onClick={() => { item.action(); onClose() }}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
}`}
>
<span className="truncate">{item.label}</span>
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
</button>
))}
</div>
))
)}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
<span> </span>
<span> </span>
<span>ESC </span>
</div>
</div>
</Modal>
)
}
+66
View File
@@ -0,0 +1,66 @@
/**
* 应用统一 API 客户端(门面)—— 2026-09 自 admin/api.ts 迁入。
*
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
* 同源请求自动携带 Cookie,无需手动管理密钥。
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
*
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
*/
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
export { ApiError, UNAUTHORIZED_EVENT }
const API_BASE = '/api/v1'
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
type ApiOpts = {
timeoutMs?: number
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
skipAuthHandling?: boolean
}
export const api = {
get: <T>(path: string) => http.get<T>(path),
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.post<T>(path, body, opts),
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
http.put<T>(path, body, opts),
delete: <T>(path: string) => http.delete<T>(path),
}
// ── 认证 ────────────────────────────────────────────────────────
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
export function login(password: string): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/login`, { password })
}
/** 退出登录,清除会话 Cookie */
export function logout(): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/logout`)
}
/** 探测当前登录状态 */
export function fetchAuthState(): Promise<{
authenticated: boolean
enabled: boolean
password_origin?: 'db' | 'env' | 'none'
}> {
return api.get(`${API_BASE}/auth/me`)
}
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
return api.post(
`${API_BASE}/auth/change-password`,
{ current_password: currentPassword, new_password: newPassword },
{ skipAuthHandling: true },
)
}
export { API_BASE }
+481
View File
@@ -0,0 +1,481 @@
/**
* 应用数据访问层 —— 需要鉴权的管理端点集合。
*
* 封装所有 API 端点调用,返回类型安全的数据,
* 所有端点对齐 FastAPI 后端实际实现。
*
* 归属说明:2026-09 从 `admin/dal.ts` 迁入 `src/api/`。
* 公开(免登录)端点在 `./public.ts`;admin/ 下仅留兼容壳。
*/
import { api, API_BASE } from './api'
import type {
DashboardStats,
CollectionRequest,
BacktestRequest,
BacktestSummary,
League,
Match,
Prediction,
EvalSummary,
DataSourceStatus,
DataSourceSetting,
DataSourceTestResult,
LLMAgentConfig,
LogEntry,
IngestSourceStatus,
IngestJob,
AdminStats,
LLMUsageStats,
IngestTriggerResult,
PredictionJobRef,
LLMPingResult,
SettleResult,
HealthProbe,
} from './types'
// ── 仪表盘 ──────────────────────────────────────────────────────
/**
* 从多个端点聚合仪表盘数据。
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
*
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
*/
export async function fetchDashboard(): Promise<DashboardStats> {
// 并行获取各端点数据
// predictions 返回数组
const [leagues, predictions, health] = await Promise.allSettled([
api.get<League[]>(`${API_BASE}/leagues`),
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
api.get<{ status: string }>('/health'),
])
return {
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
// predictions 直接返回数组
total_predictions: predictions.status === 'fulfilled' ? predictions.value?.length ?? 0 : 0,
health: health.status === 'fulfilled' ? health.value.status : 'unknown',
// 以下三项目前无对应后端端点。返回 null 而非空数组:
// 空数组会被读成「查过了,没有数据」,而事实是「还没接」。
db_tables: null,
last_collection: null,
recent_errors: null,
}
}
// ── 数据采集 ────────────────────────────────────────────────────
export async function triggerCollection(req: CollectionRequest): Promise<IngestTriggerResult> {
// 后端 IngestBzzoiroRequest 的字段全部为可选,用 Partial 显式表达
// 「未选中的筛选项不发」,而不是靠 `Record<string, any>` 蒙混。
const body: Partial<CollectionRequest> & { task: string; limit: number } = {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: req.status || undefined,
task: req.task || 'events',
limit: req.limit || 100,
season: req.season || undefined,
}
return api.post<IngestTriggerResult>(`${API_BASE}/ingest/bzzoiro`, body)
}
// ── 预测管理 ────────────────────────────────────────────────────
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<PredictionJobRef> {
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
match_id: req.match_id,
mode: req.mode || 'multi',
})
}
export async function fetchPredictions(limit = 50): Promise<Prediction[]> {
const res = await api.get<Prediction[] | { items?: Prediction[] }>(
`${API_BASE}/predictions?limit=${limit}`,
)
// 后端声明为 list[PredictionOut];保留 items 分支兼容网关包装层。
if (Array.isArray(res)) return res
return res?.items ?? []
}
// ── 评估 & 回测 ─────────────────────────────────────────────────
export async function fetchEvalSummary(params: {
limit?: number
provider?: string
model?: string
prompt_version?: string
mode?: string
league_code?: string
} = {}): Promise<EvalSummary | null> {
const sp = new URLSearchParams()
if (params.limit) sp.set('limit', String(params.limit))
if (params.provider) sp.set('provider', params.provider)
if (params.model) sp.set('model', params.model)
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
if (params.mode) sp.set('mode', params.mode)
if (params.league_code) sp.set('league_code', params.league_code)
try {
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
} catch {
return null
}
}
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
}
// ── 辅助数据 ────────────────────────────────────────────────────
export async function fetchLeagues(): Promise<League[]> {
try {
return await api.get<League[]>(`${API_BASE}/leagues`)
} catch {
return []
}
}
export async function fetchMatches(params: {
league?: string
status?: string
limit?: number
cursor?: string
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
const sp = new URLSearchParams()
if (params.league) sp.set('league', params.league)
if (params.status) sp.set('status', params.status)
if (params.limit) sp.set('limit', String(params.limit))
if (params.cursor) sp.set('cursor', params.cursor)
try {
return await api.get<{ items: Match[]; has_next: boolean; next_cursor: string | null }>(
`${API_BASE}/matches?${sp}`,
)
} catch {
return { items: [], has_next: false, next_cursor: null }
}
}
export async function settlePrediction(
prediction_id: number,
home_goals: number,
away_goals: number,
): Promise<SettleResult> {
return api.post<SettleResult>(`${API_BASE}/eval/settle`, {
prediction_id,
home_goals,
away_goals,
})
}
// ── 健康检查 ────────────────────────────────────────────────────
export async function fetchHealth(): Promise<HealthProbe> {
try {
return await api.get<HealthProbe>('/health')
} catch {
return { status: 'unknown' }
}
}
// ── 数据完整性 ──────────────────────────────────────────────────
export interface DataCompletenessResponse {
generated_at: string
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
issues: string[]
leagues: Array<{
code: string
name: string
country?: string
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
stats: {
rows: number
fields: Record<string, { count: number; pct: number }>
}
standings: { rows: number; latest_retrieved?: string }
}>
}
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
}
// ── 积分榜 & 比赛详情 ───────────────────────────────────────────
//
// 这几者与它们的类型已迁到 `src/api/public.ts` —— 它们是**无需登录**
// 的公开端点,却是前台页面的数据来源。留在这里会让公开页反向依赖
// admin 层,并把 41 个后台端点一起拖进用户首屏的 chunk。
//
// 此处保留再导出,使既有 `from './dal'` 的调用路径继续可用;
// 新代码请直接从 `src/api/public` 导入。
export { fetchStandings, fetchMatchDetail, fetchMatchContext } from '../api/public'
export type { StandingRow, StandingsLeague } from '../api/public'
// ── 数据源管理 ──────────────────────────────────────────────────
/**
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
*/
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
}
/**
* 获取数据源状态与配置(脱敏)
*/
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
}
/**
* 探测当前 LLM 服务可用模型(只读,不产生费用)
*/
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
return api.get(`${API_BASE}/admin/llm/models`)
}
/**
* 各专家/终裁的独立 LLM 配置状态
*/
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
}
/**
* 查询系统日志(内存缓冲,最新在前)
*/
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
const sp = new URLSearchParams()
if (params.level) sp.set('level', params.level)
if (params.keyword) sp.set('keyword', params.keyword)
if (params.limit) sp.set('limit', String(params.limit))
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
}
/**
* 全部可配置项(脱敏),供各配置页渲染
*/
export function fetchSettings(): Promise<DataSourceSetting[]> {
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
}
/**
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
*/
export function updateSetting(key: string, value: string) {
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
}
/**
* 清除配置项的 DB 覆盖值,回落 .env
*/
export function clearSetting(key: string) {
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
}
// ── LLM 配置 ────────────────────────────────────────────────────
/**
* 测试 LLM 连接 — 调用预测端点验证
*/
export async function testLLMConnection(matchId?: number): Promise<LLMPingResult | PredictionJobRef> {
// F4 修复: 优先使用不依赖比赛的 ping 端点
try {
return await api.post<LLMPingResult>(`${API_BASE}/admin/llm/ping`, {})
} catch {
// 回退到旧方式(兼容)
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
match_id: matchId || 1,
mode: 'single',
})
}
}
/**
* 获取 LLM 使用统计 — 从预测列表聚合。
*
* 注意:延迟统计是**后端尚未提供的维度**。此前这里返回硬编码的
* `avg_latency_ms: 2400`,它会穿过类型检查、被渲染成「2.4s」,
* 并作为真实性能指标进入人的判断。现已改为 `null`,由 UI 显示
* 「—」并标注未接入。宁可留空,不可编数。
*/
export async function fetchLLMUsageStats(): Promise<LLMUsageStats> {
try {
const predictions = await fetchPredictions(50)
const total = predictions.length
const successCount = predictions.filter(p => p.pred_1x2).length
return {
total_predictions: total,
avg_latency_ms: null, // 后端暂无延迟统计端点
success_rate: total > 0 ? (successCount / total) * 100 : 0,
recent_predictions: predictions.slice(0, 10).map(p => ({
id: p.id,
match_id: p.match_id,
model: p.model,
created_at: p.created_at,
status: p.pred_1x2 ? 'success' as const : 'failed' as const,
})),
}
} catch {
return {
total_predictions: 0,
avg_latency_ms: null,
success_rate: 0,
recent_predictions: [],
}
}
}
/**
* 数据源健康/最近采集状态(只读,不触发采集)
*/
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
}
/**
* 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额
*/
export function fetchUpstreamProbe(): Promise<{
ok: boolean
status_code?: number
latency_ms: number
endpoint: string
error?: string
}> {
return api.get<never>(`${API_BASE}/admin/monitoring/upstream`)
}
/**
* 采集任务状态轮询(单任务)
*/
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
}
/**
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
*/
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
const q = new URLSearchParams()
if (params.limit != null) q.set('limit', String(params.limit))
if (params.status) q.set('status', params.status)
const qs = q.toString()
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
}
/**
* 管理区统计(只读):近 24h/7d 预测次数
*/
export function fetchAdminStats(): Promise<AdminStats> {
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
}
// ── API Key 轮换环 ──────────────────────────────────────────────
export interface KeyRingKeyStatus {
masked: string
blocked_remaining: number
}
export interface KeyRingStatusResponse {
base_url: string
total: number
has_multiple: boolean
cooldown_seconds: number
active_index: number
active_key: string | null
keys: KeyRingKeyStatus[]
}
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
}
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
}
// ── 定时任务 ────────────────────────────────────────────────────
export interface ScheduleItem {
id: string
task: string
cron: string
leagues?: string
enabled: boolean
last_run_at?: string | null
last_status?: string | null
}
export async function fetchSchedules(): Promise<ScheduleItem[]> {
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
}
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
}
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
}
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
}
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
}
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
export interface IngestFailureItem {
id: number
source: string
entity_type: string
source_record_id?: string
error_type: string
error_detail?: string
retry_count: number
status: string
next_retry_at?: string | null
created_at?: string
}
export interface DataQualityCheckItem {
id: number
check_name: string
entity_type: string
passed: boolean
severity: string
detail?: Record<string, unknown> | null
checked_at?: string
}
export interface DataQualityResponse {
failures: IngestFailureItem[]
checks: DataQualityCheckItem[]
}
export async function fetchDataQuality(): Promise<DataQualityResponse> {
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
}
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
}
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
}
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 前台公开端点 —— 预测主站与积分榜所需的最小数据访问。
*
* 为什么要单独成文件(而不是继续挂在 `admin/dal.ts` 里):
*
* 1. **分层方向错了。** 面向公众的页面反向 import `../admin/dal`,
* 意味着想重构后台就会牵连前台,目录名也持续误导人以为
* `admin/` 是可独立删除的包。公开端点的归属本就不该是 admin。
*
* 2. **它真的会进用户的首屏。** `dal.ts` 是单个大模块,内部函数
* 相互引用,打包器无法按调用点做 tree-shaking —— 实测
* Matches 只用 2 个函数,但 `/admin/ingest/jobs`、`/admin/llm/models`、
* `/admin/eval`、`/admin/backtest`、`/admin/logs` 这些字符串全部
* 留在了公开页所在 chunk 里。抽出来后可省下约 55 kB 首屏 JS。
*
* 本文件只放**无需登录即可访问**的端点。任何需要鉴权的端点继续留在
* `./dal.ts`。新增公开端点时请加在这里,不要加回 admin。
*/
import { api, API_BASE } from './api'
import type { MatchDetailOut, MatchContextOut } from './types'
// ── 积分榜 ──────────────────────────────────────────────────────
export interface StandingRow {
position: number
team: string
team_en: string
played: number
won: number
drawn: number
lost: number
goals_for: number
goals_against: number
goal_diff: number
points: number
xg_for: number | null
xg_against: number | null
form: string | null
zone: string | null
}
export interface StandingsLeague {
league_code: string
league_name: string
season: string
retrieved_at: string | null
rows: StandingRow[]
}
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
const sp = new URLSearchParams()
if (league) sp.set('league', league)
if (season) sp.set('season', season)
const qs = sp.toString()
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
}
// ── 比赛详情 ────────────────────────────────────────────────────
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
}
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
}
+503
View File
@@ -0,0 +1,503 @@
/**
* 应用级 API 类型定义(2026-09 自 admin/types.ts 迁入)。
*
* 与 FastAPI 后端 Pydantic 模型对齐。
*/
// ── 系统健康 ────────────────────────────────────────────────────
export interface HealthStatus {
status: 'ok' | 'degraded' | 'error'
version?: string
uptime_seconds?: number
checks: Record<string, 'pass' | 'fail' | 'warn'>
}
// ── 仪表盘 ──────────────────────────────────────────────────────
export interface DashboardStats {
leagues: League[]
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
// 不要再用列表 items.length 近似(上限 100 会严重失真)
total_predictions: number
health: string
/**
* 以下三个字段对应的后端端点在**当前版本中并不存在**。
*
* 它们曾以 `[]` 返回,调用方无法区分「确实没有错误」与
* 「这个功能还没接」——空数组是一个会被当成结论的事实断言。
* 改为可空,让「未接入」在类型层面无法被忽略。
* 后端补齐端点后,把类型收窄回数组即可(编译器会指出所有消费点)。
*/
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] | null
last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[] | null
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[] | null
}
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
export interface League {
id?: number
code: string
name: string
name_zh?: string
country?: string
}
export interface Match {
id: number
league_code?: string
season?: string | null
home_team: string
away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date: string
match_status: string
home_goals?: number | null
away_goals?: number | null
match_stage?: string | null
}
// ── 预测 ────────────────────────────────────────────────────────
/** 列表接口返回的单路专家摘要字段 */
export interface PredictionAgentOutput {
agent: string
status: string
analysis?: string | null
probable_score?: string | null
subjective_confidence?: number | null
}
export interface Prediction {
id: number
match_id: number
provider: string
model: string
prompt_version?: string
mode?: string
pred_home_goals?: number | null
pred_away_goals?: number | null
alt_pred_home_goals?: number | null
alt_pred_away_goals?: number | null
pred_1x2?: string | null
subjective_confidence?: number | null
reasoning?: string | null
agent_outputs?: PredictionAgentOutput[] | null
agent_weights?: Record<string, number> | null
status?: 'success' | 'failed' | 'degraded'
created_at: string
actual_home_goals?: number | null
actual_away_goals?: number | null
settled?: boolean
/**
* 关联比赛摘要。后端 `PredictionOut.match` 是 `dict | None`
* (见 `src/api/schemas.py`),由 `_match_dict()` 拼出,字段与 `Match`
* 对齐但**每个字段都可能为 null**(比赛缺 league/team 时)。
*
* 此前这个字段在类型里根本不存在,消费方只能写 `(p as any).match`
* ——类型系统失去了它唯一该起作用的地方。现在它被显式声明为可选。
*/
match?: PredictionMatchRef | null
}
/**
* 预测记录里内嵌的比赛摘要。
*
* 独立于 `Match` 声明而不是直接复用,是因为二者契约不同:
* `Match` 的 `id`/`match_date` 是必填的(来自列表端点),
* 而这里是嵌套投影,后端 `_match_dict()` 在关联缺失时返回 null,
* 字段也可能缺失。混用会让必填约束说谎。
*/
export interface PredictionMatchRef {
id?: number | null
league_code?: string | null
season?: string | null
home_team?: string | null
away_team?: string | null
home_team_zh?: string | null
away_team_zh?: string | null
match_date?: string | null
match_status?: string | null
home_goals?: number | null
away_goals?: number | null
match_stage?: string | null
}
export interface PredictRequest {
match_id: number
mode?: 'single' | 'multi'
provider?: string
model?: string
}
// ── 通用接口返回壳 ──────────────────────────────────────────────
//
// 这些类型此前在 dal.ts 里以 `Promise<any>` 的形式存在,等于把后端契约
// 丢掉了。声明在这里(与其它 API 类型同处)而不是 dal.ts 内联,是为了
// 让消费方能直接 import type 而无需从实现文件取类型。
/** POST /api/v1/ingest/bzzoiro — 采集任务已受理,返回 job_id 供轮询 */
export interface IngestTriggerResult {
ok?: boolean
job_id?: string
message?: string
}
/** POST /api/v1/predict — 预测任务已受理(P1-async)。注意是 202 语义,非最终结果 */
export interface PredictionJobRef {
job_id: string
status: 'running' | 'success' | 'failed'
poll_url?: string
/** job 完成后的信封:`_predict_jobs[id]` 里是 { status, result } 或 { status, error } */
result?: Prediction
error?: string
}
/** POST /api/v1/admin/llm/ping — 连通性探测 */
export interface LLMPingResult {
ok?: boolean
provider?: string
model?: string
latency_ms?: number
message?: string
/** ping 端点尚未接入时后端可能只回一个透传对象 */
[key: string]: unknown
}
/** POST /api/v1/eval/settle — 结算结果 */
export interface SettleResult {
ok?: boolean
settled?: number
message?: string
}
/**
* `GET /health` 存活探针的实际返回。
*
* 与文件开头那个 `HealthStatus` 不是同一个契约 —— 那个是 admin/stats
* 聚合口径(status 为 ok|degraded|error,带 checks 明细),这个才是
* `src/api/app.py` 里 `/health` 直接吐出的结构。此前两者在 dal.ts 里
* 共用同一个 `any`,差异被彻底抹平。
*
* `version` / `uptime_seconds` 可空:后端在包元数据缺失时返回 null,
* 监控页据此降级隐藏版本卡片。
*/
export interface HealthProbe {
status: 'healthy' | 'ok' | 'unknown' | string
service?: string
version?: string | null
uptime_seconds?: number | null
}
// ── 数据采集 ────────────────────────────────────────────────────
export interface CollectionRequest {
status?: string
source: 'bzzoiro'
leagues?: string[]
task?: 'events' | 'standings' | 'stats' | 'all'
limit?: number
season?: string
date_from?: string
date_to?: string
}
// ── 评估 & 回测 ─────────────────────────────────────────────────
export interface EvalCalibrationBucket {
total: number
/** 该桶命中率,百分数;样本不足为 null */
hit_rate: number | null
}
export interface EvalSummaryRow {
provider: string
model: string
prompt_version: string | null
total: number
/** 1X2 准确率,百分数 0-100 */
accuracy_1x2?: number
avg_score_rmse?: number | null
avg_subjective_confidence?: number | null
/** 置信度校准:按主观置信度分桶的命中率 */
calibration?: Record<string, EvalCalibrationBucket>
}
export interface EvalSummary {
summary: Array<EvalSummaryRow>
/** 全量已结算数 */
total_settled: number
/** 应用筛选后的已结算数 */
filtered_settled: number
/** 实际评估条数(status=success 且比分齐全) */
evaluated: number
/** 跳过的 degraded 条数 */
skipped_degraded: number
/** 跳过的比分不全条数 */
skipped_incomplete?: number
}
export interface BacktestRequest {
league_id?: number
date_from?: string
date_to?: string
mode?: 'single' | 'multi'
limit?: number
model?: string
}
export interface BacktestSummary {
total: number
scored: number
success: number
degraded: number
accuracy_1x2?: number
avg_score_rmse?: number
avg_subjective_confidence?: number
}
// ── 数据源配置 ──────────────────────────────────────────────────
export interface DataSourceSetting {
key: string
label: string
description: string
sensitive: boolean
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface DataSourceStatus {
name: string
label: string
description: string
key_configured: boolean
last_ingestion: string | null
settings: DataSourceSetting[]
}
export interface DataSourceTestResult {
ok: boolean
status: number | null
latency_ms: number
detail: string
}
export interface DataSourceTestRequest {
source: 'bzzoiro' | 'understat' | 'injuries'
}
export interface IngestionHistoryEntry {
id: string
source: string
started_at: string
finished_at: string | null
status: 'success' | 'running' | 'failed'
records_count: number | null
error_message: string | null
}
// ── LLM 配置 ────────────────────────────────────────────────────
export interface LLMConfig {
provider: string
model: string
base_url: string
api_key_configured: boolean
api_key_masked: string
}
export interface LLMUsageStats {
total_predictions: number
/**
* 平均延迟(毫秒)。**后端当前没有延迟统计端点**,故此处为 `null`。
*
* 曾经这里是一个硬编码的 2400,并且会被渲染成看起来完全可信的
* 「2.4s」。运营据此判断系统性能时,读到的是一个不存在的数字。
* 改为 `null` 是刻意的:它让「未接入」这件事在类型层面强制可见,
* 调用方必须显式处理,而不是继承一个编造的默认值。
*/
avg_latency_ms: number | null
success_rate: number
recent_predictions: Array<{
id: number
match_id: number
model: string
created_at: string
latency_ms?: number
status: 'success' | 'failed'
}>
}
// ── 系统配置 ────────────────────────────────────────────────────
export interface SystemConfigEntry {
key: string
value_masked: string
description: string
is_sensitive: boolean
}
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
export interface LLMAgentFieldState {
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface LLMAgentConfig {
id: string
label: string
effective_model: string
fields: {
model: LLMAgentFieldState
base_url: LLMAgentFieldState
api_key: LLMAgentFieldState
}
}
// ── 系统日志 ─────────────────────────────────────────────────────
export interface LogEntry {
ts: number
level: string
logger: string
message: string
}
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
export interface IngestLastFailure {
at: string
logger: string
detail: string
note: string
}
export interface IngestSourceStatus {
name: string
label: string
key_configured: boolean
base_url?: string
reachable: boolean | null
status?: 'key_not_configured' | 'no_data' | 'has_data'
last_success_at: string | null
latest_match_date?: string | null
recent_count: number
note: string
last_failure: IngestLastFailure | null
}
// ── 采集任务状态 ──────────────────────────────────────────────
export interface IngestJob {
id: string
task: string
params: Record<string, unknown>
status: 'pending' | 'running' | 'success' | 'failed'
result: Record<string, unknown> | null
error: string | null
created_at: string | null
started_at: string | null
finished_at: string | null
}
// ── 比赛详情 ─────────────────────────────────────────────────────
export interface MatchRecentPrediction {
id: number
provider: string
model: string
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
alt_pred_home_goals: number | null
alt_pred_away_goals: number | null
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
status: string
settled: boolean
correct_1x2?: boolean
created_at: string
actual_home_goals: number | null
actual_away_goals: number | null
/**
* 单路专家原始输出。后端 `agent_outputs` 是 `list[dict]`(未定 schema),
* 各专家的键随 agent 不同。这里用 `Record<string, unknown>` 而非 `any`:
* 取值处必须显式收窄,编译器不再放行任意属性访问。
*/
agent_outputs?: Array<Record<string, unknown>> | null
agent_weights?: Record<string, number> | null
}
export interface MatchDetailOut {
id: number
league_code: string | null
season: string | null
home_team: string
away_team: string
home_team_zh: string | null
away_team_zh: string | null
match_date: string
match_status: string
home_goals: number | null
away_goals: number | null
match_stage: string | null
home_xg: number | null
away_xg: number | null
stats: MatchStatsDetail | null
recent_predictions: MatchRecentPrediction[]
}
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
export interface MatchStatsDetail {
home_xg: number | null
away_xg: number | null
home_shots: number | null
away_shots: number | null
home_shots_on_target: number | null
away_shots_on_target: number | null
home_corners: number | null
away_corners: number | null
home_possession: number | null
home_yellow_cards: number | null
away_yellow_cards: number | null
home_red_cards: number | null
away_red_cards: number | null
home_big_chances: number | null
away_big_chances: number | null
home_fouls: number | null
away_fouls: number | null
}
export interface TeamRecentMatch {
match_date: string | null
home_team: string | null
away_team: string | null
home_goals: number | null
away_goals: number | null
}
export interface MatchContextOut {
home_recent: TeamRecentMatch[]
away_recent: TeamRecentMatch[]
h2h: TeamRecentMatch[]
}
// ── 管理区统计 ─────────────────────────────────────────────────
export interface AdminStats {
predictions: {
total: number
last_24h: number
last_7d: number
}
matches?: { total: number; finished: number }
stats?: { total: number }
standings?: { total: number }
}
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
/**
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
*
* 滚动监听已抽到 `lib/useScroll` 的 useWindowScrollY —— 全站三处
* 滚动监听此前各自实现一遍生命周期,现统一为单一来源。
*/
import { useWindowScrollY } from '../lib/useScroll'
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
const scrollY = useWindowScrollY()
const show = scrollY > 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>
)
}
+24 -10
View File
@@ -1,8 +1,17 @@
import { Component, ErrorInfo, ReactNode } from 'react' import { Component, ErrorInfo, ReactNode } from 'react'
import { Button } from './ui'
interface Props { interface Props {
children: ReactNode children: ReactNode
fallback?: ReactNode fallback?: ReactNode
/**
* 该边界是否铺满整个视口。
*
* 应用最外层的边界应铺满(`true`,默认);而当边界下沉到
* 路由级、嵌在 SiteLayout 的 <main> 里时,铺满视口会撑开
* 布局并让页头页脚错位 —— 那种场景传 `false`,改为局部卡片。
*/
fullScreen?: boolean
} }
interface State { interface State {
@@ -29,20 +38,25 @@ export class ErrorBoundary extends Component<Props, State> {
if (this.props.fallback) { if (this.props.fallback) {
return this.props.fallback return this.props.fallback
} }
const fullScreen = this.props.fullScreen ?? true
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6"> <div
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3"> className={
<div className="text-4xl"></div> fullScreen
<h2 className="text-lg font-semibold text-gray-800"></h2> ? 'flex min-h-screen items-center justify-center bg-paper-50 p-6'
<p className="text-sm text-gray-500"> : 'flex min-h-[50vh] items-center justify-center p-6'
}
role="alert"
>
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
<h2 className="font-serif text-lg font-bold text-ink-900"></h2>
<p className="text-sm leading-relaxed text-ink-500">
{this.state.error?.message || '未知错误'} {this.state.error?.message || '未知错误'}
</p> </p>
<button <Button size="sm" onClick={() => this.setState({ hasError: false, error: null })}>
onClick={() => this.setState({ hasError: false, error: null })}
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
>
</button> </Button>
</div> </div>
</div> </div>
) )
+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>
)
}
+47
View File
@@ -0,0 +1,47 @@
/**
* SiteLayout —— 前台公开站点的共享外壳(报头 + 内容区 + 页脚)。
*
* 背景:此前 `HomePage` 与 `StandingsLayout` 各自复制了一遍
* `<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">`
* 和 `<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">`。
* 两处 className 逐字相同,唯一差异是 Masthead 的 active 值与
* 页脚文案。改一次容器内边距要改两处,加第三个页面就是第三处。
*
* 这里把「容器宽度 / 内边距 / 页脚分隔线样式」收进单一来源,
* 调用方只需提供 active(决定报头高亮)与页脚文案。
*/
import type { ReactNode } from 'react'
import Masthead from './Masthead'
import type { MastheadActive } from './Masthead'
/** 报头的 active 分区,决定报刊头里哪个分区链接高亮 */
export type SiteSection = MastheadActive
export interface SiteLayoutProps {
/** 当前分区,用于报头高亮 */
active: SiteSection
/** 页脚文案(各分区声明差异,如首页含免责声明) */
footerNote: ReactNode
children: ReactNode
}
export function SiteLayout({ active, footerNote, children }: SiteLayoutProps) {
return (
<div className="min-h-screen bg-paper-50">
<Masthead active={active} />
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
{children}
</main>
<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">
{footerNote}
</div>
</footer>
</div>
)
}
export default SiteLayout
+26
View File
@@ -0,0 +1,26 @@
/**
* 主客队标志:报刊风小方框字。
*
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
* 用法: <TeamSideTag side="home" /> 队名
*/
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
return (
<span
aria-label={side === 'home' ? '主队' : '客队'}
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
side === 'home'
? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-300 text-ink-400'
}`}
>
{side === 'home' ? '主' : '客'}
</span>
)
}
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
export function teamSidePrefix(side: 'home' | 'away'): string {
return side === 'home' ? '[主] ' : '[客] '
}
@@ -0,0 +1,67 @@
/**
* AnimatedBadge: 带脉冲状态点的小标签。
*
* 灵感来自 beui.dev AnimatedBadge,适配 Profeto 报纸风:
* - 状态点脉冲动画(loading) + 静态色点(其他状态)
* - 三种尺寸(sm/md/lg) + 七种语义(neutral/info/loading/success/warning/danger)
* - 用于 ingest_jobs / predictions / data_sources 等状态展示
*/
import { cn } from './utils'
export type AnimatedBadgeStatus = 'neutral' | 'info' | 'loading' | 'success' | 'warning' | 'danger'
export type AnimatedBadgeSize = 'sm' | 'md' | 'lg'
const STATUS_DOT: Record<AnimatedBadgeStatus, string> = {
neutral: 'bg-ink-400',
info: 'bg-sky-500',
loading: 'bg-sky-500',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-rose-500',
}
const STATUS_LABEL: Record<AnimatedBadgeStatus, string> = {
neutral: '未开始',
info: '信息',
loading: '进行中',
success: '成功',
warning: '警告',
danger: '失败',
}
const SIZE_CLASS: Record<AnimatedBadgeSize, string> = {
sm: 'gap-1.5 px-2 py-0.5 text-2xs',
md: 'gap-2 px-2.5 py-1 text-xs',
lg: 'gap-2.5 px-3 py-1 text-sm',
}
export interface AnimatedBadgeProps {
status: AnimatedBadgeStatus
size?: AnimatedBadgeSize
children?: React.ReactNode
className?: string
}
export function AnimatedBadge({ status, size = 'md', children, className }: AnimatedBadgeProps) {
const dot = STATUS_DOT[status]
const label = children ?? STATUS_LABEL[status]
return (
<span
className={cn(
'inline-flex items-center justify-center rounded-full border border-ink-200 bg-paper-50 font-medium text-ink-700',
SIZE_CLASS[size],
className,
)}
>
{/* 状态点:loading 脉冲动画,其他静态 */}
<span className="relative flex h-2 w-2">
{status === 'loading' && (
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', dot)} />
)}
<span className={cn('relative inline-flex h-2 w-2 rounded-full', dot)} />
</span>
{label}
</span>
)
}
+39
View File
@@ -0,0 +1,39 @@
/**
* 状态标记 Badge。
*
* 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性。
* 原位于 admin/components.tsx。
*/
import type { ReactNode } from 'react'
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
warning: { text: 'text-press', mark: 'border border-press' },
failed: { text: 'text-press font-medium', mark: 'bg-press' },
error: { text: 'text-press font-medium', mark: 'bg-press' },
loss: { text: 'text-press font-medium', mark: 'bg-press' },
}
export function Badge({
status,
children,
}: {
status: string
children: ReactNode
}) {
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
return (
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
{children}
</span>
)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 卡片族:Card / CardHeader / CardBody。
*
* 报刊风:方角、墨线描边、无阴影。原位于 admin/components.tsx,
* 合并到 components/ui 作为全站共享组件。
*/
import type { ReactNode } from 'react'
export function Card({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return (
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
)
}
export function CardHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
{action}
</div>
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
</div>
)
}
export function CardBody({
children,
className = '',
}: {
children: ReactNode
className?: string
}) {
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 数据表格族:DataTable / MobileCardList / ResponsiveTable。
* 原位于 admin/components.tsx。
*
* ResponsiveTable 按断点二选一渲染:桌面表格 / 移动卡片。
*/
import type { ReactNode } from 'react'
import { EmptyState } from './Feedback'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function DataTable<T = any>({
columns,
data,
rowKey,
emptyText = '暂无数据',
}: {
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
data: T[]
rowKey: (row: T) => string | number
emptyText?: string
}) {
if (data.length === 0) {
return <EmptyState text={emptyText} />
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
{columns.map(col => (
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => (
<tr
key={rowKey(row)}
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
>
{columns.map(col => (
<td key={col.key} className="px-3 py-2.5 text-ink-800">
{col.render
? col.render(row)
: row != null && typeof row === 'object' && col.key in row
? String((row as Record<string, unknown>)[col.key] ?? '—')
: '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MobileCardList<T = any>({
data,
renderCard,
emptyText = '暂无数据',
}: {
data: T[]
renderCard: (row: T, index: number) => ReactNode
emptyText?: string
}) {
if (data.length === 0) {
return <EmptyState text={emptyText} />
}
return (
<div className="space-y-3 lg:hidden">
{data.map((row, idx) => (
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
{renderCard(row, idx)}
</div>
))}
</div>
)
}
// ── 响应式表格容器 ──────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function ResponsiveTable<T = any>({
columns,
data,
rowKey,
cardRender,
emptyText = '暂无数据',
}: {
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
data: T[]
rowKey: (row: T) => string | number
cardRender: (row: T, index: number) => ReactNode
emptyText?: string
}) {
if (data.length === 0) {
return <EmptyState text={emptyText} />
}
return (
<>
{/* 桌面端表格 */}
<div className="hidden overflow-x-auto lg:block">
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
</div>
{/* 移动端卡片 */}
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
</>
)
}
+111
View File
@@ -0,0 +1,111 @@
/**
* EventList: 事件/活动流 —— 时间线样式的事件列表。
*
* 灵感来自 beautifului EventList,适配 Profeto 报纸风:
* - 左侧时间轴细线
* - 事件类型图标圆点(彩色)
* - 时间戳 + 摘要 + 详情
* - 支持展开/折叠详情
*/
import { useState } from 'react'
export interface EventItem {
id: string
type: 'success' | 'error' | 'warning' | 'info' | 'pending'
title: string
timestamp: string
detail?: string
actor?: string
}
const TYPE_DOT: Record<string, string> = {
success: 'bg-emerald-500',
error: 'bg-rose-500',
warning: 'bg-amber-500',
info: 'bg-sky-500',
pending: 'bg-ink-300',
}
const TYPE_LABEL: Record<string, string> = {
success: '成功',
error: '失败',
warning: '警告',
info: '信息',
pending: '进行中',
}
export function EventList({ events, maxInitial = 10 }: { events: EventItem[]; maxInitial?: number }) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [showAll, setShowAll] = useState(false)
const visible = showAll ? events : events.slice(0, maxInitial)
const hasMore = events.length > maxInitial && !showAll
const toggle = (id: string) => setExpanded(s => ({ ...s, [id]: !s[id] }))
if (events.length === 0) {
return <div className="py-8 text-center text-xs text-ink-400"></div>
}
return (
<div className="relative">
{/* 时间轴细线 */}
<div className="absolute bottom-2 left-3 top-2 w-px bg-ink-200" aria-hidden="true" />
<ul className="space-y-0">
{visible.map((ev) => {
const isOpen = expanded[ev.id]
return (
<li key={ev.id} className="relative pl-8 pr-3">
{/* 圆点 */}
<span
className={`absolute left-1.5 top-3 inline-block h-3 w-3 rounded-full border-2 border-paper-50 ${TYPE_DOT[ev.type]}`}
aria-hidden="true"
/>
<button
type="button"
onClick={() => ev.detail && toggle(ev.id)}
className={`w-full border-b border-ink-100 py-2.5 text-left transition-colors hover:bg-paper-100 ${
ev.detail ? 'cursor-pointer' : 'cursor-default'
}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-ink-800">{ev.title}</span>
<span className="flex-shrink-0 text-2xs tabular-nums text-ink-400">{ev.timestamp}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-ink-500">
<span className={`rounded px-1 py-0.5 text-2xs font-medium ${
ev.type === 'success' ? 'bg-emerald-50 text-emerald-700' :
ev.type === 'error' ? 'bg-rose-50 text-rose-700' :
ev.type === 'warning' ? 'bg-amber-50 text-amber-700' :
ev.type === 'pending' ? 'bg-ink-100 text-ink-500' :
'bg-sky-50 text-sky-700'
}`}>
{TYPE_LABEL[ev.type]}
</span>
{ev.actor && <span>· {ev.actor}</span>}
</div>
{ev.detail && isOpen && (
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{ev.detail}
</p>
)}
</button>
</li>
)
})}
</ul>
{hasMore && (
<div className="mt-2 pl-8">
<button
onClick={() => setShowAll(true)}
className="text-xs text-ink-500 underline-offset-2 hover:text-ink-900 hover:underline"
>
{events.length}
</button>
</div>
)}
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
/**
* 反馈类组件:Alert / ErrorBanner / describeError / EmptyState / EmptyText。
* 原位于 admin/components.tsx。
*
* 依赖说明:`ApiError` 直接从 `lib/http` 导入(而非 `admin/api` 的转发),
* 使本组件不依赖 admin 目录 —— 这正是合并两套组件库要解决的问题。
*/
import type { ReactNode } from 'react'
import { ApiError } from '../../lib/http'
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
export function Alert({
kind,
title,
message,
onClose,
action,
}: {
kind: 'error' | 'ok' | 'info' | 'warning'
title: string
message?: string
onClose?: () => void
/** 右侧操作按钮(如「去修复」) */
action?: ReactNode
}) {
const style =
kind === 'error'
? 'border-press bg-press-wash'
: kind === 'warning'
? 'border-press bg-press-wash/60'
: kind === 'ok'
? 'border-ink-900 bg-paper-100'
: 'border-ink-300 bg-paper-50'
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
/*
无障碍:提示条是异步出现的(请求失败、操作完成),视觉用户
看到了,屏幕阅读器用户此前什么都收不到 —— 因为它只是一个
普通的 <div>,出现时不会触发任何朗读。
这里按语义分级:
error / warning → role="alert" (assertive,立即打断朗读)
ok / info → role="status" (polite,等当前朗读结束)
这也是 WAI-ARIA 对 live region 的推荐用法。
*/
const isUrgent = kind === 'error' || kind === 'warning'
return (
<div
role={isUrgent ? 'alert' : 'status'}
className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}
>
<div>
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
<span
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
aria-hidden="true"
/>
{title}
</p>
{message && (
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{message}
</p>
)}
</div>
<div className="flex items-center gap-2">
{action}
{onClose && (
<button
onClick={onClose}
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">
<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>
</button>
)}
</div>
</div>
)
}
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
if (err instanceof ApiError) {
const status = err.status
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
? String((err.data as { detail: unknown }).detail)
: ''
const msg = apiDetail || err.message
switch (status) {
case 401:
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
case 403:
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
case 429:
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
case 502:
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
case 503:
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
case 0:
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
}
if (status >= 500) {
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
}
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
}
if (err instanceof Error) {
return { title: '操作失败', detail: err.message, kind: 'error' }
}
return { title: '未知错误', detail: String(err), kind: 'error' }
}
/** 统一错误横幅:用于页面级错误展示。 */
export function ErrorBanner({
err,
onClose,
}: {
err: unknown
onClose?: () => void
}) {
const { title, detail, kind } = describeError(err)
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
}
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
return (
<div className="border-y border-ink-200 py-12 text-center">
<p className="font-serif text-sm text-ink-600">{text}</p>
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
</div>
)
}
/** 空状态文本(极简版) */
export function EmptyText({ text }: { text: string }) {
return (
<div className="py-10 text-center text-sm text-ink-400">
{text}
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
/**
* FilterTable: 带列筛选 + 排序的增强表格。
*
* 灵感来自 beautifului FilterTable,适配 Profeto 报纸风:
* - 列头点击排序(升/降/无)
* - 文本列支持输入筛选
* - 状态列支持下拉筛选
* - 与现有 DataTable 风格统一(细线/留白/衬线)
*/
import { useMemo, useState } from 'react'
import { EmptyState } from './Feedback'
export interface FilterColumn<T> {
key: string
label: string
width?: string
align?: 'left' | 'center' | 'right'
sortable?: boolean
filterable?: boolean
filterType?: 'text' | 'select'
filterOptions?: { value: string; label: string }[]
render?: (row: T) => React.ReactNode
}
type SortDir = 'asc' | 'desc' | null
export function FilterTable<T extends Record<string, any>>({
columns,
data,
rowKey,
emptyText = '暂无数据',
initialSort,
}: {
columns: FilterColumn<T>[]
data: T[]
rowKey: (row: T) => string | number
emptyText?: string
initialSort?: { key: string; dir: 'asc' | 'desc' }
}) {
const [sortKey, setSortKey] = useState<string | null>(initialSort?.key ?? null)
const [sortDir, setSortDir] = useState<SortDir>(initialSort?.dir ?? null)
const [filters, setFilters] = useState<Record<string, string>>({})
const handleSort = (key: string) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc')
if (sortDir === 'desc') setSortKey(null)
} else {
setSortKey(key)
setSortDir('asc')
}
}
const handleFilter = (key: string, value: string) => {
setFilters(f => (value ? { ...f, [key]: value } : { ...f, [key]: '' }))
// 筛选变化时重置排序,避免混淆
setSortKey(null)
setSortDir(null)
}
const filtered = useMemo(() => {
let rows = data
for (const col of columns) {
const fv = filters[col.key]
if (!fv) continue
rows = rows.filter(r => {
const v = r[col.key]
if (v == null) return false
return String(v).toLowerCase().includes(fv.toLowerCase())
})
}
return rows
}, [data, columns, filters])
const sorted = useMemo(() => {
if (!sortKey || !sortDir) return filtered
const sorted = [...filtered].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
if (av == null && bv == null) return 0
if (av == null) return 1
if (bv == null) return -1
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return sorted
}, [filtered, sortKey, sortDir, columns])
if (data.length === 0) {
return <EmptyState text={emptyText} />
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
{columns.map(col => (
<th
key={col.key}
className="select-none px-3 py-2 font-medium"
style={{ width: col.width, textAlign: col.align ?? 'left' }}
>
<div className="flex items-center gap-1">
{col.sortable && (
<button
onClick={() => handleSort(col.key)}
className={`flex items-center gap-0.5 hover:text-ink-900 ${
sortKey === col.key ? 'text-ink-900' : ''
}`}
title="排序"
>
{col.label}
<span className="text-[10px]">
{sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕'}
</span>
</button>
)}
{!col.sortable && col.label}
</div>
</th>
))}
</tr>
{/* 筛选行 */}
{columns.some(c => c.filterable) && (
<tr className="border-b border-ink-200 bg-paper-100/50">
{columns.map(col => (
<th key={col.key} className="px-2 py-1.5">
{col.filterable && col.filterType === 'select' && col.filterOptions ? (
<select
value={filters[col.key] ?? ''}
onChange={e => handleFilter(col.key, e.target.value)}
className="w-full border border-ink-200 bg-paper-50 px-1 py-0.5 text-xs text-ink-700"
>
<option value=""></option>
{col.filterOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : col.filterable ? (
<input
type="text"
value={filters[col.key] ?? ''}
onChange={e => handleFilter(col.key, e.target.value)}
placeholder="筛选…"
className="w-full border border-ink-200 bg-paper-50 px-2 py-0.5 text-xs text-ink-700 placeholder:text-ink-300"
/>
) : null}
</th>
))}
</tr>
)}
</thead>
<tbody>
{sorted.map(row => (
<tr
key={rowKey(row)}
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
>
{columns.map(col => (
<td
key={col.key}
className="px-3 py-2.5 text-ink-800"
style={{ textAlign: col.align ?? 'left' }}
>
{col.render
? col.render(row)
: row[col.key] != null
? String(row[col.key])
: '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
{sorted.length === 0 && filtered.length > 0 && (
<div className="py-6 text-center text-xs text-ink-400"></div>
)}
</div>
)
}
@@ -0,0 +1,84 @@
/**
* InsightCard: 指标洞察卡片 —— 数值 + 趋势 + 对比。
*
* 灵感来自 beautifului InsightCard / StatCard,适配 Profeto 报纸风:
* - 大字号主指标(衬线)
* - 趋势箭头(↑↓) + 变化率
* - 可选对比基线(如"较上周 +12%")
* - 状态色(info/warning/positive/negative)
*/
import { TrendingArrow } from './TrendingArrow'
export interface InsightCardProps {
label: string
value: string | number
/** 趋势方向: up/down/flat */
trend?: 'up' | 'down' | 'flat'
/** 变化率文案,如 "+12%" */
trendLabel?: string
/** 对比说明,如 "较昨日" */
compareHint?: string
/** 状态色 */
status?: 'info' | 'warning' | 'positive' | 'negative'
/** 副标题 / 补充说明 */
subtitle?: string
/** 图标(ReactNode) */
icon?: React.ReactNode
}
const STATUS_STYLE: Record<string, { card: string; accent: string }> = {
info: { card: 'border-ink-200', accent: 'text-ink-600' },
warning: { card: 'border-amber-300', accent: 'text-amber-700' },
positive: { card: 'border-emerald-300', accent: 'text-emerald-700' },
negative: { card: 'border-rose-300', accent: 'text-rose-700' },
}
const TREND_COLOR: Record<string, string> = {
up: 'text-emerald-600',
down: 'text-rose-600',
flat: 'text-ink-400',
}
export function InsightCard({ label, value, trend, trendLabel, compareHint, status = 'info', subtitle, icon }: InsightCardProps) {
const style = STATUS_STYLE[status]
return (
<article className={`border bg-paper-50 px-4 py-4 shadow-sm transition-shadow hover:shadow-md ${style.card}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium uppercase tracking-wider text-ink-500">{label}</p>
<p className={`mt-1 font-serif text-2xl font-bold leading-none sm:text-3xl ${style.accent}`}>
{value}
</p>
{subtitle && (
<p className="mt-1 truncate text-xs text-ink-400">{subtitle}</p>
)}
</div>
{icon && (
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded ${style.accent} bg-paper-100`}>
{icon}
</div>
)}
</div>
{(trend || trendLabel) && (
<div className="mt-3 flex items-center gap-1.5 text-xs">
{trend && <TrendingArrow dir={trend} className="h-3 w-3" />}
{trendLabel && (
<span className={`font-medium ${TREND_COLOR[trend ?? 'flat']}`}>{trendLabel}</span>
)}
{compareHint && <span className="text-ink-400">{compareHint}</span>}
</div>
)}
</article>
)
}
/** InsightCard 网格容器 */
export function InsightGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{children}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
/**
* LoaderGrid: 网格骨架占位 —— 卡片/面板加载态。
*
* 灵感来自 beautifului LoaderGrid,适配 Profeto 报纸风:
* - 网格布局(响应式)
* - 脉冲动画占位块
* - 可配置列数/行数
*/
function shimmer() {
return 'animate-pulse bg-paper-100'
}
/** 单张骨架卡片 */
function SkeletonCard({ lines = 3 }: { lines?: number }) {
return (
<div className="border border-ink-200 bg-paper-50 p-4">
<div className="flex items-center gap-3">
<div className={`h-8 w-8 rounded ${shimmer()}`} />
<div className="flex-1 space-y-1.5">
<div className={`h-3 w-2/3 rounded ${shimmer()}`} />
<div className={`h-2.5 w-1/3 rounded ${shimmer()}`} />
</div>
</div>
<div className="mt-4 space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div key={i} className={`h-3 rounded ${shimmer()}`} style={{ width: `${70 + Math.random() * 25}%` }} />
))}
</div>
<div className="mt-4 flex items-center justify-between">
<div className={`h-2.5 w-16 rounded ${shimmer()}`} />
<div className={`h-2.5 w-12 rounded ${shimmer()}`} />
</div>
</div>
)
}
/** 单行骨架(表格行) */
function SkeletonRow({ cols = 4 }: { cols?: number }) {
return (
<tr className="border-b border-ink-200">
{Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-3 py-3">
<div className={`h-3 rounded ${shimmer()}`} style={{ width: i === 0 ? '60%' : '80%' }} />
</td>
))}
</tr>
)
}
export interface LoaderGridProps {
/** 模式: card=卡片网格 / row=表格行 */
mode?: 'card' | 'row'
/** 显示数量 */
count?: number
/** 列数(card 模式)或列数(row 模式) */
cols?: number
/** 卡片模式行数 */
cardLines?: number
}
export function LoaderGrid({ mode = 'card', count = 4, cols = 4, cardLines = 3 }: LoaderGridProps) {
if (mode === 'card') {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<SkeletonCard key={i} lines={cardLines} />
))}
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-ink-900">
{Array.from({ length: cols }).map((_, i) => (
<th key={i} className="px-3 py-2">
<div className={`h-3 w-12 rounded ${shimmer()}`} />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: count }).map((_, i) => (
<SkeletonRow key={i} cols={cols} />
))}
</tbody>
</table>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
/**
* SearchList: 即时搜索列表 —— 输入框 + 过滤结果 + 选中态。
*
* 灵感来自 beautifului SearchList,适配 Profeto 报纸风:
* - 顶部搜索输入(放大镜图标)
* - 即时过滤(前端本地匹配)
* - 选中高亮 + hover 态
* - 空态提示
*/
import { useMemo, useState } from 'react'
export interface SearchListProps<T> {
items: T[]
/** 抽取用于搜索的文本 */
searchText: (item: T) => string
/** 抽取唯一 key */
itemKey: (item: T) => string
/** 渲染每行 */
renderItem: (item: T, active: boolean) => React.ReactNode
/** 点击回调 */
onSelect?: (item: T) => void
/** 当前选中 key */
activeKey?: string
placeholder?: string
emptyText?: string
maxHeight?: string
}
export function SearchList<T>({
items,
searchText,
itemKey,
renderItem,
onSelect,
activeKey,
placeholder = '搜索…',
emptyText = '无匹配项',
maxHeight = '360px',
}: SearchListProps<T>) {
const [query, setQuery] = useState('')
const filtered = useMemo(() => {
if (!query.trim()) return items
const q = query.trim().toLowerCase()
return items.filter(it => searchText(it).toLowerCase().includes(q))
}, [items, query, searchText])
return (
<div className="flex flex-col">
{/* 搜索框 */}
<div className="sticky top-0 border-b border-ink-200 bg-paper-50 px-3 py-2">
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
>
<circle cx="8.5" cy="8.5" r="5" stroke="currentColor" strokeWidth="1.5" />
<path d="M12.5 12.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full border border-ink-200 bg-paper-50 py-1.5 pl-8 pr-3 text-sm text-ink-800 placeholder:text-ink-300 focus:border-ink-400 focus:outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-ink-400 hover:text-ink-700"
aria-label="清除"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 12 12" fill="none">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
)}
</div>
</div>
{/* 结果列表 */}
<div className="overflow-y-auto" style={{ maxHeight }}>
{filtered.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-ink-400">{emptyText}</div>
) : (
<ul>
{filtered.map(item => {
const key = itemKey(item)
const active = key === activeKey
return (
<li key={key}>
<button
type="button"
onClick={() => onSelect?.(item)}
className={`w-full border-b border-ink-100 px-3 py-2 text-left transition-colors ${
active ? 'bg-press-wash/40' : 'hover:bg-paper-100'
}`}
>
{renderItem(item, active)}
</button>
</li>
)
})}
</ul>
)}
</div>
{/* 结果计数 */}
{filtered.length > 0 && (
<div className="border-t border-ink-200 px-3 py-1.5 text-right text-2xs text-ink-400">
{filtered.length} / {items.length}
</div>
)}
</div>
)
}
@@ -0,0 +1,25 @@
/**
* 小节标题 SectionHeader:同前台 section-head 语言。
* 原位于 admin/components.tsx。
*/
import type { ReactNode } from 'react'
export function SectionHeader({
title,
description,
action,
}: {
title: string
description?: string
action?: ReactNode
}) {
return (
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
</div>
{action}
</div>
)
}
+249
View File
@@ -0,0 +1,249 @@
/**
* Select: 自定义下拉选择器(带展开动画 + 分组 + 图标)。
*
* 灵感来自 beui.dev Select,适配 Profeto 报纸风:
* - 触发器 + 弹出层(Portal) + 选项列表
* - 键盘交互(↑↓/Enter/Esc)
* - 选项分组(SelectGroup)
* - 选中态高亮 + hover 态
* - 支持图标前缀
*/
import { useEffect, useRef, useState } from 'react'
import { cn } from './utils'
export interface SelectOption {
value: string
label: string
icon?: React.ReactNode
disabled?: boolean
}
export interface SelectGroup {
label: string
options: SelectOption[]
}
interface SelectCoreProps {
value: string
onChange: (value: string) => void
options?: SelectOption[]
groups?: SelectGroup[]
placeholder?: string
disabled?: boolean
className?: string
id?: string
}
export function CustomSelect({
value,
onChange,
options = [],
groups = [],
placeholder = '请选择…',
disabled = false,
className,
id,
}: SelectCoreProps) {
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLUListElement>(null)
// 扁平化选项列表(用于键盘导航)
const flat: SelectOption[] = []
const groupLabels: string[] = []
if (groups.length) {
groups.forEach(g => {
groupLabels.push(g.label)
flat.push(...g.options)
})
} else {
flat.push(...options)
}
const selected = flat.find(o => o.value === value)
const selectedLabel = selected?.label ?? placeholder
// 点击外部关闭
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// 高亮项滚动到可视区
useEffect(() => {
if (open && listRef.current) {
const el = listRef.current.children[highlighted] as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}
}, [highlighted, open])
// 键盘交互
const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
if (open) {
const opt = flat[highlighted]
if (opt && !opt.disabled) {
onChange(opt.value)
setOpen(false)
}
} else {
setOpen(true)
setHighlighted(flat.findIndex(o => o.value === value))
}
break
case 'Escape':
setOpen(false)
break
case 'ArrowDown':
e.preventDefault()
if (!open) {
setOpen(true)
} else {
setHighlighted(h => {
let n = (h + 1) % flat.length
while (flat[n]?.disabled) n = (n + 1) % flat.length
return n
})
}
break
case 'ArrowUp':
e.preventDefault()
if (open) {
setHighlighted(h => {
let n = (h - 1 + flat.length) % flat.length
while (flat[n]?.disabled) n = (n - 1 + flat.length) % flat.length
return n
})
}
break
}
}
const handleSelect = (opt: SelectOption) => {
if (opt.disabled) return
onChange(opt.value)
setOpen(false)
}
const renderItems = () => {
if (groups.length > 0) {
let idx = 0
return groups.map((group, gi) => (
<li key={gi}>
<p className="border-b border-ink-100 px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-400">
{group.label}
</p>
<ul>
{group.options.map(opt => {
const i = idx++
const on = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={on}
disabled={opt.disabled}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setHighlighted(i)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
opt.disabled && 'cursor-not-allowed opacity-40',
)}
>
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
<span className="truncate">{opt.label}</span>
{on && (
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
</li>
)
})}
</ul>
</li>
))
}
return options.map((opt, idx) => {
const on = opt.value === value
return (
<li key={opt.value}>
<button
type="button"
role="option"
aria-selected={on}
disabled={opt.disabled}
onClick={() => handleSelect(opt)}
onMouseEnter={() => setHighlighted(idx)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
opt.disabled && 'cursor-not-allowed opacity-40',
)}
>
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
<span className="truncate">{opt.label}</span>
{on && (
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
</li>
)
})
}
return (
<div ref={ref} className={cn('relative', className)} onKeyDown={onKeyDown}>
<button
type="button"
id={id}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => !disabled && setOpen(o => !o)}
className={cn(
'flex w-full items-center justify-between border bg-paper-50 px-3 py-2 text-sm transition-colors',
open ? 'border-ink-900' : 'border-ink-200',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-ink-400',
)}
>
<span className={cn('flex items-center gap-2 truncate', selected ? 'text-ink-800' : 'text-ink-400')}>
{selected?.icon && <span className="flex-shrink-0">{selected.icon}</span>}
{selectedLabel}
</span>
<svg
className={cn('h-4 w-4 flex-shrink-0 text-ink-400 transition-transform', open && 'rotate-180')}
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
>
<path d="M5 8l5 5 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<ul
ref={listRef}
role="listbox"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto border border-ink-900 bg-paper-50 py-1 shadow-md"
>
{renderItems()}
</ul>
)}
</div>
)
}
@@ -0,0 +1,22 @@
/**
* SkeletonRows:骨架占位行(低调脉动灰块)。
*
* 原位于 pages/matches/ui.tsx —— 它是通用骨架,不属于 Matches 页面,
* 上移到 UI 层。单块骨架用 <Skeleton>,整行占位用本组件。
*/
export function SkeletonRows({ n = 4 }: { n?: number }) {
return (
<>
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
<div className="skeleton h-3 w-16" />
<div className="skeleton h-3 flex-1" />
<div className="skeleton h-3 w-10" />
<div className="skeleton h-3 flex-1" />
<div className="skeleton h-3 w-16" />
</div>
))}
</>
)
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 统计与进度可视化:StatCard / ProgressBar / AgentWeightsBar。
* 原位于 admin/components.tsx。
*/
export function StatCard({
label,
value,
hint,
}: {
label: string
value: string | number
hint?: string
}) {
return (
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
{value}
</div>
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
</div>
)
}
/** 进度条:同前台置信度细线 */
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
<div
className="h-full rounded-full bg-press transition-[width] duration-500"
style={{ width: `${clamped}%` }}
/>
</div>
)
}
/** Agent 权重条形图 */
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
const entries = Object.entries(weights).filter(([, w]) => w > 0)
if (entries.length === 0) return null
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
return (
<div className="mt-2 border-t border-ink-200 pt-2">
<div className="mb-1 text-2xs text-ink-400"></div>
<div className="space-y-1">
{entries.map(([k, w], i) => (
<div key={k} className="flex items-center gap-2 text-2xs">
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
<div
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
/>
</div>
<span className="w-12 text-right tabular-nums text-ink-500">
{Math.round((w / total) * 100)}%
</span>
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
</div>
))}
</div>
<div className="mt-1 text-2xs text-ink-400">{okCount}/{entries.length}</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Switch:一组互斥的文字切换(状态 / 模式)。
*
* 原位于 pages/matches/ui.tsx,上移到 UI 层 —— 它用的是全站统一的
* `.tab` / `.tab-on` 语言(方角、印报红下划线),属通用组件。
*
* 注:内部的 <button> 不在 `.btn` 体系内(用 `.tab`),属有意保留的自定义样式。
*/
export 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>
)
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Tabs —— 报刊风版面切换基元。
*
* 背景:`Matches.tsx` 与 `Standings.tsx` 各写了一份高度雷同的联赛切换
* `<button>` + `.tab` / `.tab-on` 类名。两份实现已经在细节上漂移
* (一个有 `disabled` 无数据态与虚线下划线,一个没有),这正是缺少
* 基元的典型症状 —— 复制粘贴会在无人察觉处产生第二个「事实标准」。
*
* 收口后,选择态、`aria-current`、无数据降级三件事只在一处定义。
*/
import { forwardRef } from 'react'
import type { ReactNode } from 'react'
import { cx } from './index'
export interface TabItem {
/** 稳定 key,同时作为标识 */
value: string
/** 显示文本 */
label: ReactNode
/**
* 无可用数据。不阻断点击(用户仍可切过去看空状态),
* 但用虚线下划线弱化提示 —— 而非降字色,浅灰在纸底上对比度不足且形似禁用。
*/
empty?: boolean
/** 原生 title 提示,鼠标悬停时解释 empty 的原因 */
title?: string
}
export interface TabsProps {
items: TabItem[]
value: string
onChange: (value: string) => void
/** 整体禁用(如切换请求进行中) */
disabled?: boolean
/** 无障碍标签,描述这组页签是什么(如「联赛」) */
ariaLabel: string
className?: string
}
/**
* 页签组基元。
*
* 视觉完全复用既有 `.tab` / `.tab-on`,不引入新的设计语言。
*
* 转发 ref 到内部 `<nav>`:调用方需要它来测量横向溢出
* (如「右侧还有更多联赛」的渐变遮罩依赖 `useCanScrollRight`)。
*/
export const Tabs = forwardRef<HTMLElement, TabsProps>(function Tabs(
{ items, value, onChange, disabled = false, ariaLabel, className },
ref,
) {
return (
<nav
ref={ref}
className={cx('flex items-center overflow-x-auto', className)}
aria-label={ariaLabel}
>
{items.map(item => {
const on = value === item.value
return (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
disabled={disabled}
title={item.title}
// aria-current 而非 aria-selected:这是「当前所在版面」的导航语义,
// 不是 tablist/tab 的复合控件模式(那需要 role=tabpanel 配套)。
aria-current={on ? 'true' : undefined}
className={cx('relative tab font-serif', on && 'tab-on', disabled && 'disabled:opacity-50')}
>
{item.label}
{item.empty && (
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 border-b border-dotted border-ink-300"
/>
)}
</button>
)
})}
</nav>
)
})
@@ -0,0 +1,27 @@
/**
* TrendingArrow: 趋势箭头(↑↓→)。
* InsightCard 的依赖子组件。
*/
export function TrendingArrow({ dir, className = '' }: { dir: 'up' | 'down' | 'flat'; className?: string }) {
if (dir === 'flat') {
return (
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
const isUp = dir === 'up'
return (
<svg
className={`${className} ${isUp ? '' : 'rotate-180'}`}
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
>
<path
d="M6 2L10 7H2L6 2Z"
fill="currentColor"
/>
</svg>
)
}

Some files were not shown because too many files have changed in this diff Show More