Compare commits
126
Commits
f50784f9dc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ae8e663a | ||
|
|
49d78136a1 | ||
|
|
63caa6736c | ||
|
|
f563d5cc99 | ||
|
|
7d2eabf750 | ||
|
|
a00364d4a7 | ||
|
|
92f50fa5d4 | ||
|
|
44816794d3 | ||
|
|
f1016b610a | ||
|
|
03727bda00 | ||
|
|
3a56ee17e1 | ||
|
|
7593b99e39 | ||
|
|
e15b554ba3 | ||
|
|
4b0d6ee58a | ||
|
|
45497d2112 | ||
|
|
66f0844798 | ||
|
|
317a5e338a | ||
|
|
7a5c695b89 | ||
|
|
6bdb1f8ae6 | ||
|
|
80616cf459 | ||
|
|
a24017eb69 | ||
|
|
3056a95ef4 | ||
|
|
d9aeff2114 | ||
|
|
17e99a4f9a | ||
|
|
4514ef4e92 | ||
|
|
18c89111d8 | ||
|
|
52d67863d6 | ||
|
|
5c7fdce0a3 | ||
|
|
3a9f3f5a0e | ||
|
|
f6c0145c32 | ||
|
|
b6a251407d | ||
|
|
b5f7e682f0 | ||
|
|
06e973d34d | ||
|
|
50a6753ff8 | ||
|
|
951faf31df | ||
|
|
11da4d3631 | ||
|
|
b9d7b6423e | ||
|
|
9ccda4bf51 | ||
|
|
8c87b9d024 | ||
|
|
f04e0df9e8 | ||
|
|
6a4994097a | ||
|
|
ae89d0f04f | ||
|
|
b6e367a640 | ||
|
|
675e176603 | ||
|
|
0a6af0657a | ||
|
|
a9e85a5d62 | ||
|
|
7a4e61ebc4 | ||
|
|
0a5a14cbbb | ||
|
|
1fc799a5b0 | ||
|
|
f08fef8a22 | ||
|
|
857a21910b | ||
|
|
ad184e1b82 | ||
|
|
83cc24e54b | ||
|
|
7025338226 | ||
|
|
4ae61a5683 | ||
|
|
a2dc2b07c5 | ||
|
|
930dfd184d | ||
|
|
9fb8e24be3 | ||
|
|
81b1f48382 | ||
|
|
730bc0de00 | ||
|
|
078fc0c5dd | ||
|
|
5cbfe84ae6 | ||
|
|
05dd0a6dc4 | ||
|
|
95be59bc9f | ||
|
|
8a8b71083e | ||
|
|
9a21edf5c0 | ||
|
|
130f41a31e | ||
|
|
d28203ac54 | ||
|
|
4cda662d6e | ||
|
|
9a65e81ab0 | ||
|
|
53b638d282 | ||
|
|
41109402a3 | ||
|
|
aaf9d38056 | ||
|
|
fb27f6e2d2 | ||
|
|
dd538d66d7 | ||
|
|
9d7f8dfeee | ||
|
|
acac856b61 | ||
|
|
19f17145cc | ||
|
|
c586a38b3a | ||
|
|
f52ec8b963 | ||
|
|
f05dc1ae15 | ||
|
|
ec8f36abb2 | ||
|
|
78375d6e39 | ||
|
|
7b1457f0db | ||
|
|
8e6ad5394e | ||
|
|
835d7217d0 | ||
|
|
c2c4752856 | ||
|
|
c0bcf1d851 | ||
|
|
6c24672e87 | ||
|
|
bee330f31f | ||
|
|
11efe91ce9 | ||
|
|
786f10aa11 | ||
|
|
b3e2c52b49 | ||
|
|
cf0751d1ff | ||
|
|
91e406f5ee | ||
|
|
6680da7d61 | ||
|
|
d3284c48c3 | ||
|
|
1219b4fd18 | ||
|
|
312778d995 | ||
|
|
e89ab1a0c9 | ||
|
|
ff0045ad93 | ||
|
|
983b620659 | ||
|
|
fa69795d69 | ||
|
|
c5f92c9a54 | ||
|
|
7df46544b8 | ||
|
|
d847f4f3f4 | ||
|
|
29e718962f | ||
|
|
e3cacc35e4 | ||
|
|
77d01450b1 | ||
|
|
acea7e699d | ||
|
|
f965650c10 | ||
|
|
c89bfe2af7 | ||
|
|
71bf723a10 | ||
|
|
235fb0de97 | ||
|
|
60e4b89822 | ||
|
|
0980c2242a | ||
|
|
c657e04679 | ||
|
|
98e3d07cf5 | ||
|
|
2c205c68b1 | ||
|
|
53e602b4f6 | ||
|
|
74586aa5b7 | ||
|
|
483cb956ba | ||
|
|
cb36dc3ef9 | ||
|
|
f3160e3062 | ||
|
|
9b44905192 | ||
|
|
c2b8a37bdd |
@@ -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\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+43
-1
@@ -1,8 +1,30 @@
|
||||
# ---- 应用 ----
|
||||
# 运行环境: development | production
|
||||
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
|
||||
APP_ENV=development
|
||||
LOG_LEVEL=INFO
|
||||
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_PASSWORD=football
|
||||
POSTGRES_DB=football
|
||||
POSTGRES_PORT=5433
|
||||
# 本地开发用 localhost;Docker Compose 内会被 environment 覆盖为 postgres 服务名
|
||||
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
||||
|
||||
# ---- LLM (OpenAI-compatible,必填一个) ----
|
||||
@@ -10,10 +32,30 @@ LLM_PROVIDER=openai
|
||||
LLM_API_KEY=sk-xxxx
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_MODEL=gpt-4o
|
||||
# 多 Agent 分档模型(留空则回落 LLM_MODEL)
|
||||
LLM_SPECIALIST_MODEL=
|
||||
LLM_AGGREGATOR_MODEL=
|
||||
# 单次 LLM 调用超时(秒)
|
||||
LLM_TIMEOUT=60
|
||||
|
||||
# ---- 数据源 ----
|
||||
BZZOIRO_KEY=
|
||||
API_FOOTBALL_KEY=
|
||||
|
||||
# ---- CORS ----
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ---- 加密主密钥 ----
|
||||
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
|
||||
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
|
||||
# 变更后已加密配置无法解密,需在后台重新保存。
|
||||
SECRET_KEY=
|
||||
|
||||
# ---- 管理接口鉴权 ----
|
||||
# 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
|
||||
# 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除。
|
||||
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
|
||||
ADMIN_PASSWORD=
|
||||
# 管理接口会话有效期(小时),默认 7 天
|
||||
ADMIN_SESSION_TTL_HOURS=168
|
||||
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
|
||||
ADMIN_API_KEY=
|
||||
|
||||
@@ -5,3 +5,12 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# AI 助手上下文文件(不入库)
|
||||
CLAUDE.md
|
||||
docs/AGENTS.md
|
||||
docs/agents/
|
||||
|
||||
# 本地审查/预览脚手架(不入库)
|
||||
.tools/
|
||||
.preview/
|
||||
|
||||
@@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
@@ -1,43 +0,0 @@
|
||||
# Profeto — 先知
|
||||
|
||||
足球 LLM 预测服务。
|
||||
|
||||
## 多 Agent 协同
|
||||
|
||||
本项目采用 4 Agent 协同模式。详见 `docs/AGENTS.md`。
|
||||
|
||||
| Agent | 所有权 | 上下文文件 |
|
||||
|---|---|---|
|
||||
| 🗄️ DBA (数据库) | `src/db/`, `alembic/`, 数据模型 | `docs/agents/dba.md` |
|
||||
| 🖥️ FE (前端) | `frontend/` | `docs/agents/fe.md` |
|
||||
| ⚙️ BE (后端) | `src/api/`, `src/core/`, `src/llm/`, `src/data/` | `docs/agents/be.md` |
|
||||
| 🚀 Ops (部署) | `Dockerfile`, `docker-compose.yml`, 配置 | `docs/agents/ops.md` |
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python 3.11+, FastAPI, SQLAlchemy async, PostgreSQL
|
||||
- **前端**: React, TypeScript, Vite, Tailwind CSS
|
||||
- **LLM**: OpenAI-compatible (OpenAI / Deepseek / Ollama)
|
||||
- **数据源**: bzzoiro, understat, api-football
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
uvicorn src.api.app:app --reload # 启动 API
|
||||
pytest # 测试
|
||||
alembic upgrade head # 数据库迁移
|
||||
|
||||
# 前端
|
||||
cd frontend && npm run dev # 开发
|
||||
cd frontend && npm run build # 构建
|
||||
|
||||
# 部署
|
||||
docker compose up -d # 启动全部服务
|
||||
```
|
||||
|
||||
## 协作原则
|
||||
|
||||
- 每个 Agent 在自己的所有权范围内工作
|
||||
- 跨 Agent 变更通过接口契约协调(REST API / ORM 模型 / docker-compose)
|
||||
- 修改接口前通知相关 Agent
|
||||
+18
-4
@@ -2,12 +2,26 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir hatchling
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
# 直连官方源不稳定,固定使用清华 PyPI 镜像
|
||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 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 .
|
||||
|
||||
# 将工作目录所有权移交给非 root 用户
|
||||
RUN chown -R profeto:profeto /app
|
||||
|
||||
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"]
|
||||
|
||||
@@ -12,30 +12,43 @@
|
||||
│ REST API
|
||||
┌──────────────────────▼──────────────────────────────┐
|
||||
│ FastAPI │
|
||||
│ ├── /api/v1/matches 比赛查询 │
|
||||
│ ├── /api/v1/matches 比赛查询(公开只读) │
|
||||
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
|
||||
│ ├── /api/v1/ingest/* 数据采集 │
|
||||
│ └── /api/v1/eval/* 评估回填 │
|
||||
│ ├── /api/v1/ingest/* 数据采集(需管理员) │
|
||||
│ ├── /api/v1/eval/* 评估回填(需管理员) │
|
||||
│ └── /api/v1/backtest 回测(需管理员) │
|
||||
└──────────┬─────────────────────────────┬────────────┘
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌─────────────▼────────────┐
|
||||
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
|
||||
│ 5 张表 │ │ OpenAI / Deepseek / │
|
||||
│ 12 张表 │ │ OpenAI / Deepseek / │
|
||||
│ leagues/teams/ │ │ Ollama / 任意网关 │
|
||||
│ matches/match_ │ └──────────────────────────┘
|
||||
│ stats/predictions/ │
|
||||
│ injuries │
|
||||
│ stats/standings/ │
|
||||
│ predictions/ │
|
||||
│ app_settings/ │
|
||||
│ schedules + │
|
||||
│ raw_events 等 4 张 │
|
||||
│ 数据治理表 │
|
||||
└─────────────────────┘
|
||||
▲
|
||||
│ 采集
|
||||
┌──────────┴─────────────────────────────────────────┐
|
||||
│ 数据源 (DataSource 协议 + 注册表) │
|
||||
│ ├── bzzoiro 比分 / 统计 / xG │
|
||||
│ ├── understat xG 回填 │
|
||||
│ └── injuries 伤停数据 (api-football) │
|
||||
│ └── bzzoiro 比分 / 赛程 / 统计 / 积分榜 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 分层架构
|
||||
|
||||
```
|
||||
API Route → Application Service → Repository → UnitOfWork → DB
|
||||
```
|
||||
|
||||
- **UnitOfWork**: 统一事务边界,业务层不再自行 commit
|
||||
- **Repository**: 封装数据访问,提供类型化查询接口
|
||||
- **DataSource**: 采集外部数据,通过注册表动态分发
|
||||
|
||||
### 多 Agent 预测
|
||||
|
||||
默认模式 (`mode=multi`) 采用 **5 专家 + 终裁** 架构:
|
||||
@@ -44,14 +57,20 @@
|
||||
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
|
||||
├─→ B 攻防数据专家 ─┤
|
||||
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
|
||||
├─→ D 阵容完整专家 ─┤
|
||||
├─→ D 联赛排名专家 ─┤
|
||||
└─→ E 历史交锋专家 ─┘
|
||||
```
|
||||
|
||||
- 各专家**只看到自己维度的数据切片**,避免信息过载
|
||||
- **fail-open**: 单个专家失败不影响整体
|
||||
- **no_data 门控**: 无数据维度跳过 LLM 调用,省 token 防幻觉
|
||||
- 终裁根据各报告的 `confidence` / `data_sufficiency` 加权输出 `agent_weights`
|
||||
- 终裁根据各报告的 `subjective_confidence` / `data_sufficiency` 输出 `agent_weights`
|
||||
|
||||
### 数据正确性保障
|
||||
|
||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
|
||||
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
|
||||
- **数据库约束**: CHECK 约束作为最后一道防线
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -61,91 +80,106 @@
|
||||
- Docker (运行 PostgreSQL)
|
||||
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
||||
|
||||
### 1. 安装
|
||||
### 方式一:Docker Compose 部署(推荐)
|
||||
|
||||
```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. 启动全部服务(自动构建 + 执行迁移)
|
||||
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
|
||||
# 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
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
# 4. 执行迁移
|
||||
alembic upgrade head
|
||||
|
||||
```bash
|
||||
# 后端 (终端 1)
|
||||
# 5. 启动后端 (终端 1)
|
||||
uvicorn src.api.app:app --reload
|
||||
|
||||
# 前端 (终端 2)
|
||||
# 6. 启动前端 (终端 2)
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`。
|
||||
|
||||
## 使用流程
|
||||
## 生产上线检查清单
|
||||
|
||||
### 1. 采集数据
|
||||
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
|
||||
|
||||
```bash
|
||||
# 采集 bzzoiro 比分与统计
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"leagues":["E0","SP1"],"date_from":"2026-08-01","date_to":"2026-09-06"}'
|
||||
- [ ] `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`
|
||||
|
||||
# 回填 understat xG
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/understat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"league":"E0","season":2026}'
|
||||
## 安全与限流
|
||||
|
||||
# 采集伤停
|
||||
curl -X POST http://localhost:8000/api/v1/ingest/injuries \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"date":"2026-09-09"}'
|
||||
```
|
||||
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
|
||||
- 登录防爆破: 进程内内存计数,同上
|
||||
- 公网部署建议 Nginx 层限流 + `TRUST_PROXY_HEADERS=True`
|
||||
- 生产环境必须配置 `ADMIN_PASSWORD` 或 `ADMIN_API_KEY`(否则管理接口 503)
|
||||
|
||||
### 2. 查询比赛
|
||||
详见 [docs/06-deployment.md](docs/06-deployment.md#安全与限流)。
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8000/api/v1/matches?league=E0&status=scheduled"
|
||||
```
|
||||
## API 概览
|
||||
|
||||
### 3. LLM 预测
|
||||
**公开只读**(无需登录;`predict` 带内存限流):
|
||||
|
||||
```bash
|
||||
# 多 Agent 预测 (默认)
|
||||
curl -X POST http://localhost:8000/api/v1/predict \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"match_id": 1}'
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/leagues` | 联赛列表(仅 id/code/name/country) |
|
||||
| GET | `/api/v1/matches` | 比赛查询(筛选/游标分页) |
|
||||
| GET | `/api/v1/matches/{id}` | 比赛详情(含统计与最近预测) |
|
||||
| GET | `/api/v1/matches/{id}/context` | 比赛上下文(双方近况 + 历史交锋) |
|
||||
| GET | `/api/v1/standings` | 联赛积分榜 |
|
||||
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`/`baseline`) |
|
||||
| GET | `/health`、`/health/ready` | 存活 / 就绪检查(含 DB) |
|
||||
|
||||
# 单次调用模式
|
||||
curl -X POST http://localhost:8000/api/v1/predict \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"match_id": 1, "mode": "single"}'
|
||||
```
|
||||
**需管理员**(Cookie 会话或 `X-API-Key`):
|
||||
|
||||
### 4. 评估
|
||||
|
||||
```bash
|
||||
# 赛后回填实际比分
|
||||
curl -X POST http://localhost:8000/api/v1/eval/settle \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prediction_id": 1, "home_goals": 2, "away_goals": 1}'
|
||||
|
||||
# 查看准确率汇总
|
||||
curl http://localhost:8000/api/v1/eval/summary
|
||||
```
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/ingest/bzzoiro` | 采集赛果/赛程/统计/积分榜 |
|
||||
| GET | `/api/v1/predictions` | 预测历史(列表) |
|
||||
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
|
||||
| POST | `/api/v1/eval/settle` | 回填实际结果 |
|
||||
| GET | `/api/v1/eval/summary` | 准确率汇总 |
|
||||
| POST | `/api/v1/backtest` | 历史回测 |
|
||||
| `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -154,41 +188,58 @@ Profeto/
|
||||
├── src/
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ │ ├── app.py # 应用工厂 + lifespan
|
||||
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
|
||||
│ │ ├── schemas.py # Pydantic 请求/响应模型
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 比赛查询
|
||||
│ │ ├── predict.py # 预测入口
|
||||
│ │ ├── ingest.py # 数据采集
|
||||
│ │ └── eval.py # 评估回填
|
||||
│ │ ├── matches.py # 比赛查询(公开只读)
|
||||
│ │ ├── predict.py # 预测入口 + 预测历史
|
||||
│ │ ├── ingest.py # 数据采集(需管理员)
|
||||
│ │ ├── eval.py # 评估回填(需管理员)
|
||||
│ │ ├── backtest.py # 回测(需管理员)
|
||||
│ │ ├── auth.py # 登录/登出/改密
|
||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
||||
│ ├── core/ # 基础设施
|
||||
│ │ ├── config.py # pydantic-settings 配置
|
||||
│ │ └── http_client.py # 共享 httpx 客户端
|
||||
│ │ ├── crypto.py # 加密/哈希
|
||||
│ │ ├── http_client.py # 共享 httpx 客户端
|
||||
│ │ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
||||
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
||||
│ │ ├── scheduler.py # 进程内 cron 调度器
|
||||
│ │ └── security_check.py # 启动安全校验
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── sources.py # DataSource 协议 + 注册表
|
||||
│ │ ├── match_lookup.py # 比赛匹配辅助函数
|
||||
│ │ ├── bzzoiro.py # bzzoiro 数据源(events/standings/stats)
|
||||
│ │ ├── normalize.py # 数据规范化契约
|
||||
│ │ ├── bzzoiro.py # bzzoiro 数据源
|
||||
│ │ ├── understat.py # understat xG 数据源
|
||||
│ │ ├── injuries.py # 伤停数据 (独立领域)
|
||||
│ │ ├── config.py # 联赛映射常量
|
||||
│ │ └── team_names.py # 队名归一化
|
||||
│ │ ├── key_ring.py # API Key 轮换环(429 冷却)
|
||||
│ │ ├── team_names.py # 队名归一化
|
||||
│ │ └── team_names_zh.py # 队名中文映射
|
||||
│ ├── db/ # 数据库
|
||||
│ │ ├── base.py # SQLAlchemy async engine
|
||||
│ │ └── models.py # ORM 模型 (5 表)
|
||||
│ │ ├── models.py # ORM 模型 (12 表)
|
||||
│ │ ├── unit_of_work.py # UnitOfWork 事务封装
|
||||
│ │ └── repositories.py # Repository 数据访问
|
||||
│ └── llm/ # LLM 预测核心
|
||||
│ ├── predict.py # 预测服务 (缓存 + 单/多模式)
|
||||
│ ├── predict.py # 预测服务 (缓存 + 单/多/基线模式)
|
||||
│ ├── context_builder.py # 数据切片 + 上下文拼接
|
||||
│ ├── baseline.py # 基线预测(均值模型)
|
||||
│ ├── eval.py # 评估统计
|
||||
│ ├── backtest.py # 回测框架
|
||||
│ ├── provider.py # 多提供商 LLM 抽象
|
||||
│ ├── utils.py # LLM 工具函数
|
||||
│ ├── validation.py # LLM 输出校验
|
||||
│ ├── agents/
|
||||
│ │ ├── base.py # Agent 基础设施 + 解析
|
||||
│ │ └── orchestrator.py # 多 Agent 编排
|
||||
│ └── prompts/ # Prompt 模板
|
||||
│ ├── match_prediction_v1.md
|
||||
│ ├── match_prediction_v2.md
|
||||
│ └── agents/ # 各专家 prompt
|
||||
├── alembic/ # 数据库迁移
|
||||
├── frontend/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
|
||||
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
|
||||
│ ├── components/ # 共享组件
|
||||
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
|
||||
├── docs/ # 详细文档
|
||||
├── tests/ # 单元测试
|
||||
├── docker-compose.yml
|
||||
@@ -204,8 +255,10 @@ Profeto/
|
||||
| `prompts/` | Prompt 模板 (迭代最频繁) |
|
||||
| `provider.py` | OpenAI-compatible 多提供商抽象 |
|
||||
| `sources.py` | 数据源协议 + 注册表 |
|
||||
| `normalize.py` | 数据清洗契约 (校验/范围/归一) |
|
||||
| `orchestrator.py` | 多 Agent 编排 (并行专家 + 终裁) |
|
||||
| `unit_of_work.py` | 统一事务边界 |
|
||||
| `repositories.py` | 数据访问封装 |
|
||||
| `validation.py` | LLM 输出严格校验 |
|
||||
| `backtest.py` | 回测框架(防未来数据泄漏) |
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -221,7 +274,6 @@ Profeto/
|
||||
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
|
||||
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
|
||||
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
|
||||
| `API_FOOTBALL_KEY` | api-football Key (伤停) | |
|
||||
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
|
||||
|
||||
## 测试
|
||||
@@ -232,8 +284,6 @@ pytest
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
生产环境建议使用 Alembic:
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
+24
-1
@@ -3,7 +3,7 @@ from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
from alembic import context
|
||||
|
||||
# 把项目根加入 pythonpath,让 alembic 能找到 src 包
|
||||
@@ -29,6 +29,23 @@ if config.config_file_name is not None:
|
||||
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:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
@@ -52,6 +69,12 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""add snapshot fields and check constraints
|
||||
|
||||
Revision ID: 0004_snapshot_and_constraints
|
||||
Revises: 0003_injuries
|
||||
Create Date: 2026-09-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0004_snapshot_and_constraints'
|
||||
down_revision: Union[str, None] = '0003_injuries'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 重命名 confidence → subjective_confidence
|
||||
op.alter_column('predictions', 'confidence', new_column_name='subjective_confidence')
|
||||
|
||||
# 2. 新增快照字段
|
||||
op.add_column('predictions', sa.Column('cutoff_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('predictions', sa.Column('input_hash', sa.String(length=64), nullable=True))
|
||||
|
||||
# 3. 新增 CHECK 约束
|
||||
# 注意: 对已有数据行加 CHECK 约束时,若存在越界数据 ALTER TABLE 会中途失败,
|
||||
# 导致迁移卡在半完成状态。这里先做一次性清洗(把越界值收敛到合法域),
|
||||
# 再建约束,保证在非空库上也能成功。
|
||||
op.execute("UPDATE predictions SET pred_home_goals = 0 WHERE pred_home_goals IS NOT NULL AND pred_home_goals < 0")
|
||||
op.execute("UPDATE predictions SET pred_away_goals = 0 WHERE pred_away_goals IS NOT NULL AND pred_away_goals < 0")
|
||||
op.execute(
|
||||
"UPDATE predictions SET subjective_confidence = "
|
||||
"CASE WHEN subjective_confidence < 0 THEN 0 "
|
||||
" WHEN subjective_confidence > 1 THEN 1 ELSE subjective_confidence END "
|
||||
"WHERE subjective_confidence IS NOT NULL "
|
||||
" AND (subjective_confidence < 0 OR subjective_confidence > 1)"
|
||||
)
|
||||
op.execute("UPDATE predictions SET pred_1x2 = NULL WHERE pred_1x2 IS NOT NULL AND pred_1x2 NOT IN ('1', 'X', '2')")
|
||||
op.execute("UPDATE predictions SET mode = 'single' WHERE mode IS NOT NULL AND mode NOT IN ('single', 'multi')")
|
||||
|
||||
op.create_check_constraint('ck_pred_home_goals_nonneg', 'predictions', 'pred_home_goals >= 0')
|
||||
op.create_check_constraint('ck_pred_away_goals_nonneg', 'predictions', 'pred_away_goals >= 0')
|
||||
op.create_check_constraint('ck_confidence_range', 'predictions', 'subjective_confidence >= 0 AND subjective_confidence <= 1')
|
||||
op.create_check_constraint('ck_pred_1x2_enum', 'predictions', "pred_1x2 IN ('1', 'X', '2')")
|
||||
op.create_check_constraint('ck_mode_enum', 'predictions', "mode IN ('single', 'multi')")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 1. 删除 CHECK 约束
|
||||
op.drop_constraint('ck_mode_enum', 'predictions', type_='check')
|
||||
op.drop_constraint('ck_pred_1x2_enum', 'predictions', type_='check')
|
||||
op.drop_constraint('ck_confidence_range', 'predictions', type_='check')
|
||||
op.drop_constraint('ck_pred_away_goals_nonneg', 'predictions', type_='check')
|
||||
op.drop_constraint('ck_pred_home_goals_nonneg', 'predictions', type_='check')
|
||||
|
||||
# 2. 删除快照字段
|
||||
op.drop_column('predictions', 'input_hash')
|
||||
op.drop_column('predictions', 'cutoff_at')
|
||||
|
||||
# 3. 恢复列名
|
||||
op.alter_column('predictions', 'subjective_confidence', new_column_name='confidence')
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add prediction status/time semantics and MatchStats provenance
|
||||
|
||||
Revision ID: 0005_prediction_status_and_stats_provenance
|
||||
Revises: 0004_snapshot_and_constraints
|
||||
Create Date: 2026-09-15
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0005_prediction_status_and_stats_provenance'
|
||||
down_revision: Union[str, None] = '0004_snapshot_and_constraints'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Prediction 新增字段
|
||||
op.add_column('predictions', sa.Column('status', sa.String(length=20), nullable=False, server_default='success'))
|
||||
op.add_column('predictions', sa.Column('match_kickoff_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('predictions', sa.Column('prediction_created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
|
||||
op.add_column('predictions', sa.Column('prediction_cutoff_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# 重命名 cutoff_at → 保留作为兼容,prediction_cutoff_at 为主字段
|
||||
# op.drop_column('predictions', 'cutoff_at') # 暂不删除,避免破坏现有数据
|
||||
|
||||
# 2. 新增 status CHECK 约束
|
||||
op.create_check_constraint('ck_status_enum', 'predictions', "status IN ('success', 'failed', 'degraded')")
|
||||
|
||||
# 3. MatchStats 新增数据血缘字段
|
||||
op.add_column('match_stats', sa.Column('source', sa.String(length=30), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('source_record_id', sa.String(length=100), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('retrieved_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('available_at', sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
# 4. 索引
|
||||
op.create_index('ix_match_stats_available_at', 'match_stats', ['available_at'])
|
||||
op.create_index('ix_predictions_cutoff_at', 'predictions', ['prediction_cutoff_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_predictions_cutoff_at', table_name='predictions')
|
||||
op.drop_index('ix_match_stats_available_at', table_name='match_stats')
|
||||
|
||||
op.drop_column('match_stats', 'available_at')
|
||||
op.drop_column('match_stats', 'retrieved_at')
|
||||
op.drop_column('match_stats', 'source_record_id')
|
||||
op.drop_column('match_stats', 'source')
|
||||
|
||||
op.drop_constraint('ck_status_enum', 'predictions', type_='check')
|
||||
|
||||
op.drop_column('predictions', 'prediction_cutoff_at')
|
||||
op.drop_column('predictions', 'prediction_created_at')
|
||||
op.drop_column('predictions', 'match_kickoff_at')
|
||||
op.drop_column('predictions', 'status')
|
||||
@@ -0,0 +1,80 @@
|
||||
"""清理 schema 与 ORM 模型的漂移
|
||||
|
||||
Revision ID: 0006_schema_model_drift_cleanup
|
||||
Revises: 0005_prediction_status_and_stats_provenance
|
||||
Create Date: 2026-09-15
|
||||
|
||||
背景(见代码审查报告 P1-1):
|
||||
0005 迁移在 predictions 表留下了 `cutoff_at` 列(删除语句被注释掉),
|
||||
但 ORM 模型 `Prediction` 中并无该字段 —— 这是一处会长期存在的 schema 漂移,
|
||||
而 alembic autogenerate 会持续建议 drop 它,造成噪声。
|
||||
|
||||
同时 0005 创建的两个索引 `ix_match_stats_available_at` 与
|
||||
`ix_predictions_cutoff_at` 未在 ORM 声明,autogenerate 会建议删除它们 ——
|
||||
一旦被误删,数据血缘相关的时间过滤查询会退化为全表扫描。
|
||||
|
||||
本迁移做两件事:
|
||||
1. drop 掉幽灵列 predictions.cutoff_at(数据已由 prediction_cutoff_at 承载,
|
||||
迁移前先把非空值回填过去,避免丢数据)
|
||||
2. 显式重建这两个索引(幂等:先 drop if exists 再 create),使 DB 状态与
|
||||
修正后的 ORM 声明一致
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0006_schema_model_drift_cleanup'
|
||||
down_revision: Union[str, None] = '0005_prediction_status_and_stats_provenance'
|
||||
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)
|
||||
|
||||
# --- 1. 幽灵列 cutoff_at: 先把数据回填到 prediction_cutoff_at 再删除 ---
|
||||
pred_cols = {c["name"] for c in inspector.get_columns("predictions")}
|
||||
if "cutoff_at" in pred_cols:
|
||||
if "prediction_cutoff_at" in pred_cols:
|
||||
# 仅回填尚未有值的行,避免覆盖更权威的数据
|
||||
op.execute(
|
||||
"UPDATE predictions "
|
||||
"SET prediction_cutoff_at = cutoff_at "
|
||||
"WHERE prediction_cutoff_at IS NULL AND cutoff_at IS NOT NULL"
|
||||
)
|
||||
op.drop_column("predictions", "cutoff_at")
|
||||
|
||||
# --- 2. 与 ORM 声明对齐的索引(幂等重建) ---
|
||||
stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")}
|
||||
if "ix_match_stats_available_at" not in stats_idx:
|
||||
op.create_index("ix_match_stats_available_at", "match_stats", ["available_at"])
|
||||
|
||||
pred_idx = {i["name"] for i in inspector.get_indexes("predictions")}
|
||||
if "ix_predictions_cutoff_at" not in pred_idx:
|
||||
op.create_index(
|
||||
"ix_predictions_cutoff_at", "predictions", ["prediction_cutoff_at"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# 恢复幽灵列(与 0005 的最终状态一致:列存在但无值)
|
||||
pred_cols = {c["name"] for c in inspector.get_columns("predictions")}
|
||||
if "cutoff_at" not in pred_cols:
|
||||
op.add_column(
|
||||
"predictions",
|
||||
sa.Column("cutoff_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
stats_idx = {i["name"] for i in inspector.get_indexes("match_stats")}
|
||||
if "ix_match_stats_available_at" in stats_idx:
|
||||
op.drop_index("ix_match_stats_available_at", table_name="match_stats")
|
||||
|
||||
pred_idx = {i["name"] for i in inspector.get_indexes("predictions")}
|
||||
if "ix_predictions_cutoff_at" in pred_idx:
|
||||
op.drop_index("ix_predictions_cutoff_at", table_name="predictions")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""修复 injuries 表约束命名与 ORM 声明不一致
|
||||
|
||||
Revision ID: 0007_injuries_constraint_naming_align
|
||||
Revises: 0006_schema_model_drift_cleanup
|
||||
Create Date: 2026-09-16
|
||||
|
||||
背景(见代码审查报告 P2-5):
|
||||
0003 迁移使用 sa.UniqueConstraint 创建唯一约束,
|
||||
而 ORM models.py 中声明为 Index(..., unique=True)。
|
||||
虽然 PostgreSQL 中两者效果相同(都保证唯一性),
|
||||
但 pg_catalog 中表示不同,会导致:
|
||||
- alembic autogenerate 持续报告漂移
|
||||
- 约束命名约定不一致(uc_ 前缀 vs ix_ 前缀)
|
||||
|
||||
本迁移将 UniqueConstraint 替换为唯一索引,与 ORM 声明对齐。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0007_injuries_constraint_naming_align'
|
||||
down_revision: Union[str, None] = '0006_schema_model_drift_cleanup'
|
||||
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)
|
||||
|
||||
# 检查当前约束类型
|
||||
constraints = {
|
||||
c["name"]: c
|
||||
for c in inspector.get_unique_constraints("injuries")
|
||||
}
|
||||
indexes = {
|
||||
i["name"]: i
|
||||
for i in inspector.get_indexes("injuries")
|
||||
}
|
||||
|
||||
# 如果存在 UniqueConstraint 形式的 ix_injuries_player_fixture,替换为唯一索引
|
||||
if "ix_injuries_player_fixture" in constraints:
|
||||
# 删除唯一约束
|
||||
op.drop_constraint("ix_injuries_player_fixture", "injuries", type_="unique")
|
||||
|
||||
# 如果不存在同名唯一索引,创建它(与 ORM 声明一致)
|
||||
if "ix_injuries_player_fixture" not in indexes:
|
||||
op.create_index(
|
||||
"ix_injuries_player_fixture",
|
||||
"injuries",
|
||||
["player_id", "fixture_id", "injury_type"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
indexes = {
|
||||
i["name"]: i
|
||||
for i in inspector.get_indexes("injuries")
|
||||
}
|
||||
constraints = {
|
||||
c["name"]: c
|
||||
for c in inspector.get_unique_constraints("injuries")
|
||||
}
|
||||
|
||||
# 恢复为 UniqueConstraint 形式
|
||||
if "ix_injuries_player_fixture" in indexes:
|
||||
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
||||
|
||||
if "ix_injuries_player_fixture" not in constraints:
|
||||
op.create_unique_constraint(
|
||||
"ix_injuries_player_fixture",
|
||||
"injuries",
|
||||
["player_id", "fixture_id", "injury_type"],
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""为 predictions 表添加 match_id+provider+model 唯一约束
|
||||
|
||||
Revision ID: 0007_predictions_unique_constraint
|
||||
Revises: 0007_injuries_constraint_naming_align
|
||||
Create Date: 2026-09-16
|
||||
|
||||
背景(见代码审查报告 P1-6):
|
||||
同一 match_id + provider + model 组合不应产生重复预测。
|
||||
当前缺少数据库级唯一约束,回测多次运行或并发采集可能产生重复记录,
|
||||
导致统计偏差。
|
||||
|
||||
先清理已存在的重复记录(保留最早创建的那条),再添加唯一约束。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0007_predictions_unique_constraint'
|
||||
down_revision: Union[str, None] = '0007_injuries_constraint_naming_align'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 清理已存在的重复记录(保留 id 最小的)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE FROM predictions
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM predictions
|
||||
GROUP BY match_id, provider, model
|
||||
)
|
||||
AND match_id IN (
|
||||
SELECT match_id
|
||||
FROM predictions
|
||||
GROUP BY match_id, provider, model
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. 添加唯一约束
|
||||
op.create_unique_constraint(
|
||||
"uq_predictions_match_provider_model",
|
||||
"predictions",
|
||||
["match_id", "provider", "model"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"uq_predictions_match_provider_model",
|
||||
"predictions",
|
||||
type_="unique",
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')",
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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=op.text('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,58 @@
|
||||
"""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')
|
||||
op.drop_index('ix_standings_leason_season_pos', table_name='standings')
|
||||
op.create_index('ix_standings_league_season_pos', '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=True,
|
||||
)
|
||||
# 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'],
|
||||
)
|
||||
+35
-7
@@ -2,30 +2,58 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: football
|
||||
POSTGRES_PASSWORD: football
|
||||
POSTGRES_DB: football
|
||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER 未设置}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-football}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U football"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER 未设置}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
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:
|
||||
- "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}
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./alembic:/app/alembic
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
|
||||
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:
|
||||
pgdata:
|
||||
|
||||
+44
-26
@@ -13,16 +13,15 @@
|
||||
│ │
|
||||
│ 数据查询 预测编排 采集(手动/cron 触发) │
|
||||
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
|
||||
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
|
||||
│ │matches│ │ orchestrator│ │ bzzoiro (唯一源) │ │
|
||||
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
|
||||
│ └──┬───┘ │ │ h2h / form / stats / │ │
|
||||
│ │ │ │ home_away / injuries │ │
|
||||
│ │ │ │ home_away / standings │ │
|
||||
│ │ │ └─ aggregator 终裁(强模型) │ │
|
||||
│ │ └────────────┘ └───────────────────┘ │
|
||||
│ │ │ └ understat (xG) │
|
||||
│ ┌──┴──────────────┴──┐ └ injuries (伤停) │
|
||||
│ │ PostgreSQL (5 张表) │ httpx → 外部 API │
|
||||
│ └────────────────────┘ │
|
||||
│ │ └────────────┘ │ events / standings │ │
|
||||
│ ┌──┴──────────────┴──┐ │ /stats 三条管线 │ │
|
||||
│ │ PostgreSQL (12 张表)│ └───────────────────┘ │
|
||||
│ └────────────────────┘ httpx → 外部 API │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -31,7 +30,7 @@
|
||||
1. `POST /predict {match_id}` → orchestrator
|
||||
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
|
||||
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
|
||||
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
|
||||
- 各自的数据切片函数查库(近况/交锋/积分榜聚合/射门控球/xG)
|
||||
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
|
||||
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
|
||||
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
|
||||
@@ -44,7 +43,7 @@
|
||||
|---|---|
|
||||
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
|
||||
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
|
||||
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
|
||||
| **no_data 门控** | 无数据维度(如积分榜未采集)不调 LLM,终裁知道维度缺失,不编造 |
|
||||
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
|
||||
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
|
||||
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
|
||||
@@ -57,40 +56,59 @@
|
||||
Profeto/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan 建表)
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
|
||||
│ │ ├── schemas.py # Pydantic v2 请求/响应
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
|
||||
│ │ ├── predict.py # 预测 + 预测历史
|
||||
│ │ ├── ingest.py # 采集触发(自管 session)
|
||||
│ │ └── eval.py # 赛后回填 + 准确率汇总
|
||||
│ │ ├── matches.py # 联赛/比赛/上下文/积分榜(公开只读)
|
||||
│ │ ├── predict.py # 预测(限流)+ 预测历史(需鉴权)
|
||||
│ │ ├── ingest.py # 采集触发(需鉴权)
|
||||
│ │ ├── eval.py # 赛后回填 + 准确率汇总(需鉴权)
|
||||
│ │ ├── backtest.py # 历史回测(需鉴权)
|
||||
│ │ ├── auth.py # 登录/登出/改密
|
||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
||||
│ ├── db/
|
||||
│ │ ├── base.py # async engine + get_db/get_db_read
|
||||
│ │ └── models.py # 5 张表 ORM
|
||||
│ │ ├── models.py # 12 张表 ORM
|
||||
│ │ ├── repositories.py # 仓储层
|
||||
│ │ └── unit_of_work.py # 事务边界
|
||||
│ ├── data/
|
||||
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
|
||||
│ │ ├── understat.py # xG 回填
|
||||
│ │ ├── injuries.py # 伤停采集(带文件缓存)
|
||||
│ │ ├── bzzoiro.py # 唯一数据源:events/standings/stats 三管线 + Bronze 层
|
||||
│ │ ├── normalize.py # NormalizedMatch 清洗契约
|
||||
│ │ ├── team_names.py # 队名归一映射
|
||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
||||
│ │ ├── key_ring.py # 多 key 轮换(429 冷却,进程内)
|
||||
│ │ ├── sources.py # 数据源注册表
|
||||
│ │ └── config.py # 联赛代码映射
|
||||
│ ├── llm/
|
||||
│ │ ├── 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 分派 + 缓存)
|
||||
│ │ ├── baseline.py # 基线预测(均值模型,mode=baseline)
|
||||
│ │ ├── eval.py # 准确率统计
|
||||
│ │ ├── utils.py # LLM 工具函数
|
||||
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
|
||||
│ │ ├── backtest.py # 回测执行
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── base.py # AgentSpec / AgentReport / run_agent
|
||||
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
|
||||
│ │ └── prompts/
|
||||
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
|
||||
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
|
||||
│ └── core/config.py # pydantic-settings
|
||||
├── alembic/versions/ # 0001 建表 + 0002 agent 字段
|
||||
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区)
|
||||
├── tests/ # 33 项(核心 13 + agent 20)
|
||||
│ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
|
||||
│ └── core/
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── crypto.py # 加密/哈希
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
||||
│ ├── runtime_config.py # DB 配置覆盖(app_settings)
|
||||
│ ├── scheduler.py # 进程内 cron 调度器
|
||||
│ └── security_check.py # 启动安全校验
|
||||
├── alembic/versions/ # 0001~0018(建表 → Bronze 层 → 单一数据源 → 基线模式等)
|
||||
├── frontend/src/ # pages/(公开站) + admin/(管理后台) + lib/http.ts(唯一 HTTP 实现)
|
||||
├── tests/ # 核心 + agent 测试(250+ 项,自包含)
|
||||
├── docker-compose.yml # api + postgres 两容器
|
||||
└── docs/ # 本文档
|
||||
└── docs/ # 本文档
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@@ -103,5 +121,5 @@ Profeto/
|
||||
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
|
||||
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
|
||||
| 前端 | Vite + React 18 + TypeScript + Tailwind |
|
||||
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
|
||||
| 测试 | pytest + pytest-asyncio(250+ 项,自包含) |
|
||||
| 部署 | Docker Compose(api + postgres) |
|
||||
|
||||
+13
-4
@@ -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"}'
|
||||
```
|
||||
|
||||
> 注:采集/评估端点需管理员凭据。本地开发环境(未配置鉴权、非 production)默认放行;
|
||||
> 生产环境需先 `POST /auth/login` 取 Cookie,或带 `X-API-Key` 头。
|
||||
|
||||
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
|
||||
|
||||
```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"}'
|
||||
```
|
||||
|
||||
### 回填 xG(可选,让攻防数据 agent 有数据)
|
||||
### 回填积分榜与统计(让攻防/排名专家有数据)
|
||||
|
||||
```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" \
|
||||
-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 ,选"英超 / 未开赛";
|
||||
@@ -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 的模型) |
|
||||
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
|
||||
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
|
||||
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
|
||||
| stats 专家报无 xG/统计 | 先跑 `task=stats` 回填(bzzoiro 统计管线,只补空字段) |
|
||||
|
||||
+84
-38
@@ -4,18 +4,30 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
所有数据端点返回 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`
|
||||
|
||||
列出已入库联赛。
|
||||
列出已入库联赛(P1-3: 公开站联赛筛选动态加载来源)。
|
||||
|
||||
```json
|
||||
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
|
||||
```
|
||||
|
||||
仅返回 `id/code/name/country` 四个展示字段,不含任何配置或密钥信息。
|
||||
|
||||
### `GET /api/v1/matches`
|
||||
|
||||
比赛列表,游标分页。
|
||||
@@ -47,7 +59,44 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
### `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"}]}
|
||||
]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -87,22 +136,22 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
"pred_home_goals": 2.1,
|
||||
"pred_away_goals": 1.0,
|
||||
"pred_1x2": "1",
|
||||
"confidence": 0.68,
|
||||
"subjective_confidence": 0.68,
|
||||
"reasoning": "综合 xg 报告的进球期望 2.1-1.0 与 form 报告的三连胜势头……",
|
||||
"agent_outputs": [
|
||||
{"agent": "h2h", "status": "ok", "data_sufficiency": "medium",
|
||||
"analysis": "近 5 次交锋主队 3 胜……", "home_edge": 0.4,
|
||||
"confidence": 0.7, "key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"],
|
||||
"subjective_confidence": 0.7, "key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"],
|
||||
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
|
||||
"model": "gpt-4o-mini", "latency_ms": 2100,
|
||||
"prompt_tokens": 380, "completion_tokens": 120},
|
||||
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "confidence": null,
|
||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
||||
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
|
||||
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
|
||||
"probable_score": null, "model": "", "latency_ms": 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 串]",
|
||||
"latency_ms": 9800
|
||||
}
|
||||
@@ -112,11 +161,11 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
|
||||
|
||||
### `GET /api/v1/predictions?match_id=&limit=`
|
||||
### `GET /api/v1/predictions?match_id=&limit=`(需管理员)
|
||||
|
||||
预测历史(倒序),含 `settled` 与实际比分回填状态。
|
||||
|
||||
### `GET /api/v1/predictions/{id}`
|
||||
### `GET /api/v1/predictions/{id}`(需管理员)
|
||||
|
||||
单条预测详情(含完整 `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
|
||||
{"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 回填单次最大比赛数(1–500) |
|
||||
|
||||
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
|
||||
- `task=stats` 只补空字段、不创建比赛(xG/射门/控球等统计回填)
|
||||
|
||||
### `POST /api/v1/ingest/understat`
|
||||
|
||||
回填 xG(只补空字段,不创建比赛):
|
||||
|
||||
```json
|
||||
{"league": "E0", "season": 2025}
|
||||
```
|
||||
|
||||
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
|
||||
|
||||
### `POST /api/v1/ingest/injuries`
|
||||
|
||||
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
|
||||
|
||||
```json
|
||||
{"date": "2026-09-10"}
|
||||
```
|
||||
> 历史版本曾有独立的 understat(xG)与 injuries(伤停)采集端点,
|
||||
> 已随数据源收敛为 bzzoiro 唯一来源而移除。
|
||||
|
||||
---
|
||||
|
||||
## 评估
|
||||
|
||||
### `POST /api/v1/eval/settle`
|
||||
### `POST /api/v1/eval/settle`(需管理员)
|
||||
|
||||
赛后回填实际比分:
|
||||
|
||||
@@ -165,14 +208,14 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
|
||||
```
|
||||
|
||||
### `GET /api/v1/eval/summary`
|
||||
### `GET /api/v1/eval/summary`(需管理员)
|
||||
|
||||
按 `provider × model` 聚合已结算预测:
|
||||
|
||||
```json
|
||||
{"summary": [
|
||||
{"provider": "openai", "model": "gpt-4o", "total": 12,
|
||||
"accuracy_1x2": 58.3, "avg_score_rmse": 1.21, "avg_confidence": 0.65}
|
||||
"accuracy_1x2": 58.3, "avg_score_rmse": 1.21, "avg_subjective_confidence": 0.65}
|
||||
]}
|
||||
```
|
||||
|
||||
@@ -183,7 +226,10 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
|
||||
## 基础
|
||||
|
||||
| 端点 | 说明 |
|
||||
|---|---|
|
||||
| `GET /health` | 存活检查 |
|
||||
| `GET /docs` | Swagger UI |
|
||||
| 端点 | 权限 | 说明 |
|
||||
|---|---|---|
|
||||
| `GET /health` | 公开 | 存活检查 |
|
||||
| `GET /docs`、`GET /redoc` | 公开 | Swagger UI / ReDoc |
|
||||
| `POST /auth/login`、`POST /auth/logout`、`GET /auth/me` | 公开 | 管理员 Cookie 会话登录/登出/当前用户 |
|
||||
| `POST /auth/change-password` | 需管理员 | 修改管理员密码 |
|
||||
| `POST /backtest` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
|
||||
|
||||
+24
-11
@@ -10,7 +10,7 @@ Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个
|
||||
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
|
||||
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
|
||||
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
|
||||
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge` 或 `no_data` |
|
||||
| `standings` 联赛排名 | 结合积分榜排名、积分与分区(欧冠/欧联/降级),评估双方竞争位置 | 两队当前赛季积分榜行(排名/积分/分区/近期战绩) | `home_edge` 或 `no_data` |
|
||||
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
|
||||
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
|
||||
|
||||
@@ -25,7 +25,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
│ ├─ form agent ─┐
|
||||
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
|
||||
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
|
||||
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
|
||||
│ ├─ standings agent │ (无数据 → 跳过 LLM,返回 stub)
|
||||
│ └─ h2h agent ─┘
|
||||
│
|
||||
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
|
||||
@@ -39,12 +39,12 @@ POST /predict {match_id, mode: "multi"}
|
||||
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
|
||||
|
||||
### 2. no_data 门控(省 token、防幻觉)
|
||||
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
|
||||
数据切片为空时(如该场比赛的积分榜尚未采集),**跳过 LLM 调用**,直接返回:
|
||||
```json
|
||||
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
||||
"analysis": "该维度无数据,跳过分析。"}
|
||||
```
|
||||
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
|
||||
终裁 Agent 会看到这个 `no_data` 状态,不会编造积分榜分析。
|
||||
|
||||
### 3. fail-open(单专家失败不阻断)
|
||||
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
|
||||
@@ -65,7 +65,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
"data_sufficiency": "high",
|
||||
"analysis": "近 5 次交锋主队 3 胜 1 平 1 负,主场交锋 3 连胜……",
|
||||
"home_edge": 0.4,
|
||||
"confidence": 0.7,
|
||||
"subjective_confidence": 0.7,
|
||||
"key_evidence": ["近5次交锋主队3胜", "主场交锋3连胜"]
|
||||
}
|
||||
```
|
||||
@@ -75,7 +75,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
| `status` | `ok` / `no_data` / `error` / `parse_error` |
|
||||
| `data_sufficiency` | `high` / `medium` / `low` / `none` |
|
||||
| `home_edge` | -1.0 ~ 1.0,正数=利主队,负数=利客队 |
|
||||
| `confidence` | 0.0 ~ 1.0,该专家对自己分析的信心 |
|
||||
| `subjective_confidence` | 0.0 ~ 1.0,该专家对自己分析的主观信心(非概率) |
|
||||
| `key_evidence` | 关键证据列表(最多 5 条) |
|
||||
|
||||
### 终裁 Agent 输出
|
||||
@@ -87,13 +87,13 @@ POST /predict {match_id, mode: "multi"}
|
||||
"pred_home_goals": 2.1,
|
||||
"pred_away_goals": 1.0,
|
||||
"1x2": "1",
|
||||
"confidence": 0.68,
|
||||
"subjective_confidence": 0.68,
|
||||
"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}
|
||||
}
|
||||
```
|
||||
|
||||
`agent_weights` 体现终裁对各专家报告的采信度(0–1),可用于后续分析"哪个维度对预测贡献大"。
|
||||
`agent_weights` 体现终裁对各专家报告的采信度(0–1),可用于后续分析"哪个维度对预测贡献大"。注意:这是 LLM 主观权重,非统计权重。
|
||||
|
||||
## 模型分档配置
|
||||
|
||||
@@ -120,7 +120,7 @@ agents/
|
||||
├── form_v1.md # 近期状态专家
|
||||
├── stats_v1.md # 攻防数据专家
|
||||
├── home_away_v1.md # 主客因素专家
|
||||
├── injuries_v1.md # 阵容完整性专家
|
||||
├── standings_v1.md # 联赛排名专家
|
||||
├── h2h_v1.md # 历史交锋专家
|
||||
└── aggregator_v1.md # 终裁
|
||||
```
|
||||
@@ -129,6 +129,19 @@ agents/
|
||||
迭代 prompt 时:复制 `h2h_v1.md` → `h2h_v2.md`,改内容,传 `prompt_version: "v2"`。
|
||||
`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
|
||||
|
||||
三步:
|
||||
|
||||
+116
-18
@@ -4,9 +4,10 @@
|
||||
|
||||
| 数据源 | 用途 | 必需 Key | 说明 |
|
||||
|---|---|---|---|
|
||||
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
|
||||
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
|
||||
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
|
||||
| bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 |
|
||||
|
||||
> 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源,
|
||||
> 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。
|
||||
|
||||
### bzzoiro
|
||||
|
||||
@@ -17,11 +18,16 @@
|
||||
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
|
||||
```
|
||||
|
||||
### understat
|
||||
|
||||
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
|
||||
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
|
||||
- 通过"天级日期 + 队名归一"匹配已有比赛
|
||||
> ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 API 模式推测(如 `home_shots`/`away_shots`),
|
||||
> 未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。
|
||||
> 请提供一份 event 样例核对以下字段:
|
||||
> - 射门: `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 去重音。
|
||||
|
||||
**唯一键是归一后英文名**:`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
|
||||
|
||||
5 张表:
|
||||
12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。
|
||||
|
||||
```sql
|
||||
-- 联赛
|
||||
@@ -124,25 +186,57 @@ CREATE TABLE predictions (
|
||||
latency_ms INT,
|
||||
pred_home_goals FLOAT, pred_away_goals FLOAT,
|
||||
pred_1x2 VARCHAR(3), -- '1' / 'X' / '2'
|
||||
confidence FLOAT,
|
||||
subjective_confidence FLOAT, -- LLM 主观置信度(非概率)
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success', -- 'success' / 'failed' / 'degraded'
|
||||
match_kickoff_at TIMESTAMPTZ, -- 比赛时间
|
||||
prediction_created_at TIMESTAMPTZ, -- 预测创建时间
|
||||
prediction_cutoff_at TIMESTAMPTZ, -- 数据截止时间
|
||||
input_hash VARCHAR(64), -- 输入快照 hash
|
||||
reasoning TEXT,
|
||||
raw_response JSONB, -- LLM 完整原始响应
|
||||
agent_outputs JSONB, -- multi 模式: 5 份专家报告
|
||||
agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重
|
||||
created_at TIMESTAMPTZ,
|
||||
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
|
||||
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` 列做唯一键。
|
||||
|
||||
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 新比赛。
|
||||
|
||||
## 入库语义(幂等)
|
||||
|
||||
@@ -154,7 +248,9 @@ CREATE TABLE predictions (
|
||||
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
|
||||
- stats:只补空(`home_xg` 已有值时不覆盖)
|
||||
|
||||
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
|
||||
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
|
||||
|
||||
`task=standings` 追加快照(available_at=now,ON CONFLICT DO NOTHING);公开接口与切片均取每队 available_at 最新快照,支持回测还原历史榜单。
|
||||
|
||||
## 采集建议
|
||||
|
||||
@@ -167,9 +263,11 @@ 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"}'
|
||||
|
||||
# 3. xG 回填(可选,提升 xg agent 质量)
|
||||
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
|
||||
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
|
||||
# 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量)
|
||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}'
|
||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}'
|
||||
|
||||
# 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同
|
||||
```
|
||||
|
||||
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
|
||||
|
||||
+174
-11
@@ -13,22 +13,45 @@
|
||||
cp .env.example .env
|
||||
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
||||
|
||||
# 2. 启动(自动建表)
|
||||
# 2. 启动(自动执行数据库迁移)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. 验证
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
`docker-compose.yml` 仅 2 个服务:
|
||||
`docker-compose.yml` 包含 3 个服务:
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|---|---|---|
|
||||
| `postgres` | 5432 | PostgreSQL 16 |
|
||||
| `api` | 8000 | FastAPI 应用 |
|
||||
| `postgres` | 5433 | PostgreSQL 16 |
|
||||
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
|
||||
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
|
||||
|
||||
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
||||
|
||||
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
|
||||
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
|
||||
|
||||
## 生产上线检查清单
|
||||
|
||||
公网上线前逐项勾选。第 1–4 项由 `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
|
||||
@@ -63,7 +86,13 @@ cd frontend && npm install && npm run dev
|
||||
|---|---|---|---|
|
||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
|
||||
| `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_API_KEY` | ✅ | — | API Key |
|
||||
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
||||
@@ -71,10 +100,30 @@ cd frontend && npm install && npm run dev
|
||||
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
||||
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
||||
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
|
||||
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
|
||||
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
|
||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
|
||||
| `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 提供商配置示例
|
||||
|
||||
@@ -127,9 +176,16 @@ alembic revision --autogenerate -m "描述"
|
||||
alembic revision -m "描述"
|
||||
```
|
||||
|
||||
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
|
||||
无需手动运行。本地开发时需手动执行迁移。
|
||||
|
||||
已有迁移:
|
||||
- `0001_initial`: 初始 5 张表
|
||||
- `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 +199,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 | 管理会话有效期(天) |
|
||||
|
||||
+73
-18
@@ -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
|
||||
# 1. 克隆并进入项目
|
||||
cd Profeto
|
||||
|
||||
# 2. 安装依赖(含 dev)
|
||||
pip install -e ".[dev]"
|
||||
# 2. 创建虚拟环境
|
||||
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 \
|
||||
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
|
||||
-p 5432:5432 postgres:16-alpine
|
||||
|
||||
# 4. 配置环境变量
|
||||
# 6. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
|
||||
|
||||
# 5. 建表
|
||||
# 7. 建表
|
||||
alembic upgrade head
|
||||
|
||||
# 6. 启动 API(热重载)
|
||||
# 8. 启动 API(热重载)
|
||||
uvicorn src.api.app:app --reload
|
||||
|
||||
# 7. 启动前端(另一个终端)
|
||||
# 9. 启动前端(另一个终端)
|
||||
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
|
||||
```
|
||||
|
||||
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -35,27 +73,34 @@ Profeto/
|
||||
├── src/ # 后端源码
|
||||
│ ├── api/
|
||||
│ │ ├── routes/ # FastAPI 路由
|
||||
│ │ │ ├── leagues.py # 联赛/比赛查询
|
||||
│ │ │ ├── matches.py # 联赛/比赛查询
|
||||
│ │ │ ├── predict.py # 预测入口
|
||||
│ │ │ ├── ingest.py # 数据采集
|
||||
│ │ │ └── eval.py # 评估回填
|
||||
│ │ │ ├── eval.py # 评估回填
|
||||
│ │ │ └── backtest.py # 历史回测
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
|
||||
│ │ ├── schemas.py # Pydantic 模型
|
||||
│ │ └── app.py # FastAPI 工厂
|
||||
│ ├── db/
|
||||
│ │ ├── base.py # SQLAlchemy async engine + session
|
||||
│ │ └── models.py # 5 张表 ORM
|
||||
│ │ ├── models.py # 12 张表 ORM
|
||||
│ │ ├── repositories.py # 仓储层(查询封装)
|
||||
│ │ └── unit_of_work.py # 事务边界
|
||||
│ ├── data/
|
||||
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
|
||||
│ │ ├── understat.py # understat xG 回填
|
||||
│ │ ├── injuries.py # 伤停采集
|
||||
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库(唯一数据源)
|
||||
│ │ ├── normalize.py # 数据清洗契约
|
||||
│ │ ├── team_names.py # 队名归一化映射
|
||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
||||
│ │ ├── sources.py # 数据源注册表
|
||||
│ │ ├── key_ring.py # 数据源 Key 读取(DB 设置优先于 env)
|
||||
│ │ └── config.py # 联赛映射常量
|
||||
│ ├── llm/
|
||||
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
|
||||
│ │ ├── context_builder.py # 数据切片 + 拼接
|
||||
│ │ ├── predict.py # 预测入口(单/多模式分派)
|
||||
│ │ ├── eval.py # 评估统计
|
||||
│ │ ├── validation.py # LLM 输出严格校验
|
||||
│ │ ├── backtest.py # 回测执行
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── base.py # AgentSpec + run_agent
|
||||
│ │ │ └── orchestrator.py # 多 agent 编排
|
||||
@@ -65,16 +110,26 @@ Profeto/
|
||||
│ │ ├── form_v1.md
|
||||
│ │ ├── stats_v1.md
|
||||
│ │ ├── home_away_v1.md
|
||||
│ │ ├── injuries_v1.md
|
||||
│ │ ├── standings_v1.md
|
||||
│ │ ├── h2h_v1.md
|
||||
│ │ └── aggregator_v1.md
|
||||
│ └── core/
|
||||
│ └── config.py # pydantic-settings 配置
|
||||
│ ├── config.py # pydantic-settings 配置
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
||||
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
|
||||
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
||||
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
||||
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
||||
├── frontend/ # React 单页前端
|
||||
├── alembic/ # 数据库迁移
|
||||
│ └── versions/
|
||||
│ ├── 0001_initial.py
|
||||
│ └── 0002_agent_outputs.py
|
||||
│ ├── 0002_agent_outputs.py
|
||||
│ ├── 0003_injuries.py
|
||||
│ ├── 0004_snapshot_and_constraints.py
|
||||
│ ├── 0005_prediction_status_and_stats_provenance.py
|
||||
│ └── 0006_schema_model_drift_cleanup.py
|
||||
├── tests/ # 测试
|
||||
│ ├── test_core.py # 核心逻辑测试
|
||||
│ └── test_agents.py # 多 agent 测试
|
||||
@@ -118,7 +173,7 @@ class MockProvider:
|
||||
return LLMResponse(
|
||||
content="{}",
|
||||
parsed={"data_sufficiency": "high", "analysis": "ok",
|
||||
"home_edge": 0.5, "confidence": 0.8,
|
||||
"home_edge": 0.5, "subjective_confidence": 0.8,
|
||||
"key_evidence": ["证据"]},
|
||||
prompt_tokens=10, completion_tokens=5, latency_ms=100,
|
||||
)
|
||||
@@ -143,7 +198,7 @@ cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
|
||||
|
||||
### 3. 新增数据源
|
||||
|
||||
1. 在 `src/data/` 写采集模块(参考 `understat.py`)
|
||||
1. 在 `src/data/` 写采集模块(参考 `bzzoiro.py`)
|
||||
2. 在 `normalize.py` 加清洗函数
|
||||
3. 在 `context_builder.py` 加切片函数
|
||||
4. 在 `api/routes/ingest.py` 加端点
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
> ⚠️ **历史文档(已过时)**:伤停(injuries)数据源与 api-football 集成已移除,
|
||||
> 数据源收敛为 bzzoiro 唯一来源。本文仅作历史决策记录保留,
|
||||
> 现状请见 [05-data.md](05-data.md) 与 [01-architecture.md](01-architecture.md)。
|
||||
|
||||
---
|
||||
|
||||
"""检查 injuries 数据源 api-football 的响应结构。"""
|
||||
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
|
||||
{
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
# 多 Agent 协同架构
|
||||
|
||||
本项目采用 4 个专业 Agent 协同工作,各司其职。
|
||||
|
||||
## Agent 职责
|
||||
|
||||
### 🗄️ 数据库 Agent (DBA)
|
||||
**所有权:** `src/db/`, `alembic/`, 数据模型
|
||||
|
||||
职责:
|
||||
- 数据库 schema 设计与迁移 (Alembic)
|
||||
- ORM 模型定义与关系
|
||||
- 查询性能优化与索引
|
||||
- 数据完整性约束
|
||||
- 数据源入库逻辑
|
||||
|
||||
边界:
|
||||
- 不写业务路由
|
||||
- 不写前端代码
|
||||
- 对外暴露稳定的模型接口
|
||||
|
||||
---
|
||||
|
||||
### 🖥️ 前端 Agent (FE)
|
||||
**所有权:** `frontend/`
|
||||
|
||||
职责:
|
||||
- React 组件与页面
|
||||
- 用户界面与交互
|
||||
- 状态管理
|
||||
- API 调用与数据展示
|
||||
- 样式 (Tailwind)
|
||||
|
||||
边界:
|
||||
- 不直接操作数据库
|
||||
- 不写后端业务逻辑
|
||||
- 通过 REST API 与后端通信
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ 后端 Agent (BE)
|
||||
**所有权:** `src/api/`, `src/core/`, `src/llm/`, `src/data/`
|
||||
|
||||
职责:
|
||||
- API 路由与业务逻辑
|
||||
- LLM 预测管线 (上下文构建 → 调用 → 存储)
|
||||
- 数据采集与清洗
|
||||
- 定时任务调度
|
||||
- 多 Agent 编排 (LLM 专家系统)
|
||||
|
||||
边界:
|
||||
- 不写前端展示
|
||||
- 不直接配置部署环境
|
||||
- 对外暴露 REST API
|
||||
|
||||
---
|
||||
|
||||
### 🚀 部署 Agent (Ops)
|
||||
**所有权:** `Dockerfile`, `docker-compose.yml`, `.env.example`, 配置
|
||||
|
||||
职责:
|
||||
- 容器化与编排
|
||||
- 环境变量与配置管理
|
||||
- 服务健康检查
|
||||
- 日志与监控
|
||||
- 备份策略
|
||||
|
||||
边界:
|
||||
- 不写应用代码
|
||||
- 不修改业务逻辑
|
||||
- 通过配置文件影响行为
|
||||
|
||||
---
|
||||
|
||||
## 协作接口
|
||||
|
||||
### FE ↔ BE
|
||||
```
|
||||
REST API (OpenAPI 契约)
|
||||
- GET /api/v1/matches
|
||||
- POST /api/v1/predict
|
||||
- GET /api/v1/predictions
|
||||
- POST /api/v1/eval/settle
|
||||
- GET /api/v1/backtest
|
||||
```
|
||||
|
||||
### BE ↔ DBA
|
||||
```
|
||||
SQLAlchemy 模型接口
|
||||
- Match, Team, League, MatchStats, Prediction, Injury
|
||||
- AsyncSession 依赖注入
|
||||
```
|
||||
|
||||
### BE ↔ LLM
|
||||
```
|
||||
LLMProvider 抽象
|
||||
- OpenAI / Deepseek / Ollama / 任意兼容网关
|
||||
- JSON mode 结构化输出
|
||||
```
|
||||
|
||||
### Ops ↔ 所有
|
||||
```
|
||||
环境变量 + docker-compose
|
||||
- .env 配置注入
|
||||
- 服务发现 (localhost:8000 / 5432)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
1. 用户/编排者 发起任务
|
||||
2. 拆解任务分配给对应 Agent
|
||||
3. Agent 在各自所有权范围内工作
|
||||
4. 跨 Agent 变更通过接口契约协调
|
||||
5. 集成测试验证协作
|
||||
```
|
||||
|
||||
## 变更协调规则
|
||||
|
||||
| 变更类型 | 需要协调 |
|
||||
|---|---|
|
||||
| 新增 API 字段 | BE + FE |
|
||||
| 修改数据模型 | DBA + BE + FE |
|
||||
| 新增数据源 | DBA + BE |
|
||||
| 修改部署配置 | Ops + 所有 |
|
||||
| 修改 LLM Prompt | BE (独立) |
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
| [02-快速开始](02-quickstart.md) | 安装、启动、首次跑通全流程 |
|
||||
| [03-API 参考](03-api.md) | 全部 12 个端点、请求/响应示例、curl 全流程 |
|
||||
| [04-多 Agent 预测](04-agents.md) | 5 专家 + 终裁架构、执行语义、输出契约、prompt 版本化、如何新增 agent |
|
||||
| [05-数据层与数据库](05-data.md) | 三数据源、清洗契约、5 张表 schema、入库语义、采集建议 |
|
||||
| [05-数据层与数据库](05-data.md) | 三数据源、清洗契约、6 张表 schema、入库语义、采集建议 |
|
||||
| [06-部署](06-deployment.md) | Docker Compose、本地部署、环境变量、LLM 提供商配置、迁移、备份 |
|
||||
| [07-开发指南](07-development.md) | 项目结构、测试、常见开发任务(prompt/agent/数据源/联赛)、前端开发 |
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# 后端 Agent (BE)
|
||||
|
||||
你是 Profeto 项目的后端 Agent,负责所有业务逻辑和 API。
|
||||
|
||||
## 所有权
|
||||
- `src/api/` — FastAPI 路由、应用工厂、schemas
|
||||
- `src/core/` — 配置、HTTP 客户端、重试工具
|
||||
- `src/llm/` — LLM 预测核心(provider、上下文构建、多 Agent 编排、评估、回测)
|
||||
- `src/data/` — 数据采集源、规范化、数据源协议
|
||||
|
||||
## 当前 API 路由
|
||||
|
||||
| 方法 | 路径 | 作用 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) |
|
||||
| GET | `/api/v1/matches/{id}` | 单场详情 |
|
||||
| GET | `/api/v1/leagues` | 联赛列表 |
|
||||
| POST | `/api/v1/predict` | LLM 预测(single/multi) |
|
||||
| GET | `/api/v1/predictions` | 预测历史 |
|
||||
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
|
||||
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 |
|
||||
| POST | `/api/v1/ingest/understat` | 回填 xG |
|
||||
| POST | `/api/v1/ingest/injuries` | 采集伤停 |
|
||||
| POST | `/api/v1/eval/settle` | 回填实际结果 |
|
||||
| GET | `/api/v1/eval/summary` | 准确率汇总 |
|
||||
| POST | `/api/v1/backtest` | 回测 |
|
||||
|
||||
## 核心模块
|
||||
|
||||
### LLM 预测管线
|
||||
```
|
||||
match_id → build_context (数据切片) → LLM 调用 → 解析 → 存储
|
||||
```
|
||||
|
||||
### 多 Agent 编排 (LLM 专家系统)
|
||||
```
|
||||
5 专家 (form/stats/home_away/injuries/h2h) → 终裁 → 最终预测
|
||||
```
|
||||
|
||||
### 数据源协议
|
||||
```python
|
||||
class DataSource(Protocol):
|
||||
name: str
|
||||
async def ingest(db, **kwargs) -> dict: ...
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
- RESTful API,OpenAPI 文档自动生成
|
||||
- 异步优先(asyncpg + async httpx)
|
||||
- 错误分层(404/502/500)
|
||||
- 预测结果持久化
|
||||
|
||||
## 对外接口
|
||||
- 为 FE Agent 提供稳定 REST API
|
||||
- 使用 DBA Agent 提供的 ORM 模型
|
||||
- 遵循 Ops Agent 定义的环境配置
|
||||
|
||||
## 不做
|
||||
- 不写前端展示
|
||||
- 不修改 schema(通过 DBA)
|
||||
- 不配置部署(通过 Ops)
|
||||
@@ -1,36 +0,0 @@
|
||||
# 数据库 Agent (DBA)
|
||||
|
||||
你是 Profeto 项目的数据库 Agent,负责所有数据层工作。
|
||||
|
||||
## 所有权
|
||||
- `src/db/` — SQLAlchemy 引擎、会话、Base
|
||||
- `src/db/models.py` — 所有 ORM 模型
|
||||
- `alembic/` — 数据库迁移脚本
|
||||
- `src/data/normalize.py` — 数据规范化契约
|
||||
- `src/data/match_lookup.py` — 比赛匹配辅助函数
|
||||
|
||||
## 当前 Schema (5 表 + 1 新增)
|
||||
|
||||
```
|
||||
leagues (id, code, name, country)
|
||||
teams (id, name, name_zh, team_type)
|
||||
matches (id, league_id, season, home_team_id, away_team_id, match_date, match_status, goals..., stats...)
|
||||
match_stats (match_id PK, xg, shots, corners, possession, cards...)
|
||||
predictions (id, match_id, provider, model, prompt_version, tokens, pred_1x2, confidence, raw_response, mode, agent_outputs, actual_..., settled)
|
||||
injuries (id, player_id, player_name, team_id, fixture_id, injury_type, reason, dates...)
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
- 所有字段可空性明确(业务可空 vs 必须 NOT NULL)
|
||||
- 外键级联删除合理(match 删除 → stats/predictions 级联)
|
||||
- 索引覆盖高频查询(按日期、按球队、按联赛)
|
||||
- 迁移脚本幂等
|
||||
|
||||
## 对外接口
|
||||
- 提供稳定的 ORM 模型供 BE Agent 使用
|
||||
- 变更模型时通知 BE Agent 更新查询
|
||||
|
||||
## 不做
|
||||
- 不写 API 路由
|
||||
- 不写前端代码
|
||||
- 不修改业务逻辑
|
||||
@@ -1,46 +0,0 @@
|
||||
# 前端 Agent (FE)
|
||||
|
||||
你是 Profeto 项目的前端 Agent,负责所有用户界面工作。
|
||||
|
||||
## 所有权
|
||||
- `frontend/` — 整个前端项目
|
||||
- React + TypeScript + Vite + Tailwind CSS
|
||||
|
||||
## 当前结构
|
||||
```
|
||||
frontend/src/
|
||||
├── main.tsx # 入口
|
||||
├── App.tsx # 根组件 + ErrorBoundary
|
||||
├── index.css # 全局样式
|
||||
├── components/
|
||||
│ └── ErrorBoundary.tsx
|
||||
└── pages/
|
||||
└── Matches.tsx # 当前唯一页面(比赛列表 + 预测)
|
||||
```
|
||||
|
||||
## 当前 API 契约(来自 BE Agent)
|
||||
|
||||
| 端点 | 用途 |
|
||||
|---|---|
|
||||
| `GET /api/v1/matches?league=&status=&limit=` | 比赛列表 |
|
||||
| `POST /api/v1/predict` body: `{match_id, mode}` | 触发预测 |
|
||||
| `GET /api/v1/predictions?match_id=&limit=` | 预测历史 |
|
||||
| `POST /api/v1/backtest` | 回测(管理用) |
|
||||
| `POST /api/v1/eval/settle` | 回填结果(管理用) |
|
||||
| `GET /api/v1/eval/summary` | 准确率统计 |
|
||||
|
||||
## 设计原则
|
||||
- 单页应用,预测面板为核心
|
||||
- 展示为主,控制操作最小化
|
||||
- 响应式(移动端优先)
|
||||
- 加载/错误/空状态完整
|
||||
|
||||
## 对外依赖
|
||||
- 通过 REST API 与 BE Agent 通信
|
||||
- 不直接访问数据库
|
||||
- 不修改 API 契约(需与 BE 协商)
|
||||
|
||||
## 不做
|
||||
- 不写后端业务逻辑
|
||||
- 不操作数据库
|
||||
- 不修改部署配置
|
||||
@@ -1,47 +0,0 @@
|
||||
# 部署 Agent (Ops)
|
||||
|
||||
你是 Profeto 项目的部署 Agent,负责基础设施和运维。
|
||||
|
||||
## 所有权
|
||||
- `Dockerfile` — API 服务镜像
|
||||
- `docker-compose.yml` — 服务编排
|
||||
- `.env.example` — 环境变量模板
|
||||
- `pyproject.toml` — Python 依赖声明
|
||||
|
||||
## 当前服务架构
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres: # PostgreSQL 16, port 5432
|
||||
api: # FastAPI, port 8000, depends on postgres
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL 连接 |
|
||||
| `LLM_PROVIDER` | 提供商标识 |
|
||||
| `LLM_API_KEY` | API 密钥 |
|
||||
| `LLM_BASE_URL` | API 地址 |
|
||||
| `LLM_MODEL` | 模型名 |
|
||||
| `LLM_SPECIALIST_MODEL` | 专家模型(多 Agent 模式) |
|
||||
| `LLM_AGGREGATOR_MODEL` | 终裁模型 |
|
||||
| `BZZOIRO_KEY` | bzzoiro 数据源密钥 |
|
||||
| `API_FOOTBALL_KEY` | api-football 密钥 |
|
||||
| `CORS_ORIGINS` | 跨域来源 |
|
||||
|
||||
## 设计原则
|
||||
- 配置与代码分离(.env 不入库)
|
||||
- 健康检查(postgres + api)
|
||||
- 数据持久化(postgres volume)
|
||||
- 最小镜像(python:3.11-slim)
|
||||
|
||||
## 对外接口
|
||||
- 为 BE Agent 提供运行环境
|
||||
- 为 FE Agent 提供反向代理目标(同端口或 nginx)
|
||||
|
||||
## 不做
|
||||
- 不写应用代码
|
||||
- 不修改业务逻辑
|
||||
- 不变更 API 契约
|
||||
@@ -0,0 +1,5 @@
|
||||
# 前端构建时不需要的文件
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
@@ -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;"]
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+43
-43
@@ -9,7 +9,8 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -766,9 +767,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,6 +814,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -915,9 +922,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -932,9 +936,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -949,9 +950,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -966,9 +964,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +978,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1000,9 +992,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1017,9 +1006,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1034,9 +1020,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1051,9 +1034,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1068,9 +1048,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1085,9 +1062,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,9 +1076,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1119,9 +1090,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2287,6 +2255,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
+126
-9
@@ -1,18 +1,135 @@
|
||||
/**
|
||||
* 主应用入口
|
||||
*
|
||||
* 页面结构:
|
||||
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
|
||||
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
|
||||
*
|
||||
* 导航策略:
|
||||
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
||||
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||
*/
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Matches from './pages/Matches'
|
||||
import Standings from './pages/Standings'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
/** 报眉日期行 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
|
||||
比赛 / 预测
|
||||
</Link>
|
||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||
评估
|
||||
</Link>
|
||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
数据由 bzzoiro 提供 · 仅供研究参考
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
|
||||
积分榜
|
||||
</Link>
|
||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
||||
评估
|
||||
</Link>
|
||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white border-b px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-blue-700">⚽ 先知 Profeto</h1>
|
||||
<span className="text-sm text-gray-500">足球 LLM 预测服务</span>
|
||||
</header>
|
||||
<main className="max-w-5xl mx-auto p-6">
|
||||
<Matches />
|
||||
</main>
|
||||
</div>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
|
||||
{adminRoutes.map(route => (
|
||||
<Route key={route.path} path={route.path} element={route.element}>
|
||||
{route.children.map(child => (
|
||||
<Route
|
||||
key={child.path ?? 'index'}
|
||||
index={child.index}
|
||||
path={child.path}
|
||||
element={child.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Admin 后台 - 布局组件(报刊风)
|
||||
*
|
||||
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
|
||||
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||
import { fetchHealth } from './dal'
|
||||
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
||||
import Login from './Login'
|
||||
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
||||
|
||||
// 线条风格 SVG 图标组件
|
||||
function Icon({ name }: { name: string }) {
|
||||
const common = 'nav-icon'
|
||||
switch (name) {
|
||||
case 'collection':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
case 'chart':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
case 'target':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
)
|
||||
case 'repeat':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
case 'eval':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
case 'monitor':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
case 'settings':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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>
|
||||
)
|
||||
case 'logs':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 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 border-b border-ink-900 px-5 py-4">
|
||||
<h1 className="font-brush text-xl text-ink-900">先知</h1>
|
||||
</div>
|
||||
|
||||
{/* 导航:分组 + 小节标题 */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
||||
{/* 仪表盘独立(始终第一项) */}
|
||||
<NavLink
|
||||
to="/admin"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon 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={({ isActive }) =>
|
||||
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
|
||||
*
|
||||
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
|
||||
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
||||
import type { LLMAgentConfig } from './types'
|
||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
||||
|
||||
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 onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{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 onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
|
||||
{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 className="text-xs text-ink-500">{f.label}</label>
|
||||
<div>
|
||||
<input
|
||||
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="field 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 onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
||||
恢复继承
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Admin 后台 - 登录页(报刊风)
|
||||
*
|
||||
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ApiError, login } from './api'
|
||||
|
||||
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-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
>
|
||||
<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"
|
||||
className="field mt-2 w-full"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p 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
|
||||
type="submit"
|
||||
disabled={!password || submitting}
|
||||
className="btn btn-solid mt-5 w-full justify-center"
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -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 后台侧栏底部「返回前台版面」链接可回到 `/`。
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Admin 后台 - 配置项行组件(报刊风)
|
||||
*
|
||||
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
|
||||
* 编辑态:输入框 + 保存/取消
|
||||
* 由数据源页与 LLM 配置页共用。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DataSourceSetting } from './types'
|
||||
import { Badge, Spinner } from './components'
|
||||
|
||||
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}`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSave(value)}
|
||||
disabled={!value.trim() || busy}
|
||||
className="btn btn-solid btn-sm"
|
||||
>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
|
||||
{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 onClick={onEdit} disabled={busy} className="btn btn-sm">
|
||||
{setting.configured ? '更换' : '配置'}
|
||||
</button>
|
||||
{setting.origin === 'db' && (
|
||||
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
|
||||
回落 .env
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端(门面)
|
||||
*
|
||||
* 鉴权:通过 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 }
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||
*
|
||||
* 与前台共用同一套设计语言:
|
||||
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { ApiError } from './api'
|
||||
|
||||
// ── 卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
// ── 统计卡片 ────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 状态标记 ────────────────────────────────────────────────────
|
||||
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 数据表格 ────────────────────────────────────────────────────
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 小节标题:同前台 section-head ───────────────────────────────
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
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'
|
||||
|
||||
return (
|
||||
<div 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 Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 骨架占位 ────────────────────────────────────────────────────
|
||||
|
||||
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />
|
||||
}
|
||||
|
||||
|
||||
/** 空状态文本 */
|
||||
export function EmptyText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-ink-400">
|
||||
{text}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Admin 后台 - 数据访问层
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
IngestJob,
|
||||
MatchDetailOut,
|
||||
MatchContextOut,
|
||||
AdminStats,
|
||||
} 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 as any)?.length ?? 0 : 0,
|
||||
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
|
||||
db_tables: [], // 后端暂无表统计端点
|
||||
last_collection: [], // 后端暂无采集历史端点
|
||||
recent_errors: [], // 后端暂无错误日志端点
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||
const body: Record<string, any> = {
|
||||
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(`${API_BASE}/ingest/bzzoiro`, body)
|
||||
}
|
||||
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{
|
||||
match_id: req.match_id,
|
||||
mode: req.mode || 'multi',
|
||||
},
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<any[]> {
|
||||
const res = await api.get<any>(`${API_BASE}/predictions?limit=${limit}`)
|
||||
return Array.isArray(res) ? res : (res as any)?.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<any>(`${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<any> {
|
||||
return api.post(`${API_BASE}/eval/settle`, {
|
||||
prediction_id,
|
||||
home_goals,
|
||||
away_goals,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 健康检查 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<any> {
|
||||
try {
|
||||
return await api.get<any>('/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`)
|
||||
}
|
||||
|
||||
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
|
||||
|
||||
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 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<any> {
|
||||
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
||||
try {
|
||||
return await api.post(`${API_BASE}/admin/llm/ping`, {})
|
||||
} catch {
|
||||
// 回退到旧方式(兼容)
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{ match_id: matchId || 1, mode: 'single' },
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 使用统计 — 从预测列表聚合
|
||||
*/
|
||||
export async function fetchLLMUsageStats(): Promise<any> {
|
||||
try {
|
||||
const predictions = await fetchPredictions(50)
|
||||
const total = predictions.length
|
||||
const successCount = predictions.filter((p: any) => p.pred_1x2).length
|
||||
return {
|
||||
total_predictions: total,
|
||||
avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
recent_predictions: predictions.slice(0, 10).map((p: any) => ({
|
||||
id: p.id,
|
||||
match_id: p.match_id,
|
||||
model: p.model,
|
||||
created_at: p.created_at,
|
||||
status: p.pred_1x2 ? 'success' : 'failed',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total_predictions: 0,
|
||||
avg_latency_ms: 0,
|
||||
success_rate: 0,
|
||||
recent_predictions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* P2-9 修复: 获取系统配置列表,从后端 /admin/settings 读取真实值(脱敏)。
|
||||
* 字段名对齐 Config.tsx 中使用的 { key, value_masked, description, is_sensitive } 格式。
|
||||
*/
|
||||
export async function fetchSystemConfig(): Promise<any[]> {
|
||||
try {
|
||||
const settings = await fetchSettings()
|
||||
return settings.map(s => ({
|
||||
key: s.key,
|
||||
value_masked: s.masked,
|
||||
description: s.description,
|
||||
is_sensitive: s.sensitive,
|
||||
}))
|
||||
} catch {
|
||||
// 后端不可用时返回空列表,Config.tsx 会显示空状态
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源健康/最近采集状态(只读,不触发采集)
|
||||
*/
|
||||
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
|
||||
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务状态轮询(单任务)
|
||||
*/
|
||||
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
||||
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 比赛详情(含最近预测摘要)
|
||||
*/
|
||||
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`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理区统计(只读):近 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`)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Admin 导航单源(D6)。
|
||||
*
|
||||
* 背景: 侧栏(NAV_SECTIONS)、命令面板(NAV_PAGES)、面包屑(ROUTE_LABELS)
|
||||
* 此前各维护一份平行清单,已出现漂移 —— data-pipeline 不在侧栏、
|
||||
* 「系统」vs「系统设置」命名不一致、monitoring/eval 两处顺序相反。
|
||||
*
|
||||
* 现在只允许维护 NAV_ITEMS 一份,其余视图一律由此派生;
|
||||
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
/** 命令面板中的分组名(展示原样) */
|
||||
group: string
|
||||
/** 侧栏图标名(见 AdminLayout 的 Icon) */
|
||||
icon: string
|
||||
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
|
||||
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: 'chart' },
|
||||
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true },
|
||||
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' },
|
||||
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' },
|
||||
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' },
|
||||
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' },
|
||||
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
||||
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
||||
]
|
||||
|
||||
/** 命令面板条目(原 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]),
|
||||
)
|
||||
|
||||
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
|
||||
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
|
||||
系统: '系统设置',
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏分组(原 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) {
|
||||
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group
|
||||
if (!titles.includes(title)) titles.push(title)
|
||||
}
|
||||
return titles.map(title => ({
|
||||
title,
|
||||
items: sidebarItems
|
||||
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title)
|
||||
.map(({ to, label, icon }) => ({ to, label, icon })),
|
||||
}))
|
||||
})()
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Admin 后台 - 回测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 回测配置: 联赛(下拉)、日期范围、场数、模式
|
||||
* - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度
|
||||
* - 逐场明细: 实际比分 vs 预测比分,正误标记
|
||||
* - 模型评估: 各模型历史准确率(/eval/summary)
|
||||
*
|
||||
* 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 导出回测明细为 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="field 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">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<input
|
||||
type="text"
|
||||
value="多专家 (5 路 + 终裁)"
|
||||
readOnly
|
||||
className="field 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="field w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
{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 onClick={() => exportCsv(result.results)} className="btn btn-sm">导出 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 onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||
*
|
||||
* 三个采集任务:
|
||||
* events — 比赛日程与比分
|
||||
* standings — 联赛积分榜
|
||||
* stats — 已完赛比赛详细统计回填
|
||||
* all — 依次执行以上三项
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal'
|
||||
import type { IngestJob, League } from '../types'
|
||||
import type { CollectionRequest } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
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])
|
||||
|
||||
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()
|
||||
}
|
||||
} catch { /* 单次轮询失败不影响后续 */ }
|
||||
}
|
||||
tick()
|
||||
pollRef.current = setInterval(tick, 3_000)
|
||||
}, [stopPolling])
|
||||
|
||||
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 = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
|
||||
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={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||
task === t.value
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: '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">联赛</label>
|
||||
<select
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="field 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">比赛状态</label>
|
||||
<select
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="field 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">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* standings 任务专用: 赛季 */}
|
||||
{(task === 'standings') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="如 2026-2027"
|
||||
className="field 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="field 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 type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
{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">
|
||||
<div className="flex items-center gap-2 text-xs text-ink-700">
|
||||
<Spinner />
|
||||
<span>任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…</span>
|
||||
</div>
|
||||
<p className="text-2xs text-ink-400">
|
||||
后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'done' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
||||
</div>
|
||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'error' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-press">采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p>
|
||||
{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="采集任务说明" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Admin 后台 - 系统配置管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
|
||||
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
||||
* - 配置修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchSystemConfig } from '../dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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 loadConfig = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchSystemConfig()
|
||||
setConfig(data)
|
||||
} catch {
|
||||
setConfig([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
fetchAuthState()
|
||||
.then(s => setPasswordOrigin(s.password_origin ?? null))
|
||||
.catch(() => setPasswordOrigin(null))
|
||||
}, [loadConfig])
|
||||
|
||||
async function handleChangePassword(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-6">
|
||||
<SectionHeader
|
||||
title="系统配置"
|
||||
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
||||
/>
|
||||
|
||||
{/* 登录与鉴权 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="登录与鉴权"
|
||||
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
|
||||
/>
|
||||
<CardBody>
|
||||
<Alert kind="ok" title="已通过密码登录" />
|
||||
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
|
||||
密码初始来自服务器 <code className="font-mono">.env</code> 的{' '}
|
||||
<code className="font-mono">ADMIN_PASSWORD</code>(启动时自动转为哈希),在下方修改后以{' '}
|
||||
<code className="font-mono">scrypt</code> 哈希安全存入数据库并立即生效,明文不再留存。
|
||||
密码即会话签名密钥,修改后所有已登录会话失效,需用新密码重新登录。
|
||||
脚本直连接口可改用 <code className="font-mono">ADMIN_API_KEY</code>(请求头 X-API-Key)。
|
||||
</p>
|
||||
{passwordOrigin && (
|
||||
<p className="mt-2 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="mt-5 space-y-3 border-t border-ink-200 pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPwd}
|
||||
onChange={e => setCurrentPwd(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPwd}
|
||||
onChange={e => setNewPwd(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPwd}
|
||||
onChange={e => setConfirmPwd(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="field 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
|
||||
type="submit"
|
||||
disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}
|
||||
className="btn btn-solid btn-sm flex-shrink-0"
|
||||
>
|
||||
{pwdBusy ? (<><Spinner /> 修改中</>) : '修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 快速导航 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="/admin/data-sources"
|
||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-serif text-sm font-bold text-ink-900">数据源配置</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-500">管理采集源 API Key</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/llm-config"
|
||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-serif text-sm font-bold text-ink-900">LLM 配置</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-500">管理模型连接与统计</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 配置列表 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="当前配置"
|
||||
description="脱敏展示,实际值在服务器 .env 文件中"
|
||||
action={
|
||||
<button onClick={loadConfig} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-3 px-4 sm:px-5">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<SkeletonBlock key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : config.length > 0 ? (
|
||||
<div>
|
||||
{config.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex flex-col gap-1 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_minmax(0,2fr)] sm:items-baseline sm:gap-4 sm:px-5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-800">{item.key}</span>
|
||||
{item.is_sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="break-all font-mono text-2xs text-ink-500">{item.value_masked}</div>
|
||||
<div className="text-2xs text-ink-400">{item.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-xs text-ink-400">无法加载配置信息</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 修改指南 */}
|
||||
<Card>
|
||||
<CardHeader title="修改配置指南" />
|
||||
<CardBody className="space-y-5">
|
||||
<div>
|
||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">通过 SSH 修改 .env</h4>
|
||||
<pre className="overflow-x-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-700">
|
||||
{`# 连接到部署主机
|
||||
ssh user@your-server-ip
|
||||
|
||||
# 进入项目目录
|
||||
cd /vol2/1000/Docker/Profeto
|
||||
|
||||
# 编辑 .env 文件
|
||||
nano .env
|
||||
|
||||
# 修改后重启后端服务
|
||||
docker compose restart api
|
||||
|
||||
# 查看日志确认生效
|
||||
docker compose logs -f api`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">常用配置项说明</h4>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
|
||||
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
|
||||
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
|
||||
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
|
||||
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
|
||||
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
|
||||
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="flex items-start gap-2.5">
|
||||
<code className="flex-shrink-0 border border-ink-200 bg-paper-100 px-1.5 py-0.5 font-mono text-2xs text-ink-800">
|
||||
{key}
|
||||
</code>
|
||||
<span className="text-xs leading-relaxed text-ink-600">{desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
*
|
||||
* 展示:
|
||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||
* - 近期预测活动(24h / 7d / 总计)
|
||||
* - 快捷操作入口(带工作流引导)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
||||
|
||||
/** 工作流步骤卡片 */
|
||||
const STEPS = [
|
||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const [s, i, d] = await Promise.allSettled([
|
||||
fetchAdminStats(),
|
||||
fetchIngestStatus(),
|
||||
fetchDashboard(),
|
||||
])
|
||||
if (s.status === 'fulfilled') setStats(s.value)
|
||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||
if (d.status === 'fulfilled') setDash(d.value)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||
const bzzoiro = sourceByName['bzzoiro']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((s, i) => (
|
||||
<a
|
||||
key={s.to}
|
||||
href={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>
|
||||
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 数据源健康一览 ── */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="数据源健康"
|
||||
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||
/>
|
||||
<CardBody className="px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{[
|
||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||
].map(({ name, label, st }) => {
|
||||
const hasData = st && st.recent_count > 0
|
||||
const keyOk = st?.key_configured !== false
|
||||
return (
|
||||
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||
<span className="text-xs font-medium text-ink-700">{label}</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>
|
||||
) : (
|
||||
<span className="text-press">未配置 Key</span>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 近期预测活动 ── */}
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
||||
<CardBody>
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 已入库联赛 ── */}
|
||||
{dash && dash.leagues.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
<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>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Admin 后台 - 数据完整性分析页
|
||||
*
|
||||
* 回答三个问题:
|
||||
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||
* 2. 字段是否齐全(每张统计表各字段非空率)
|
||||
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchDataCompleteness } from '../dal'
|
||||
import type { DataCompletenessResponse } from '../dal'
|
||||
import {
|
||||
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||
ProgressBar, Spinner, EmptyState,
|
||||
} from '../components'
|
||||
|
||||
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-emerald-500'
|
||||
if (pct >= 50) return 'bg-amber-500'
|
||||
return 'bg-rose-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)
|
||||
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])
|
||||
|
||||
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
||||
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={
|
||||
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{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
|
||||
onClick={() => scrollToLeague(action.code)}
|
||||
className="btn btn-sm whitespace-nowrap"
|
||||
>
|
||||
定位
|
||||
</button>
|
||||
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
||||
{action.label}
|
||||
</a>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Admin 后台 - 数据管线管理页(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 采集失败记录列表(可重试)
|
||||
* - 数据质量检查结果
|
||||
* - 手动触发质量检查
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataQuality,
|
||||
runDataQualityCheck,
|
||||
fetchIngestFailures,
|
||||
retryIngestFailure,
|
||||
} from '../dal'
|
||||
import type { IngestFailureItem, DataQualityCheckItem } from '../dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
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 onClick={handleRunCheck} disabled={running} className="btn btn-sm">
|
||||
{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 onClick={() => handleRetry(f.id)} className="btn btn-sm">
|
||||
重试
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataSourceStatuses,
|
||||
fetchIngestStatus,
|
||||
fetchAdminStats,
|
||||
fetchKeyRingStatus,
|
||||
resetKeyRingCooldown,
|
||||
updateSetting,
|
||||
clearSetting,
|
||||
testDataSourceConnection,
|
||||
} from '../dal'
|
||||
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
||||
import type { KeyRingStatusResponse } from '../dal'
|
||||
import SettingRow from '../SettingRow'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '暂无记录'
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataSourcesPage() {
|
||||
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
|
||||
const [ingestLoading, setIngestLoading] = useState(true)
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
|
||||
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
|
||||
|
||||
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)
|
||||
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
||||
const [ringLoading, setRingLoading] = useState(false)
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
setSources(await fetchDataSourceStatuses())
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
||||
setSources([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
|
||||
const loadIngest = useCallback(async () => {
|
||||
setIngestLoading(true)
|
||||
try {
|
||||
const { sources } = await fetchIngestStatus()
|
||||
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setIngestLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 管理区统计(只读)
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
setStats(await fetchAdminStats())
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Key Ring 状态(只读)
|
||||
const loadKeyRing = useCallback(async () => {
|
||||
setRingLoading(true)
|
||||
try {
|
||||
setKeyRing(await fetchKeyRingStatus())
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setRingLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSources()
|
||||
loadIngest()
|
||||
loadStats()
|
||||
loadKeyRing()
|
||||
}, [loadSources, loadIngest, loadStats, loadKeyRing])
|
||||
|
||||
async function handleTest(sourceName: string) {
|
||||
setTestingSource(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
|
||||
try {
|
||||
const result = await testDataSourceConnection(sourceName)
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
|
||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
|
||||
} finally {
|
||||
setTestingSource(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染数据源健康块(最近采集 + 异常提示)
|
||||
function renderHealth(sourceName: string) {
|
||||
const st = ingestStats[sourceName]
|
||||
if (!st) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近成功采集</span>
|
||||
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
|
||||
const issues: string[] = []
|
||||
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
|
||||
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
|
||||
if (st.last_failure) issues.push('近期有采集失败')
|
||||
return (
|
||||
<div className="space-y-1.5 border-t border-ink-200 pt-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">最近成功采集</span>
|
||||
<span className="text-ink-600">{ago}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-400">已入库(近期)</span>
|
||||
<span className="text-ink-600">{st.recent_count.toLocaleString()} 条</span>
|
||||
</div>
|
||||
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
|
||||
{issues.length > 0 && (
|
||||
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
|
||||
{issues.join(' / ')} — 请前往「数据采集」补采
|
||||
</p>
|
||||
)}
|
||||
{st.last_failure && (
|
||||
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
|
||||
最近失败: {st.last_failure.detail.slice(0, 60)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSources()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetCooldown() {
|
||||
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] : '重置失败' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据源管理"
|
||||
description="数据采集源的 API 配置、健康状态与连通性测试。"
|
||||
/>
|
||||
|
||||
{loadError && (
|
||||
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
||||
)}
|
||||
|
||||
{/* 数据源卡片 */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<Card key={i}>
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
<SkeletonBlock className="h-3 w-32" />
|
||||
<SkeletonBlock className="h-8 w-full" />
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sources.map(source => {
|
||||
const result = testResults[source.name]
|
||||
const cardKeys = source.settings.map(s => s.key)
|
||||
return (
|
||||
<Card key={source.name}>
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
|
||||
<Badge status={source.key_configured ? 'success' : 'error'}>
|
||||
{source.key_configured ? '已就绪' : '缺配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
||||
|
||||
{source.settings.length > 0 ? (
|
||||
<div>
|
||||
{source.settings.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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-400">无需 API Key</p>
|
||||
)}
|
||||
|
||||
{rowNotice && cardKeys.includes(rowNotice.key) && (
|
||||
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
||||
)}
|
||||
|
||||
{/* 数据源健康:最近采集 + 异常提示 */}
|
||||
{renderHealth(source.name)}
|
||||
|
||||
{result && testingSource !== source.name && (
|
||||
<Alert
|
||||
kind={result.ok ? 'ok' : 'error'}
|
||||
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
|
||||
message={result.ok ? undefined : result.detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleTest(source.name)}
|
||||
disabled={testingSource === source.name}
|
||||
className="btn btn-sm w-full"
|
||||
>
|
||||
{testingSource === source.name ? (<><Spinner /> 测试中</>) : '测试连接'}
|
||||
</button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key 轮换环状态 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="API Key 轮换环"
|
||||
description={keyRing?.has_multiple
|
||||
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
|
||||
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
|
||||
}
|
||||
action={
|
||||
<button
|
||||
onClick={handleResetCooldown}
|
||||
disabled={ringLoading}
|
||||
className="btn-sm btn-outline"
|
||||
>
|
||||
重置冷却
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{ringLoading && !keyRing ? (
|
||||
<SkeletonBlock className="h-10 w-full" />
|
||||
) : 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-amber-500' : 'bg-emerald-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && (
|
||||
<span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||
)}
|
||||
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
在「Bzzoiro」配置项中用<b>逗号 / 分号 / 换行</b>分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key;
|
||||
全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 近期活动统计(只读) */}
|
||||
{stats && stats.predictions && (
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
保存的配置存于数据库 <code className="font-mono">app_settings</code> 并<b>立即生效</b>,优先于 <code className="font-mono">.env</code>;「回落 .env」删除覆盖值。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。
|
||||
</p>
|
||||
<p className="border-l-2 border-ink-300 pl-3">
|
||||
「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Admin 后台 - 评估汇总页(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 按 provider / model / prompt_version / mode / league_code 筛选
|
||||
* - 展示汇总统计卡片(已结算/已评估/跳过数)
|
||||
* - 准确率对比表格
|
||||
* - 空态与加载态
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { EvalSummary } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components'
|
||||
|
||||
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 border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
/>
|
||||
</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 border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
/>
|
||||
</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 border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">模式</span>
|
||||
<select
|
||||
value={filters.mode}
|
||||
onChange={handleChange('mode')}
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
|
||||
>
|
||||
<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 border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
|
||||
>
|
||||
<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 onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? '加载中…' : '应用筛选'}
|
||||
</button>
|
||||
<button onClick={handleReset} disabled={loading} className="btn btn-sm btn-ghost">
|
||||
重置
|
||||
</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
|
||||
columns={[
|
||||
{ key: 'provider', label: '提供商' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'prompt_version', label: '版本', render: (row: any) => (
|
||||
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
|
||||
) },
|
||||
{ key: 'total', label: '评估条数' },
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
|
||||
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => (
|
||||
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
|
||||
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
|
||||
row.calibration ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
|
||||
<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: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
emptyText="暂无评估数据"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Admin 后台 - LLM 配置管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
|
||||
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
|
||||
* - 显示 LLM 使用统计(从预测记录聚合)
|
||||
* - 可用模型列表
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
|
||||
import type { LLMUsageStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
import type { DataSourceSetting } from '../types'
|
||||
|
||||
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// LLM 连接配置(运行时配置,DB 覆盖 .env)
|
||||
const [llmSettings, setLlmSettings] = 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)
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchLLMUsageStats()
|
||||
setStats(data)
|
||||
} catch {
|
||||
setStats(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true)
|
||||
try {
|
||||
const all = await fetchSettings()
|
||||
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
|
||||
} catch {
|
||||
setLlmSettings([])
|
||||
} finally {
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
loadSettings()
|
||||
}, [loadStats, loadSettings])
|
||||
|
||||
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
|
||||
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||
const r = await fetchLLMModels()
|
||||
if (!r.ok) throw new Error(r.detail)
|
||||
return r.models
|
||||
}, [])
|
||||
|
||||
async function handleSave(key: string, value: string) {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(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)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
await testLLMConnection()
|
||||
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
|
||||
setTestResult({ success: false, message: msg })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="LLM 配置"
|
||||
description="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* LLM 连接配置 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="保存到数据库并立即生效,优先于服务器 .env"
|
||||
action={
|
||||
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
|
||||
{settingsLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rowNotice && (
|
||||
<div className="mt-3">
|
||||
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
模式: 多专家 (5 路 + 终裁)。填入可连通的 OpenAI 兼容服务(如 DeepSeek、
|
||||
智谱、通义或任意网关)后点下方「测试 LLM 连接」验证。
|
||||
</p>
|
||||
|
||||
{/* 测试连接 */}
|
||||
{testResult && (
|
||||
<div className="mt-4">
|
||||
<Alert
|
||||
kind={testResult.success ? 'ok' : 'error'}
|
||||
title={testResult.success ? '连接正常' : '连接失败'}
|
||||
message={testResult.success ? undefined : testResult.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
||||
{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 onClick={loadStats} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<SkeletonBlock className="h-16 w-full" />
|
||||
<SkeletonBlock className="h-16 w-full" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<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">
|
||||
{stats.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">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均延迟</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">
|
||||
{stats.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>
|
||||
|
||||
{/* 专家与终裁独立配置 */}
|
||||
<AgentLLMCard />
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
<CardHeader title="最近预测记录" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{loading ? (
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3].map(i => (
|
||||
<SkeletonBlock key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : stats && stats.recent_predictions.length > 0 ? (
|
||||
<div>
|
||||
{stats.recent_predictions.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
|
||||
>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
||||
<span className="text-xs text-ink-800">比赛 #{p.match_id}</span>
|
||||
<span className="font-mono text-2xs text-ink-500">{p.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xs tabular-nums text-ink-400">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
|
||||
</span>
|
||||
{p.status === 'success' ? <Badge status="success">成功</Badge> : <Badge status="error">失败</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无预测记录</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Admin 后台 - 系统日志页面(报刊风)
|
||||
*
|
||||
* 查看应用运行日志(内存缓冲,最新在前):
|
||||
* - 级别筛选 + 关键字搜索
|
||||
* - 自动刷新(10s)可开关
|
||||
* - 缓冲上限 2000 条,进程重启后清零
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchLogs } from '../dal'
|
||||
import type { LogEntry } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
|
||||
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' },
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const userScrolledUp = useRef(false)
|
||||
|
||||
// 检测用户是否向上滚动过
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
|
||||
userScrolledUp.current = !atBottom
|
||||
}
|
||||
|
||||
// 加载后自动滚动到底部(仅当用户未向上滚动时)
|
||||
useEffect(() => {
|
||||
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [entries, autoRefresh])
|
||||
|
||||
// 自动刷新
|
||||
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 条,重启后清零。"
|
||||
/>
|
||||
|
||||
{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 onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{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)}
|
||||
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
|
||||
>
|
||||
{lv || '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键字(消息 / logger)…"
|
||||
className="field w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="space-y-2 px-4 py-3 sm:px-5">
|
||||
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
|
||||
{entries.map((e, i) => {
|
||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
||||
return (
|
||||
<div
|
||||
key={`${e.ts}-${i}`}
|
||||
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
|
||||
>
|
||||
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
|
||||
<span className="w-14 flex-shrink-0">
|
||||
<Badge status={badge.status}>{badge.text}</Badge>
|
||||
</span>
|
||||
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
|
||||
{e.logger}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
|
||||
{e.message}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { api } from '../api'
|
||||
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [ready, setReady] = useState<'ready' | 'not_ready' | 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)
|
||||
try {
|
||||
const [h, r] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const t = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(t)
|
||||
}, [refresh])
|
||||
|
||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||
</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 存活状态 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||
<div className="mt-2 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 ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库就绪 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||
<div className="mt-2 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>
|
||||
</div>
|
||||
|
||||
{/* 服务名 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">服务</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
|
||||
{health?.service || 'profeto'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本 */}
|
||||
{health?.version && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 运行时间 */}
|
||||
{health?.uptime_seconds != null && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检查项 */}
|
||||
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">健康检查</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(health.checks).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-2xs text-ink-500">{key}</span>
|
||||
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
|
||||
{String(val)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Admin 后台 - 预测历史页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 查看预测记录列表(支持状态筛选)
|
||||
* - 实际比分自动从比赛赛果填充
|
||||
* - 赛后一键结算,供准确率评估
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchPredictions, settlePrediction } from '../dal'
|
||||
import type { Prediction } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
|
||||
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 as any).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-emerald-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)}
|
||||
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
|
||||
>
|
||||
{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 as any).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-emerald-300 text-emerald-700 bg-emerald-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
|
||||
onClick={() => handleSettle(p)}
|
||||
disabled={settlingId === p.id}
|
||||
className="btn btn-sm btn-solid"
|
||||
>
|
||||
{settlingId === p.id ? <Spinner /> : '结算'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : p.id)}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{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 as any).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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Admin 后台 - 预测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 触发预测(选择比赛 + 模式)
|
||||
* - 结算:录入实际比分,写入评估(接 /eval/settle)
|
||||
* - 预测记录列表:可展开查看终裁理由与专家摘要
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { teamSidePrefix } from '../../components/TeamSideTag'
|
||||
import { AgentWeightsBar } from '../components'
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋分析专家',
|
||||
form: '近期状态分析专家',
|
||||
stats: '攻防数据分析专家',
|
||||
home_away: '主客因素分析专家',
|
||||
injuries: '阵容完整性分析专家',
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
function fmtTime(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime())
|
||||
? s
|
||||
: d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export default function PredictionsPage() {
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||
const [matchId, setMatchId] = useState('')
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
// 结算表单
|
||||
const [settleId, setSettleId] = useState('')
|
||||
const [homeGoals, setHomeGoals] = useState('')
|
||||
const [awayGoals, setAwayGoals] = useState('')
|
||||
const [settling, setSettling] = useState(false)
|
||||
const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null)
|
||||
|
||||
const refreshPredictions = useCallback(async () => {
|
||||
const list = await fetchPredictions(50)
|
||||
setPredictions(list)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshPredictions()
|
||||
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
|
||||
}, [refreshPredictions])
|
||||
|
||||
/** match_id → 中文名对阵 */
|
||||
const matchName = useMemo(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const m of matches) {
|
||||
const home = m.home_team_zh || m.home_team
|
||||
const away = m.away_team_zh || m.away_team
|
||||
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
|
||||
}
|
||||
return map
|
||||
}, [matches])
|
||||
|
||||
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
|
||||
|
||||
async function handlePredict(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!matchId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
await triggerPrediction({ match_id: parseInt(matchId), mode })
|
||||
setSuccessMsg('预测任务已完成,记录已更新')
|
||||
await refreshPredictions()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '预测失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unsettled = predictions.filter(p => !p.settled)
|
||||
|
||||
async function handleSettle(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const pid = parseInt(settleId)
|
||||
const hg = parseInt(homeGoals)
|
||||
const ag = parseInt(awayGoals)
|
||||
if (!pid || isNaN(hg) || isNaN(ag)) return
|
||||
setSettling(true)
|
||||
setSettleMsg(null)
|
||||
try {
|
||||
await settlePrediction(pid, hg, ag)
|
||||
setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' })
|
||||
setSettleId('')
|
||||
setHomeGoals('')
|
||||
setAwayGoals('')
|
||||
await refreshPredictions()
|
||||
} catch (err: unknown) {
|
||||
setSettleMsg({
|
||||
kind: 'error',
|
||||
text: err instanceof Error ? err.message : '结算失败',
|
||||
})
|
||||
} finally {
|
||||
setSettling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const settleTarget = predictions.find(p => p.id === parseInt(settleId))
|
||||
|
||||
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={handlePredict} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛</label>
|
||||
<select
|
||||
value={matchId}
|
||||
onChange={e => setMatchId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">选择比赛</option>
|
||||
{matches.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
|
||||
({m.match_date?.slice(5, 10)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="multi">多专家 (5 路 + 终裁,慢而稳)</option>
|
||||
<option value="single">单次调用 (快)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
||||
{successMsg && (
|
||||
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !matchId}
|
||||
className="btn btn-solid w-full"
|
||||
>
|
||||
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 秒</>) : '触发预测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 结算 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="预测结算"
|
||||
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
|
||||
/>
|
||||
<CardBody>
|
||||
{unsettled.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">
|
||||
没有待结算的预测记录。预测完成后可在此录入实际比分。
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSettle} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">预测记录</label>
|
||||
<select
|
||||
value={settleId}
|
||||
onChange={e => setSettleId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">选择待结算预测({unsettled.length} 条)</option>
|
||||
{unsettled.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} {nameOf(p.match_id)} · 预测 {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{settleTarget && (
|
||||
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
|
||||
预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
|
||||
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
|
||||
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">主队实际进球</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={homeGoals}
|
||||
onChange={e => setHomeGoals(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">客队实际进球</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={awayGoals}
|
||||
onChange={e => setAwayGoals(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settleMsg && (
|
||||
<Alert
|
||||
kind={settleMsg.kind}
|
||||
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
|
||||
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
|
||||
onClose={() => setSettleMsg(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
|
||||
className="btn btn-solid w-full"
|
||||
>
|
||||
{settling ? (<><Spinner /> 结算中</>) : '提交结算'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{predictions.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-ink-400">
|
||||
暂无预测记录,触发预测后将在此显示
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{predictions.map(p => {
|
||||
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
|
||||
return (
|
||||
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
|
||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
|
||||
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
|
||||
</span>
|
||||
<span className="text-2xs text-ink-500">
|
||||
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
|
||||
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
|
||||
` · ${Math.round(p.subjective_confidence * 100)}%`}
|
||||
</span>
|
||||
<span className="ml-auto flex items-baseline gap-3">
|
||||
{p.settled ? (
|
||||
<Badge status="success">已结算</Badge>
|
||||
) : (
|
||||
<Badge status="pending">未结算</Badge>
|
||||
)}
|
||||
{p.status === 'degraded' && (
|
||||
<Badge status="error">降级·仅供参考</Badge>
|
||||
)}
|
||||
{p.status === 'failed' && (
|
||||
<Badge status="error">预测失败</Badge>
|
||||
)}
|
||||
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
||||
<p className="text-2xs text-ink-500">
|
||||
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
||||
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
||||
</p>
|
||||
|
||||
{p.reasoning && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{p.agent_weights && Object.keys(p.agent_weights).length > 0 && (
|
||||
<AgentWeightsBar weights={p.agent_weights} okCount={okAgents.length} />
|
||||
)}
|
||||
|
||||
{p.agent_outputs && p.agent_outputs.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{p.agent_outputs.map((a, i) => (
|
||||
<li key={i} className="flex items-baseline gap-2.5 text-xs">
|
||||
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
|
||||
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
|
||||
{a.probable_score && (
|
||||
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
|
||||
)}
|
||||
<span className="text-2xs text-ink-400">
|
||||
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{p.settled && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
|
||||
实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Admin 后台 - 统一设置页(报刊风)
|
||||
*
|
||||
* 合并原「数据源」「LLM 配置」「系统配置」三页:
|
||||
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
|
||||
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
|
||||
* 3. 认证 — 修改密码
|
||||
* 4. 系统 — 配置查看 + 修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchSettings, updateSetting, clearSetting,
|
||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||
fetchKeyRingStatus, resetKeyRingCooldown,
|
||||
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
|
||||
} from '../dal'
|
||||
import type { ScheduleItem } from '../dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import type { LLMUsageStats, DataSourceSetting } from '../types'
|
||||
import type { KeyRingStatusResponse } from '../dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
|
||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
export default function SettingsPage() {
|
||||
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。"
|
||||
/>
|
||||
|
||||
{/* ── 1. 数据源 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据源</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Bzzoiro API"
|
||||
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{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 onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline">重置冷却</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-amber-500' : 'bg-emerald-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
||||
{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 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">大语言模型</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{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 onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
||||
{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 onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{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">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均延迟</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. 认证 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">登录认证</h2>
|
||||
<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 className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="field 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 type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd} className="btn btn-solid btn-sm flex-shrink-0">
|
||||
{pwdBusy ? <><Spinner /> 修改中</> : '修改密码'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* ── 4. 定时任务 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">定时任务</h2>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集调度"
|
||||
description="配置 cron 表达式定时触发采集任务"
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
|
||||
.then(() => fetchSchedules().then(setSchedules))
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
+ 新建
|
||||
</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-emerald-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
|
||||
onClick={() => handleRunSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm btn-danger"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Admin 后台 - 路由配置
|
||||
*
|
||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||
* 挂载路径: /admin/*
|
||||
*/
|
||||
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import AdminLayout from './AdminLayout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import CollectionPage from './pages/Collection'
|
||||
import DataCompletenessPage from './pages/DataCompleteness'
|
||||
import PredictionsPage from './pages/PredictionHistory'
|
||||
import BacktestPage from './pages/Backtest'
|
||||
import MonitoringPage from './pages/Monitoring'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import EvalPage from './pages/EvalPage'
|
||||
import DataPipelinePage from './pages/DataPipeline'
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'data-completeness', element: <DataCompletenessPage /> },
|
||||
{ path: 'data-pipeline', element: <DataPipelinePage /> },
|
||||
{ path: 'predictions', element: <PredictionsPage /> },
|
||||
{ path: 'backtest', element: <BacktestPage /> },
|
||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'logs', element: <LogsPage /> },
|
||||
{ path: 'eval', element: <EvalPage /> },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export { adminRoutes as default }
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Admin 后台 - TypeScript 类型定义
|
||||
*
|
||||
* 与 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 }[]
|
||||
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 }[]
|
||||
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[]
|
||||
}
|
||||
|
||||
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
export interface PredictRequest {
|
||||
match_id: number
|
||||
mode?: 'single' | 'multi'
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
avg_latency_ms: number
|
||||
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?: Array<Record<string, any>> | 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 }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
|
||||
*
|
||||
* 提供全局快捷键:
|
||||
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
|
||||
* / — 聚焦搜索(日志页)
|
||||
* r — 刷新当前页面数据(通用)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="命令面板"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||
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="输入页面名或路径…"
|
||||
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, i) => (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,16 +30,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
||||
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3">
|
||||
<div className="text-4xl">⚠️</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">页面出现错误</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||
<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 || '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
|
||||
className="btn btn-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
|
||||
@@ -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' ? '[主] ' : '[客] '
|
||||
}
|
||||
@@ -1,3 +1,180 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 毛体草书(刘建毛草) - 国内 CDN,离线回退粗楷体 */
|
||||
@font-face {
|
||||
font-family: 'Liu Jian Mao Cao';
|
||||
src: url('https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/liujianmaocao/LiuJianMaoCao-Regular.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* 粗毛笔字效果:笔触加粗 + 微描边 */
|
||||
.font-brush {
|
||||
font-weight: 700;
|
||||
-webkit-text-stroke: 0.3px currentColor;
|
||||
text-shadow: 0.5px 0.5px 0 currentColor;
|
||||
}
|
||||
|
||||
/* 页面内容横向轻扫+淡入 */
|
||||
.page-content-enter {
|
||||
animation: pageSlideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes pageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-paper-50 text-ink-800 font-sans antialiased;
|
||||
font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-press-wash text-press;
|
||||
}
|
||||
|
||||
/* 统一焦点环:印报红细线,键盘可达 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #9e1b1b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 细滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full bg-ink-300;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-ink-400;
|
||||
}
|
||||
|
||||
/* 尊重「减少动态效果」偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||
.masthead-rule {
|
||||
border-top: 3px solid #17140f;
|
||||
}
|
||||
|
||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
|
||||
text-sm text-ink-700 transition-colors duration-150
|
||||
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||
disabled:cursor-not-allowed disabled:opacity-40
|
||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700
|
||||
min-h-[44px] py-1.5;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 text-xs min-h-[36px];
|
||||
}
|
||||
.btn-solid {
|
||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||
}
|
||||
|
||||
/* ── 统一空态 ── */
|
||||
.empty-state {
|
||||
@apply border-y border-ink-200 py-12 text-center;
|
||||
}
|
||||
.empty-state-title {
|
||||
@apply font-serif text-sm text-ink-600;
|
||||
}
|
||||
.empty-state-sub {
|
||||
@apply mt-1.5 text-xs text-ink-400;
|
||||
}
|
||||
.empty-state-action {
|
||||
@apply mt-4 inline-flex items-center gap-2 text-2xs text-press hover:text-press-dark transition-colors;
|
||||
}
|
||||
|
||||
/* ── 统一错误横幅(前台用,后台用 Alert) ── */
|
||||
.error-banner {
|
||||
@apply flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3;
|
||||
}
|
||||
.error-banner-title {
|
||||
@apply text-sm font-medium text-press;
|
||||
}
|
||||
.error-banner-detail {
|
||||
@apply mt-0.5 text-xs text-ink-600;
|
||||
}
|
||||
|
||||
/* ── 表单控件:方正、无圆角 ── */
|
||||
.field {
|
||||
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
||||
transition-colors hover:border-ink-400
|
||||
focus:border-press focus:outline-none;
|
||||
}
|
||||
|
||||
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
||||
.tab {
|
||||
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||
}
|
||||
.tab-on {
|
||||
@apply font-medium text-press;
|
||||
}
|
||||
.tab-on::after {
|
||||
content: '';
|
||||
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press;
|
||||
}
|
||||
|
||||
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
||||
.section-head {
|
||||
@apply border-b border-ink-900 pb-1.5 font-serif text-sm font-bold text-ink-900;
|
||||
}
|
||||
|
||||
/* ── 骨架占位:低调脉动,不用渐变扫光 ── */
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded-none bg-ink-200;
|
||||
}
|
||||
|
||||
/* ── 侧边栏图标:统一样式 + hover过渡到品牌色 ── */
|
||||
.nav-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
stroke-width: 1.5;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-icon-wrap:hover .nav-icon,
|
||||
.nav-icon-wrap.active .nav-icon {
|
||||
opacity: 1;
|
||||
color: #9E1B1B;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Admin 后台 ──
|
||||
与前台共用同一套报刊风组件(.btn/.field/.tab/.section-head/.skeleton),
|
||||
不再单独维护暗色主题。 */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* P0-00: HTTP client method/body/headers 可信度测试。
|
||||
* 运行: node --experimental-strip-types frontend/src/lib/http.test.ts
|
||||
*
|
||||
* 最小环境 polyfill:Node 22 自带 fetch/AbortController,本测试不触发 401 路径,
|
||||
* 故 window.dispatchEvent 不会被调用,无需完整 DOM。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// 最小浏览器环境 polyfill(仅覆盖 http.ts 在 happy path 用到的全局)
|
||||
const store: Record<string, string> = {}
|
||||
// @ts-expect-error 测试用最小 window stub
|
||||
globalThis.window = {
|
||||
dispatchEvent: () => false,
|
||||
localStorage: {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
},
|
||||
}
|
||||
|
||||
// 捕获每次 fetch 的入参供断言
|
||||
let lastInit: RequestInit | undefined
|
||||
globalThis.fetch = async (_url: string, init?: RequestInit) => {
|
||||
lastInit = init
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
|
||||
const { http } = await import('./http.ts')
|
||||
|
||||
test('GET: method=GET, 无 body, 无 Content-Type', async () => {
|
||||
await http.get('/api/v1/matches')
|
||||
assert.equal(lastInit?.method, 'GET')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
|
||||
test('POST: method=POST, 序列化 body, 有 Content-Type', async () => {
|
||||
await http.post('/api/v1/matches', { a: 1 })
|
||||
assert.equal(lastInit?.method, 'POST')
|
||||
assert.equal(lastInit?.body, JSON.stringify({ a: 1 }))
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('POST 空 body: 不设 Content-Type', async () => {
|
||||
await http.post('/api/v1/matches', undefined)
|
||||
assert.equal(lastInit?.method, 'POST')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
|
||||
test('PUT: method=PUT, 有 body 与 Content-Type', async () => {
|
||||
await http.put('/api/v1/x', { b: 2 })
|
||||
assert.equal(lastInit?.method, 'PUT')
|
||||
assert.equal(lastInit?.body, JSON.stringify({ b: 2 }))
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], 'application/json')
|
||||
})
|
||||
|
||||
test('DELETE: method=DELETE, 无 body, 无 Content-Type', async () => {
|
||||
await http.delete('/api/v1/x/1')
|
||||
assert.equal(lastInit?.method, 'DELETE')
|
||||
assert.equal(lastInit?.body, undefined)
|
||||
assert.equal((lastInit?.headers as Record<string, string>)?.['Content-Type'], undefined)
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 共享 HTTP 客户端(公开站 + Admin 统一)
|
||||
*
|
||||
* 特性:
|
||||
* - 统一超时(默认 30s,可覆盖)
|
||||
* - 统一错误处理(ApiError)
|
||||
* - 401 自动广播(Admin 场景)
|
||||
* - JSON/HTML 容错解析
|
||||
* - 请求竞态防护(可选 signal)
|
||||
*
|
||||
* 注: Admin 侧的 admin/api.ts 是本模块的薄门面,不再有第二套实现。
|
||||
*/
|
||||
|
||||
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
||||
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
timeoutMs?: number
|
||||
skipAuthHandling?: boolean
|
||||
signal?: AbortSignal
|
||||
method?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
|
||||
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
// 如果外部传了 signal,也关联到内部 controller
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const method = (options.method ?? 'GET').toUpperCase()
|
||||
const body = options.body
|
||||
// 仅当有 body 时设置 Content-Type,避免 GET/DELETE 等无 body 请求被误标
|
||||
const headers: Record<string, string> = body ? { 'Content-Type': 'application/json' } : {}
|
||||
const res = await fetch(url, { signal: controller.signal, method, body, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
const rawText = await res.text()
|
||||
let detail: unknown = rawText
|
||||
try {
|
||||
detail = JSON.parse(rawText)
|
||||
} catch {
|
||||
// 非 JSON(如 HTML 错误页),保留原始文本
|
||||
}
|
||||
let message =
|
||||
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
|
||||
? String((detail as { detail: unknown }).detail)
|
||||
: `HTTP ${res.status}: ${res.statusText}`
|
||||
|
||||
if (res.status === 401 && !skipAuthHandling) {
|
||||
message += '\n登录已过期,请重新登录。'
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
||||
}
|
||||
throw new ApiError(message, res.status, detail)
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new ApiError('请求超时,请稍后重试', 0)
|
||||
}
|
||||
throw new ApiError(
|
||||
err instanceof Error ? err.message : '网络错误,请检查连接',
|
||||
0,
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
|
||||
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
||||
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
||||
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
|
||||
}
|
||||
+256
-289
@@ -1,315 +1,282 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
interface Match {
|
||||
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
|
||||
}
|
||||
|
||||
interface Prediction {
|
||||
prediction_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
mode: string
|
||||
pred_home_goals: number | null
|
||||
pred_away_goals: number | null
|
||||
pred_1x2: string | null
|
||||
confidence: number | null
|
||||
reasoning: string | null
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
interface AgentReport {
|
||||
agent: string
|
||||
status: string
|
||||
data_sufficiency: string
|
||||
analysis: string
|
||||
home_edge: number | null
|
||||
confidence: number | null
|
||||
key_evidence: string[]
|
||||
exp_home_goals: number | null
|
||||
exp_away_goals: number | null
|
||||
probable_score: string | null
|
||||
model: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋',
|
||||
form: '近期状态',
|
||||
stats: '攻防数据',
|
||||
home_away: '主客因素',
|
||||
injuries: '阵容完整性',
|
||||
}
|
||||
|
||||
const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
]
|
||||
/**
|
||||
* 赛程页(组装层)。
|
||||
*
|
||||
* D3(工程债): 原文件 ~1400 行,已拆分为:
|
||||
* matches/types.ts — 类型与常量
|
||||
* matches/ui.tsx — 原子 UI 小件(Spinner/Skeleton/Switch/日期工具)
|
||||
* matches/hooks/useMatchesList.ts — 列表/分页/进行中比赛加载
|
||||
* matches/hooks/useMatchPredict.ts — 预测流程状态机
|
||||
* matches/components/MatchPredictPanel.tsx — 预测弹窗全套
|
||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
import { PredictModal } from './matches/components/MatchPredictPanel'
|
||||
import { MatchRow } from './matches/components/MatchDetailSection'
|
||||
import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui'
|
||||
import { type Match } from './matches/types'
|
||||
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||
const [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
|
||||
// P1-3: 联赛列表优先请求 /api/v1/leagues,失败/空回退本地五大联赛常量
|
||||
const leagues = useLeagues()
|
||||
const {
|
||||
league, setLeague,
|
||||
status, setStatus,
|
||||
matches,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
showAllUpcoming, setShowAllUpcoming,
|
||||
liveMatches,
|
||||
load,
|
||||
loadMore,
|
||||
} = useMatchesList({ onError: setError })
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
const res = await fetch(`/api/v1/matches?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setMatches(data.items)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [league, status])
|
||||
const {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
} = useMatchPredict({ onError: setError })
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
|
||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||
|
||||
const predict = async (matchId: number) => {
|
||||
setPredictingId(matchId)
|
||||
setError(null)
|
||||
setPrediction(null)
|
||||
try {
|
||||
const res = await fetch('/api/v1/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ match_id: matchId, mode }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
throw new Error(`HTTP ${res.status}: ${t}`)
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
const [showBackTop, setShowBackTop] = useState(false)
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
||||
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
||||
|
||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||
const isScheduledView = status === 'scheduled'
|
||||
const visibleMatches = (!isScheduledView || showAllUpcoming)
|
||||
? matches
|
||||
: matches.filter(m => withinNext3Days(m.match_date))
|
||||
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
|
||||
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
|
||||
|
||||
/** 展开时懒加载详情: 缓存命中则不再请求 */
|
||||
async function toggleExpand(m: Match) {
|
||||
if (expandedId === m.id) { setExpandedId(null); return }
|
||||
setExpandedId(m.id)
|
||||
if (!detailMap[m.id] || !contextMap[m.id]) {
|
||||
setDetailLoading(m.id)
|
||||
try {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(m.id).catch(() => null),
|
||||
fetchMatchContext(m.id).catch(() => null),
|
||||
])
|
||||
if (d) setDetailMap(prev => ({ ...prev, [m.id]: d }))
|
||||
if (c) setContextMap(prev => ({ ...prev, [m.id]: c }))
|
||||
} finally {
|
||||
setDetailLoading(null)
|
||||
}
|
||||
const data = await res.json()
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setPredictingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const fmtDate = (s: string) => {
|
||||
const d = new Date(s)
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选 */}
|
||||
<div className="flex gap-3 items-center flex-wrap">
|
||||
<select value={league} onChange={e => setLeague(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm">
|
||||
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
|
||||
</select>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm">
|
||||
<option value="scheduled">未开赛</option>
|
||||
<option value="finished">已完赛</option>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-500">模式:</span>
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
||||
{leagues.map(l => (
|
||||
<button
|
||||
onClick={() => setMode('single')}
|
||||
className={`px-2 py-1 rounded text-xs ${mode === 'single' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}
|
||||
>单次</button>
|
||||
<button
|
||||
onClick={() => setMode('multi')}
|
||||
className={`px-2 py-1 rounded text-xs ${mode === 'multi' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}
|
||||
>多 Agent</button>
|
||||
</div>
|
||||
<button onClick={load} disabled={loading}
|
||||
className="bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? '加载中...' : '刷新'}
|
||||
</button>
|
||||
<span className="text-sm text-gray-500">共 {matches.length} 场</span>
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="text-2xs text-ink-400">状态</span>
|
||||
<Switch
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
items={[
|
||||
{ v: 'scheduled', label: '未开赛' },
|
||||
{ v: 'finished', label: '已完赛' },
|
||||
{ v: '', label: '全部' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="ml-auto inline-flex items-center gap-3">
|
||||
<span className="tabular-nums">
|
||||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||
: `共 ${visibleMatches.length} 场`}
|
||||
</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 错误提示(统一 error-banner 样式) ── */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm flex items-center justify-between">
|
||||
<span>❌ {error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 text-xs">✕</button>
|
||||
<div className="error-banner">
|
||||
<div>
|
||||
<p className="error-banner-title">请求失败</p>
|
||||
<p className="error-banner-detail">{error}</p>
|
||||
</div>
|
||||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 比赛表 */}
|
||||
<div className="bg-white rounded border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">日期</th>
|
||||
<th className="text-left px-4 py-2">主队</th>
|
||||
<th className="text-left px-4 py-2">客队</th>
|
||||
<th className="text-center px-4 py-2">比分</th>
|
||||
<th className="text-center px-4 py-2">状态</th>
|
||||
<th className="text-center px-4 py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-gray-400 py-8">
|
||||
<span className="inline-block animate-spin mr-2">⏳</span>加载中...
|
||||
</td></tr>
|
||||
)}
|
||||
{!loading && matches.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-gray-400 py-8">暂无数据,请先采集</td></tr>
|
||||
)}
|
||||
{matches.map(m => (
|
||||
<tr key={m.id} className="border-t hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-gray-600">{fmtDate(m.match_date)}</td>
|
||||
<td className="px-4 py-2 font-medium">{m.home_team_zh || m.home_team}</td>
|
||||
<td className="px-4 py-2 font-medium">{m.away_team_zh || m.away_team}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
m.match_status === 'finished' ? 'bg-green-100 text-green-700' :
|
||||
m.match_status === 'scheduled' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'
|
||||
}`}>
|
||||
{m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button onClick={() => predict(m.id)}
|
||||
disabled={predictingId === m.id}
|
||||
className="text-blue-600 hover:underline text-xs disabled:opacity-50">
|
||||
{predictingId === m.id ? '预测中...' : 'LLM 预测'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 预测中指示 */}
|
||||
{predictingId && (
|
||||
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm flex items-center gap-2">
|
||||
<span className="inline-block animate-spin">⏳</span>
|
||||
LLM 预测中,请稍候...
|
||||
</div>
|
||||
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
|
||||
{predictionFor && (
|
||||
<PredictModal
|
||||
match={predictionFor}
|
||||
predicting={predictingId === predictionFor.id}
|
||||
prediction={predictingId === predictionFor.id ? null : prediction}
|
||||
error={predictingId === predictionFor.id ? null : error}
|
||||
onClose={closePredict}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 预测结果 */}
|
||||
{prediction && (
|
||||
<div className="bg-white rounded border p-5 space-y-3">
|
||||
<h3 className="font-bold text-lg">🤖 LLM 预测结果</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">主进球</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_home_goals ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">客进球</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_away_goals ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">胜平负</div>
|
||||
<div className="text-xl font-bold">{prediction.pred_1x2 ?? '-'}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded p-3">
|
||||
<div className="text-gray-500 text-xs">置信度</div>
|
||||
<div className="text-xl font-bold">
|
||||
{prediction.confidence !== null ? `${(prediction.confidence * 100).toFixed(0)}%` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */}
|
||||
{isScheduledView && liveMatches.length > 0 && (
|
||||
<section aria-label="进行中" className="border border-ink-900 bg-paper-100">
|
||||
<div className="flex items-center gap-2 border-b border-ink-900 px-3 py-2">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-press" />
|
||||
<span className="text-xs font-medium tracking-wide text-ink-700">进行中 · 实时比分</span>
|
||||
<span className="text-2xs text-ink-400">{liveMatches.length} 场</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{prediction.provider} / {prediction.model} · 耗时 {prediction.latency_ms}ms
|
||||
{prediction.mode === 'multi' && ' · 多 Agent 模式'}
|
||||
</div>
|
||||
|
||||
{/* 各专家 agent 报告 */}
|
||||
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-gray-700">专家 Agent 报告</div>
|
||||
{prediction.agent_outputs.map((r) => (
|
||||
<details key={r.agent} className="bg-white border rounded">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{AGENT_LABELS[r.agent] || r.agent}
|
||||
{r.status !== 'ok' && (
|
||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded ${
|
||||
r.status === 'no_data' ? 'bg-gray-100 text-gray-500' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
{r.status === 'no_data' ? '无数据' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
<div className="divide-y divide-ink-200">
|
||||
{liveMatches.map(m => {
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
return (
|
||||
<div key={m.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||
{m.home_goals ?? '-'}
|
||||
</span>
|
||||
<span className="flex gap-3 text-xs text-gray-500">
|
||||
{r.home_edge !== null && (
|
||||
<span className={r.home_edge > 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}>
|
||||
主队优势 {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{r.confidence !== null && <span>信心 {(r.confidence * 100).toFixed(0)}%</span>}
|
||||
{r.probable_score && <span>比分 {r.probable_score}</span>}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 space-y-2 text-sm">
|
||||
{r.analysis && <p className="text-gray-700">{r.analysis}</p>}
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="text-xs text-gray-500 list-disc pl-4">
|
||||
{r.key_evidence.map((e, i) => <li key={i}>{e}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<div className="text-xs text-gray-500">
|
||||
进球期望: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400">
|
||||
数据充分度 {r.data_sufficiency} · {r.model} · {r.latency_ms}ms
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-xs text-ink-700">{homeName}</span>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prediction.reasoning && (
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">推理过程</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{prediction.reasoning}</div>
|
||||
</div>
|
||||
)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整上下文</summary>
|
||||
<pre className="mt-2 bg-gray-900 text-green-300 p-3 rounded overflow-x-auto text-xs">
|
||||
{prediction.context}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
<span className="shrink-0 text-2xs text-ink-400">vs</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="min-w-0 truncate text-right text-xs text-ink-700">{awayName}</span>
|
||||
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
|
||||
{m.away_goals ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
|
||||
<section aria-label="赛程">
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
{!loading && visibleMatches.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{matches.length > 0 ? (
|
||||
<>
|
||||
<p className="empty-state-title">未来 3 天暂无 {leagueName} 比赛</p>
|
||||
<p className="empty-state-sub">已导入 {matches.length} 场未开赛,点击下方按钮查看</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="empty-state-title">本版暂无赛程</p>
|
||||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||
<a href="/admin/collection" className="empty-state-action">
|
||||
前往数据采集 <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
|
||||
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
|
||||
{formatDateHeader(dateKey)}
|
||||
<span className="ml-2 text-2xs font-normal text-ink-400">{group.length} 场</span>
|
||||
</div>
|
||||
{group.map(m => (
|
||||
<MatchRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
busy={predictingId === m.id}
|
||||
expanded={expandedId === m.id}
|
||||
detail={detailMap[m.id]}
|
||||
ctx={contextMap[m.id]}
|
||||
detailLoading={detailLoading === m.id}
|
||||
onToggle={() => toggleExpand(m)}
|
||||
onPredict={predict}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */}
|
||||
{!loading && (hasHiddenUpcoming || nextCursor) && (
|
||||
<div className="flex justify-center pt-4">
|
||||
{hasHiddenUpcoming ? (
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(true)}
|
||||
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
|
||||
>
|
||||
显示后续 {matches.length - visibleMatches.length} 场未开赛
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
|
||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
|
||||
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(false)}
|
||||
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
|
||||
>
|
||||
收起,仅显示未来 3 天
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 主站 - 联赛积分榜页
|
||||
*
|
||||
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
import { Spinner } from '../admin/components'
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
// 欧战资格
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
// 升级
|
||||
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
// 降级
|
||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
||||
// 附加赛
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
}
|
||||
|
||||
function zoneBadge(zone?: string | null) {
|
||||
if (!zone) return null
|
||||
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||
return <span className={`whitespace-nowrap rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
}
|
||||
|
||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||
function FormDots({ form }: { form?: string | null }) {
|
||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StandingsPage() {
|
||||
// 统一数据源:复用 useLeagues hook(优先 API,失败回退本地常量)
|
||||
const leagues = useLeagues()
|
||||
const [standings, setStandings] = useState<StandingsLeague[]>([])
|
||||
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
||||
|
||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
||||
const load = useCallback(async (code?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchStandings(code)
|
||||
setStandings(data.leagues)
|
||||
if (!activeLeague && data.leagues.length > 0) {
|
||||
setActiveLeague(data.leagues[0].league_code)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeLeague])
|
||||
|
||||
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 切换联赛(带加载态,禁用 tab 防重复点击)
|
||||
const switchLeague = async (code: string) => {
|
||||
if (code === activeLeague || switching) return
|
||||
setSwitching(true)
|
||||
setActiveLeague(code)
|
||||
try {
|
||||
await fetchStandings(code).then(data => setStandings(data.leagues))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const active = standings.find(l => l.league_code === activeLeague) ?? standings[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 联赛切换 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{leagues.map(l => {
|
||||
// 标记该联赛是否有积分榜数据:有数据可正常切换,无数据也可选中但显示空态
|
||||
const hasData = standings.some(s => s.league_code === l.code)
|
||||
const isEmpty = activeLeague === l.code && !hasData
|
||||
return (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => switchLeague(l.code)}
|
||||
disabled={switching}
|
||||
title={hasData ? undefined : '暂无积分榜数据'}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
|
||||
activeLeague === l.code
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||
} ${!hasData ? 'border-dashed' : ''}`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||||
)}
|
||||
|
||||
{/* 切换联赛时的轻量加载指示 */}
|
||||
{switching && !loading && (
|
||||
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
|
||||
<Spinner /> 切换联赛中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !active && (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">
|
||||
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||||
{active.league_name}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-400">
|
||||
{active.season} 赛季 · {active.rows.length} 队
|
||||
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分榜表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||
<th className="w-8 py-2 font-medium">#</th>
|
||||
<th className="py-2 font-medium">球队</th>
|
||||
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||||
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||||
<th className="w-10 text-center py-2 font-medium">平</th>
|
||||
<th className="w-10 text-center py-2 font-medium">负</th>
|
||||
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||||
<th className="w-12 text-center py-2 font-medium">净</th>
|
||||
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||||
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||||
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||||
<th className="w-16 text-right py-2 font-medium">区</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.rows.map((r: StandingRow) => (
|
||||
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||||
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||
</td>
|
||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||
<td className="text-center py-2 text-ink-500">
|
||||
{r.xg_for != null && r.xg_against != null
|
||||
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||||
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* AgentsPanel: 五路专家意见 —— 可折叠 + 状态摘要 + 权重条形图 + 单路详情。
|
||||
*
|
||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { AgentReport, Prediction } from '../../types'
|
||||
import { AGENT_LABELS, CN_NUM } from '../../types'
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
ok: { label: '正常', cls: 'text-ink-500' },
|
||||
no_data: { label: '无数据', cls: 'text-ink-400' },
|
||||
error: { label: '调用失败', cls: 'text-press' },
|
||||
parse_error: { label: '解析失败', cls: 'text-press' },
|
||||
}
|
||||
|
||||
const SUFFICIENCY_LABEL: Record<string, string> = {
|
||||
high: '充分',
|
||||
medium: '一般',
|
||||
low: '偏少',
|
||||
none: '无',
|
||||
}
|
||||
|
||||
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */
|
||||
function EdgeBar({ value }: { value: number }) {
|
||||
const v = Math.max(-1, Math.min(1, value))
|
||||
const half = Math.abs(v) * 50
|
||||
return (
|
||||
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||
<span
|
||||
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||
style={
|
||||
v >= 0
|
||||
? { left: '50%', width: `${half}%` }
|
||||
: { right: '50%', width: `${half}%` }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单路专家意见:汉字编号 + 细线行 */
|
||||
function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
|
||||
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' }
|
||||
const inactive = r.status !== 'ok'
|
||||
|
||||
return (
|
||||
<details className="group border-b border-ink-200">
|
||||
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
|
||||
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
{r.status === 'ok' && r.probable_score && (
|
||||
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||
)}
|
||||
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||
{inactive && (
|
||||
<p className="text-xs leading-relaxed text-ink-500">
|
||||
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && r.home_edge !== null && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||
<span className="text-ink-500">主队优势</span>
|
||||
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<EdgeBar value={r.home_edge} />
|
||||
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||
<span>利客队</span>
|
||||
<span>利主队</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.analysis && (
|
||||
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||
)}
|
||||
|
||||
{r.key_evidence.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{r.key_evidence.map((e, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||
<span>{e}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||
<p className="text-xs text-ink-500">
|
||||
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!inactive && (
|
||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
<span className="font-mono">{r.model}</span>
|
||||
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentsPanel({ prediction }: { prediction: Prediction }) {
|
||||
const [expertsOpen, setExpertsOpen] = useState(false)
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
if (reports.length === 0) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setExpertsOpen(o => !o)}
|
||||
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||
>
|
||||
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||
</button>
|
||||
|
||||
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||
{Object.entries(prediction.agent_weights)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||
<div className="h-1.5 bg-paper-100">
|
||||
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expertsOpen && (
|
||||
<div className="mt-2">
|
||||
{reports.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内
|
||||
* group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致;
|
||||
* 展开懒加载的 state 仍由页面持有。
|
||||
*/
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../../../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../admin/types'
|
||||
import type { MatchStatsDetail } from '../../../admin/types'
|
||||
import { STATUS_META } from '../types'
|
||||
import type { Match } from '../types'
|
||||
import { Spinner } from '../ui'
|
||||
|
||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||
function MatchDetailPanel({
|
||||
match, detail, ctx, loading,
|
||||
}: {
|
||||
match: Match
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
loading: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const finished = match.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> 加载详情中…</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail && !ctx && (
|
||||
<p className="py-4 text-center text-xs text-ink-400">暂无详情数据</p>
|
||||
)}
|
||||
|
||||
{!loading && (detail || ctx) && (
|
||||
<div className="space-y-5">
|
||||
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs text-ink-500">
|
||||
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
|
||||
</p>
|
||||
{match.home_xg != null && match.away_xg != null && (
|
||||
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}</p>
|
||||
)}
|
||||
</div>
|
||||
{!finished && (
|
||||
<span className="text-2xs text-ink-500">
|
||||
点击行首「预测」按钮发起多专家分析
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||||
{detail?.stats && (
|
||||
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||||
)}
|
||||
|
||||
{/* 双方近况 + H2H */}
|
||||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
|
||||
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
|
||||
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
|
||||
</div>
|
||||
) : (
|
||||
!loading && <p className="text-2xs text-ink-400">暂无近期对战数据</p>
|
||||
)}
|
||||
|
||||
{/* 历史预测列表 */}
|
||||
<div>
|
||||
<h4 className="section-head mb-2">历史预测({detail?.recent_predictions?.length ?? 0})</h4>
|
||||
{detail?.recent_predictions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{detail.recent_predictions.map(p => (
|
||||
<PredictionHistoryRow key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 LLM) */
|
||||
export function MatchRow({
|
||||
m,
|
||||
busy,
|
||||
expanded,
|
||||
detail,
|
||||
ctx,
|
||||
detailLoading,
|
||||
onToggle,
|
||||
onPredict,
|
||||
}: {
|
||||
m: Match
|
||||
busy: boolean
|
||||
expanded: boolean
|
||||
detail: MatchDetailOut | undefined
|
||||
ctx: MatchContextOut | undefined
|
||||
detailLoading: boolean
|
||||
onToggle: () => void
|
||||
onPredict: (m: Match) => void
|
||||
}) {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
const finished = m.match_status === 'finished'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 行:可点击展开 */}
|
||||
<div
|
||||
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
|
||||
expanded ? 'bg-paper-100/60' : ''
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter') onToggle() }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
|
||||
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
|
||||
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
|
||||
<div className="flex items-center justify-between text-xs sm:contents">
|
||||
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
|
||||
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
|
||||
</div>
|
||||
|
||||
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
|
||||
{/* 主队(右对齐) */}
|
||||
<span className="flex min-w-0 items-center justify-end gap-2">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||||
</span>
|
||||
|
||||
{/* 比分 / VS(严格居中) */}
|
||||
<span className="flex flex-col items-center justify-center">
|
||||
{m.home_goals !== null && m.away_goals !== null ? (
|
||||
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
|
||||
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
|
||||
)}
|
||||
{m.home_xg !== null && m.away_xg !== null && (
|
||||
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 客队(左对齐) */}
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
|
||||
<span className="hidden text-right sm:block">
|
||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
|
||||
m.match_status === 'finished'
|
||||
? 'border-ink-200 text-ink-500'
|
||||
: m.match_status === 'scheduled'
|
||||
? 'border-ink-300 text-ink-600'
|
||||
: 'border-press/30 text-press'
|
||||
}`}>
|
||||
{st.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* 预测按钮(统一,响应式尺寸) */}
|
||||
{!finished && (
|
||||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onPredict(m)}
|
||||
disabled={busy}
|
||||
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||||
title="以多专家模式预测这场"
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* 关闭可点击行 */}
|
||||
|
||||
{/* 展开详情面板 */}
|
||||
{expanded && (
|
||||
<MatchDetailPanel
|
||||
match={m} detail={detail} ctx={ctx}
|
||||
loading={detailLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 行内时间展示:只显示 HH:mm(日期由分组头承担) */
|
||||
function fmtTime(s: string): string {
|
||||
const d = new Date(s)
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
}
|
||||
|
||||
/** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */
|
||||
function MatchStatsPanel({
|
||||
stats, homeName, awayName,
|
||||
}: { stats: MatchStatsDetail; homeName: string; awayName: string }) {
|
||||
const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [
|
||||
{ label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg },
|
||||
{ label: '射门', home: stats.home_shots, away: stats.away_shots },
|
||||
{ label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target },
|
||||
{ label: '角球', home: stats.home_corners, away: stats.away_corners },
|
||||
{ label: '犯规', home: stats.home_fouls, away: stats.away_fouls },
|
||||
{ label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances },
|
||||
{ label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards },
|
||||
{ label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards },
|
||||
]
|
||||
const hasAny = rows.some(r => r.home != null || r.away != null)
|
||||
if (!hasAny) return null
|
||||
|
||||
// 控球率用横条展示
|
||||
const possHome = stats.home_possession
|
||||
const possAway = possHome != null ? Math.max(0, 100 - possHome) : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">比赛统计</h4>
|
||||
|
||||
{/* 控球率横条 */}
|
||||
{possHome != null && possAway != null && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||||
<span>{possHome.toFixed(0)}%</span>
|
||||
<span className="text-ink-400">控球率</span>
|
||||
<span>{possAway.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||||
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||||
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主客对比表 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-ink-400">
|
||||
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||||
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||||
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.filter(r => r.home != null || r.away != null).map(r => {
|
||||
const h = r.home ?? 0
|
||||
const a = r.away ?? 0
|
||||
const winner = h > a ? 'home' : h < a ? 'away' : 'tie'
|
||||
return (
|
||||
<tr key={r.label} className="border-b border-ink-100">
|
||||
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||
{r.home ?? '—'}
|
||||
</td>
|
||||
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||||
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||||
{r.away ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 近况/H2H 单区块 */
|
||||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||||
return (
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
|
||||
{rows && rows.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{rows.map((r, i) => {
|
||||
const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'
|
||||
const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs'
|
||||
const label = side === 'h2h'
|
||||
? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}`
|
||||
: `${score}`
|
||||
return (
|
||||
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
|
||||
<span className="text-ink-400">{date}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-2xs text-ink-300">暂无</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 历史预测单行(含专家报告入口) */
|
||||
function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
|
||||
const badge = p.status === 'degraded'
|
||||
? { label: 'degraded', cls: 'text-press' }
|
||||
: p.settled
|
||||
? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' }
|
||||
: { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' }
|
||||
const score = (p.pred_home_goals != null && p.pred_away_goals != null)
|
||||
? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}`
|
||||
: '—'
|
||||
const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null)
|
||||
? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null
|
||||
const hasAgents = p.agent_outputs && p.agent_outputs.length > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="tabular-nums text-ink-600">
|
||||
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
|
||||
{alt && <span className="ml-1 text-ink-400">备选 {alt}</span>}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{p.subjective_confidence != null && (
|
||||
<span className="text-2xs tabular-nums text-ink-400">信心 {Math.round(p.subjective_confidence * 100)}%</span>
|
||||
)}
|
||||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||||
<span className="truncate">{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'}</span>
|
||||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||||
</div>
|
||||
{p.reasoning && (
|
||||
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果
|
||||
export async function loadMatchDetailBundle(
|
||||
matchId: number,
|
||||
): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(matchId).catch(() => null),
|
||||
fetchMatchContext(matchId).catch(() => null),
|
||||
])
|
||||
return { detail: d, ctx: c }
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS } from '../types'
|
||||
import { AgentsPanel } from './AgentsPanel'
|
||||
import { OutcomePanel } from './OutcomePanel'
|
||||
import { ReasoningPanel } from './ReasoningPanel'
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
.**
|
||||
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
||||
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
|
||||
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
|
||||
*/
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
embedded = false,
|
||||
}: {
|
||||
prediction: Prediction
|
||||
match: Match
|
||||
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
const reports = prediction.agent_outputs ?? []
|
||||
const okReports = reports.filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||
{!embedded && (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{degraded && (
|
||||
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||
<p className="font-serif text-sm font-bold text-press-dark">
|
||||
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||
</p>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||
</p>
|
||||
|
||||
<AgentsPanel prediction={prediction} />
|
||||
|
||||
<ReasoningPanel prediction={prediction} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictProgress() {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||||
const SLICE_END = 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||
|
||||
return (
|
||||
<div className="px-5 py-8 sm:px-8">
|
||||
{/* 阶段标题 */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="text-press" />
|
||||
<span className="font-serif text-sm font-bold text-ink-900">
|
||||
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||
{phase === 'agents' && '五路专家并行分析中'}
|
||||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条:渐进式,不封顶到 100% */}
|
||||
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||
<div
|
||||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 专家灯序(多专家模式) */}
|
||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||
{agents.map((a, i) => {
|
||||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||||
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||||
</p>
|
||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function PredictModal({
|
||||
match,
|
||||
predicting,
|
||||
prediction,
|
||||
error,
|
||||
onClose,
|
||||
}: {
|
||||
match: Match
|
||||
predicting: boolean
|
||||
prediction: Prediction | null
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
return () => document.removeEventListener('keydown', h)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" 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 className="flex-1 overflow-y-auto">
|
||||
{predicting ? (
|
||||
<PredictProgress />
|
||||
) : error ? (
|
||||
<div className="px-5 py-10 text-center sm:px-8">
|
||||
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||||
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||||
{error}
|
||||
</p>
|
||||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||
</div>
|
||||
) : prediction ? (
|
||||
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* OutcomePanel: 预测主结论 —— 比分 / 胜平负 / 置信度 / 成本。
|
||||
*
|
||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||
*/
|
||||
import TeamSideTag from '../../../../components/TeamSideTag'
|
||||
import type { Match, Prediction } from '../../types'
|
||||
import { OUTCOME_LABEL } from '../../types'
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
function Meter({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
function OutcomeLine({
|
||||
pick,
|
||||
confidence,
|
||||
}: {
|
||||
pick: string | null
|
||||
confidence: number | null
|
||||
}) {
|
||||
const options = ['1', 'X', '2'] as const
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||
{options.map(o => {
|
||||
const on = pick === o
|
||||
return (
|
||||
<div key={o} className="flex flex-col items-center gap-1">
|
||||
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||
{OUTCOME_LABEL[o]}
|
||||
</span>
|
||||
{on && confidence !== null && (
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
置信 {Math.round(confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{pick && confidence !== null && (
|
||||
<div className="mx-auto mt-3 max-w-xs">
|
||||
<Meter value={confidence} />
|
||||
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测成本展示:耗时 + token + 限流余量 */
|
||||
function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null
|
||||
const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null
|
||||
? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}`
|
||||
: null
|
||||
|
||||
if (!latency && !tokens && prediction.rate_limit_remaining == null) return null
|
||||
|
||||
return (
|
||||
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||
{latency && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||
</span>
|
||||
)}
|
||||
{tokens && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||
</span>
|
||||
)}
|
||||
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||
<span className="text-press" title="每分钟最多 10 次预测">
|
||||
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OutcomePanel({ prediction, match }: { prediction: Prediction; match: Match }) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
|
||||
return (
|
||||
<>
|
||||
{!degraded && (
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||
备选{' '}
|
||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!degraded && (
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!degraded && <PredictionCost prediction={prediction} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ReasoningPanel: 终裁意见 / 降级原因 —— 预测的文本解释。
|
||||
*
|
||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||
*/
|
||||
import type { Prediction } from '../../types'
|
||||
|
||||
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
|
||||
if (!prediction.reasoning) return null
|
||||
|
||||
// 降级态:reasoning 展示为「降级原因」
|
||||
if (degraded) {
|
||||
return (
|
||||
<section>
|
||||
<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">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 成功态:reasoning 展示为「终裁意见」
|
||||
return (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||
</blockquote>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 公开站联赛列表(P1-3):优先请求 GET /api/v1/leagues,
|
||||
* 失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
|
||||
*
|
||||
* 显示名规则:常量里已有的 code 沿用中文标签(保持现有 UI 语言不变),
|
||||
* 新增联赛用 API 返回的 name;排序按常量顺序优先、新联赛按 API 返回序追加。
|
||||
* API 仅返回 {id, code, name, country},无敏感配置字段。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchLeagues } from '../../../admin/dal'
|
||||
import { LEAGUES } from '../types'
|
||||
|
||||
export function useLeagues(): { code: string; name: string }[] {
|
||||
const [leagues, setLeagues] = useState(LEAGUES)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
|
||||
const rows = await fetchLeagues()
|
||||
if (!alive || rows.length === 0) return
|
||||
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
|
||||
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
|
||||
const merged = rows
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) => (rank.get(a.code) ?? LEAGUES.length) - (rank.get(b.code) ?? LEAGUES.length),
|
||||
)
|
||||
.map(l => ({ code: l.code, name: zhName.get(l.code) ?? l.name }))
|
||||
setLeagues(merged)
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return leagues
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出。语义不变:
|
||||
* - 连点防护:同一场比赛预测中再次点击直接忽略
|
||||
* - 竞态防护:递增序号,过期响应丢弃
|
||||
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
|
||||
* - 5 分钟超时,与 nginx 代理 300s 对齐
|
||||
*/
|
||||
import { useRef, useState } from 'react'
|
||||
import { http } from '../../../lib/http'
|
||||
import type { Match, Prediction } from '../types'
|
||||
|
||||
/** 把后端/网络错误翻译成用户可读文案 */
|
||||
function readablePredictError(e: unknown): string {
|
||||
if (e instanceof Error) {
|
||||
const m = e.message
|
||||
if (/429/.test(m)) {
|
||||
// 429 来自后端限流(每分钟 10 次),非上游 LLM
|
||||
return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。'
|
||||
}
|
||||
if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试'
|
||||
if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额'
|
||||
if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测'
|
||||
if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测'
|
||||
if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试'
|
||||
return m
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
interface UseMatchPredictOptions {
|
||||
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||
onError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||
const predictSeq = useRef(0)
|
||||
// 预测请求控制器:关闭弹窗时中止
|
||||
const predictAbort = useRef<AbortController | null>(null)
|
||||
|
||||
function closePredict() {
|
||||
predictAbort.current?.abort()
|
||||
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||||
setPredictingId(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(null)
|
||||
onError(null)
|
||||
}
|
||||
|
||||
const predict = async (m: Match) => {
|
||||
// 防连点:若该场比赛已在预测中,直接忽略
|
||||
if (predictingId === m.id) return
|
||||
const seq = ++predictSeq.current
|
||||
setPredictingId(m.id)
|
||||
onError(null)
|
||||
setPrediction(null)
|
||||
setPredictionFor(m)
|
||||
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
||||
const controller = new AbortController()
|
||||
predictAbort.current = controller
|
||||
const timer = setTimeout(() => controller.abort(), 300_000)
|
||||
try {
|
||||
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
|
||||
timeoutMs: 300_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (seq !== predictSeq.current) return
|
||||
setPrediction(data)
|
||||
} catch (e) {
|
||||
if (seq !== predictSeq.current) return
|
||||
onError(
|
||||
e instanceof DOMException && e.name === 'AbortError'
|
||||
? '预测超时(5 分钟),请稍后重试'
|
||||
: readablePredictError(e),
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (seq === predictSeq.current) setPredictingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
predictingId,
|
||||
prediction,
|
||||
predictionFor,
|
||||
predict,
|
||||
closePredict,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* useMatchesList: 赛程列表数据获取(筛选/游标分页/进行中比赛)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出。竞态防护语义不变 —— 递增序号只认最后一次请求;
|
||||
* loadMore 不自增序号(切换筛选才自增,翻页跟随当前序列)。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { http } from '../../../lib/http'
|
||||
import type { Match } from '../types'
|
||||
|
||||
interface UseMatchesListOptions {
|
||||
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||
onError: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useMatchesList({ onError }: UseMatchesListOptions) {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
const [matches, setMatches] = useState<Match[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
|
||||
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
|
||||
|
||||
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||
const loadSeq = useRef(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++loadSeq.current
|
||||
setLoading(true)
|
||||
setLoadingMore(false)
|
||||
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
|
||||
onError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||
setMatches(data.items)
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false)
|
||||
}
|
||||
}, [league, status, onError])
|
||||
|
||||
// 加载下一页(游标分页)
|
||||
const loadMore = async () => {
|
||||
if (!nextCursor || loadingMore) return
|
||||
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||
const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`)
|
||||
if (seq !== loadSeq.current) return
|
||||
setMatches(prev => [...prev, ...data.items])
|
||||
setNextCursor(data.next_cursor ?? null)
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq.current) return
|
||||
onError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载进行中比赛(顶部独立区块)
|
||||
const loadLive = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ league, status: 'in_play', limit: '20' })
|
||||
const data = await http.get<{ items: Match[] }>(`/matches?${params}`)
|
||||
setLiveMatches(data.items ?? [])
|
||||
} catch {
|
||||
/* ignore:进行中非核心功能 */
|
||||
}
|
||||
}, [league])
|
||||
|
||||
useEffect(() => { load(); loadLive() }, [load, loadLive])
|
||||
|
||||
return {
|
||||
league, setLeague,
|
||||
status, setStatus,
|
||||
matches,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadingMore,
|
||||
showAllUpcoming, setShowAllUpcoming,
|
||||
liveMatches,
|
||||
load,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Matches 页面族共享类型与常量。
|
||||
*
|
||||
* D3(工程债): Matches.tsx 原本 ~1400 行,类型/常量/预测面板/详情面板/
|
||||
* 列表逻辑全部内联。本文件是拆分后的共享层 —— 只放数据契约与纯常量,
|
||||
* 不含 React 组件。
|
||||
*/
|
||||
|
||||
/** /matches 列表项(公开接口) */
|
||||
export interface Match {
|
||||
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
|
||||
}
|
||||
|
||||
/** POST /predict 响应 */
|
||||
export interface Prediction {
|
||||
prediction_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
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
|
||||
agent_outputs: AgentReport[] | null
|
||||
agent_weights: Record<string, number> | null
|
||||
context: string
|
||||
latency_ms: number | null
|
||||
prompt_tokens: number | null
|
||||
completion_tokens: number | null
|
||||
rate_limit_remaining: number | null
|
||||
}
|
||||
|
||||
/** 多专家单路报告 */
|
||||
export interface AgentReport {
|
||||
agent: string
|
||||
status: string
|
||||
data_sufficiency: string
|
||||
analysis: string
|
||||
home_edge: number | null
|
||||
subjective_confidence: number | null
|
||||
key_evidence: string[]
|
||||
exp_home_goals: number | null
|
||||
exp_away_goals: number | null
|
||||
probable_score: string | null
|
||||
model: string
|
||||
latency_ms: number | null
|
||||
}
|
||||
|
||||
export const AGENT_LABELS: Record<string, string> = {
|
||||
h2h: '历史交锋分析专家',
|
||||
form: '近期状态分析专家',
|
||||
stats: '攻防数据分析专家',
|
||||
home_away: '主客因素分析专家',
|
||||
standings: '联赛排名分析专家',
|
||||
}
|
||||
|
||||
export const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
{ code: 'CL', name: '欧冠' },
|
||||
{ code: 'EL', name: '欧联' },
|
||||
]
|
||||
|
||||
/** 汉字编号,给专家意见排版用 */
|
||||
export const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||
|
||||
export const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
finished: { label: '已完赛', cls: 'text-ink-400' },
|
||||
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
||||
// 键与 normalize.py 的 VALID_STATUS 对齐: 库里存的是 in_play(上游 live 被归一化),不存在 'live' 状态
|
||||
in_play: { label: '进行中', cls: 'text-press font-medium' },
|
||||
paused: { label: '暂停', cls: 'text-press font-medium' },
|
||||
postponed: { label: '延期', cls: 'text-ink-400' },
|
||||
cancelled: { label: '取消', cls: 'text-ink-400' },
|
||||
suspended: { label: '中止', cls: 'text-ink-400' },
|
||||
}
|
||||
|
||||
/** 1x2 → 中文标签 */
|
||||
export const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Matches 页面族共享的原子 UI 小件(无业务状态)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数
|
||||
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。
|
||||
*/
|
||||
import type { Match } from './types'
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 骨架占位行:低调脉动灰块 */
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||
export function formatDateHeader(dateKey: string): string {
|
||||
if (!dateKey) return '未开赛'
|
||||
const d = new Date(dateKey + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKey
|
||||
const today = new Date()
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||
export function toLocalDateKey(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 按本地日期分组(非 UTC),保持时间序 */
|
||||
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||
const map = new Map<string, Match[]>()
|
||||
for (const m of list) {
|
||||
const key = toLocalDateKey(m.match_date)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
|
||||
function addDays(d: Date, n: number): string {
|
||||
const x = new Date(d)
|
||||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||
return dateKey(x)
|
||||
}
|
||||
|
||||
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||||
export function withinNext3Days(matchDate: string): boolean {
|
||||
const key = toLocalDateKey(matchDate)
|
||||
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||
}
|
||||
@@ -1,6 +1,63 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 纸白:微暖底色,像新闻纸而不是纯白画布
|
||||
paper: {
|
||||
50: '#FDFCF8',
|
||||
100: '#F7F4EC',
|
||||
200: '#EDE9DE',
|
||||
300: '#DDD7C7',
|
||||
},
|
||||
// 墨色:暖黑灰阶,替代冷调 slate
|
||||
ink: {
|
||||
50: '#FAF9F7',
|
||||
100: '#F0EEE9',
|
||||
200: '#E2DFD7',
|
||||
300: '#C9C4B8',
|
||||
400: '#9C9587',
|
||||
500: '#6E675B',
|
||||
600: '#524C42',
|
||||
700: '#3B362E',
|
||||
800: '#282420',
|
||||
900: '#17140F',
|
||||
},
|
||||
// 印报红:全站唯一强调色,克制使用
|
||||
press: {
|
||||
DEFAULT: '#9E1B1B',
|
||||
dark: '#7C1414',
|
||||
wash: '#F7E9E4',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||
brush: ['"Liu Jian Mao Cao"', '"KaiTi"', '"STKaiti"', '"Kaiti SC"', '"楷体"', '"Songti SC"', 'serif'],
|
||||
// 标题与比分:宋体血统,报纸版面的骨架
|
||||
serif: [
|
||||
'Georgia',
|
||||
'"Times New Roman"',
|
||||
'"Songti SC"',
|
||||
'STSong',
|
||||
'SimSun',
|
||||
'"Noto Serif CJK SC"',
|
||||
'serif',
|
||||
],
|
||||
// 正文:中文黑体栈
|
||||
sans: [
|
||||
'"PingFang SC"',
|
||||
'"Hiragino Sans GB"',
|
||||
'"Microsoft YaHei"',
|
||||
'"Noto Sans CJK SC"',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['11px', { lineHeight: '16px' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
+5
-1
@@ -12,13 +12,17 @@ dependencies = [
|
||||
"asyncpg>=0.29",
|
||||
"psycopg2-binary>=2.9",
|
||||
"httpx>=0.27",
|
||||
"alembic>=1.13",
|
||||
"cryptography>=42.0",
|
||||
"croniter>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# 开发/CI 依赖:本地安装用 pip install -e ".[dev]",CI 用 pip-sync requirements-dev.txt
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.27",
|
||||
"pip-tools>=7.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user