Compare commits
135
Commits
8e6ad5394e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae05833de3 | ||
|
|
eedfc50bbc | ||
|
|
f33a1d91ff | ||
|
|
e8e626e3e4 | ||
|
|
812d19c9ed | ||
|
|
d863982527 | ||
|
|
e6373d3c9a | ||
|
|
f71f29b4cb | ||
|
|
2cfc9ccc0f | ||
|
|
a7e8b75503 | ||
|
|
575507e44f | ||
|
|
5001093c3b | ||
|
|
45535aa921 | ||
|
|
d7d903b35b | ||
|
|
a6740f6140 | ||
|
|
38e1c6c31f | ||
|
|
0742eef52e | ||
|
|
f00bf71e8f | ||
|
|
246b06379d | ||
|
|
3fc3e91bd3 | ||
|
|
007a276fb7 | ||
|
|
83a1eed283 | ||
|
|
9c77efaa4b | ||
|
|
b9beddacf7 | ||
|
|
77ecb078a3 | ||
|
|
51fbc1828e | ||
|
|
1422afd6f6 | ||
|
|
7c4a244ce9 | ||
|
|
432bfee8ad | ||
|
|
f1c586111f | ||
|
|
db3c64d49b | ||
|
|
d013407aa1 | ||
|
|
0ab838258c | ||
|
|
9cedb874f7 | ||
|
|
1cdc6411f5 | ||
|
|
f0c1cc1491 | ||
|
|
8fef411ced | ||
|
|
197641b9f7 | ||
|
|
1a9dc63edd | ||
|
|
b15678b3f6 | ||
|
|
28ecf85da8 | ||
|
|
b749b1c621 | ||
|
|
69ec19e646 | ||
|
|
5f4075be22 | ||
|
|
eae88f4cd9 | ||
|
|
2b52478b8f | ||
|
|
b30ad56319 | ||
|
|
b997c06ede | ||
|
|
1ddf697c97 | ||
|
|
5ff43d4984 | ||
|
|
41cb2edd47 | ||
|
|
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 |
+3
-1
@@ -3,6 +3,9 @@
|
||||
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
|
||||
APP_ENV=development
|
||||
LOG_LEVEL=INFO
|
||||
# 日志持久化:空=仅 stdout + Admin 内存日志页(重启清零);填路径则额外写滚动文件(10MB×5)。
|
||||
# 本地开发示例: LOG_FILE=./logs/app.log (容器内由 compose 默认设为 /app/logs/app.log 并挂卷)
|
||||
LOG_FILE=
|
||||
API_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
@@ -40,7 +43,6 @@ LLM_TIMEOUT=60
|
||||
|
||||
# ---- 数据源 ----
|
||||
BZZOIRO_KEY=
|
||||
API_FOOTBALL_KEY=
|
||||
|
||||
# ---- CORS ----
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
@@ -5,6 +5,7 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
logs/
|
||||
|
||||
# AI 助手上下文文件(不入库)
|
||||
CLAUDE.md
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Profeto CI —— 首次引入自动化质量防线。
|
||||
#
|
||||
# 背景:此前仓库无任何 CI。唯一的测试文件 frontend/src/lib/http.test.ts
|
||||
# 从未被自动执行过,类型检查也仅靠本地手跑 tsc。本流水线把
|
||||
# 「类型检查 + 单元测试 + 构建」固化为 MR 门禁。
|
||||
#
|
||||
# 复用项目自带的 Docker 镜像(与 docker-compose.yml 同源),
|
||||
# 避免 CI 环境与本地/线上不一致。
|
||||
|
||||
stages:
|
||||
- verify
|
||||
|
||||
default:
|
||||
image: node:22-alpine
|
||||
|
||||
# ── 前端:Lint ────────────────────────────────────────────────────
|
||||
# 拦截「机器能看出来、人容易漏掉」的问题:幻影 CSS 变体、
|
||||
# 硬编码色值、渲染期间副作用、未使用符号。
|
||||
# 当前基线为 0 error / 56 warning,故本 job 会真实失败于新增 error。
|
||||
frontend-lint:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm lint
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:类型检查 ────────────────────────────────────────────────
|
||||
frontend-typecheck:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm typecheck
|
||||
# 缓存 pnpm store,避免每次 MR 重新下载依赖
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:单元测试 ────────────────────────────────────────────────
|
||||
# 这是 http.test.ts 第一次被自动化调用。它覆盖 HTTP 层的
|
||||
# method/body/Content-Type 组装与 /health 免前缀豁免 —— 后者的
|
||||
# 回归会导致管理后台右上角永远显示「系统异常」。
|
||||
frontend-test:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm test
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:生产构建 ────────────────────────────────────────────────
|
||||
# 独立于 typecheck:构建会暴露类型检查覆盖不到的问题
|
||||
# (Tailwind 配置错误、资源缺失、chunk 拆分失败等)。
|
||||
frontend-build:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm build
|
||||
artifacts:
|
||||
name: "frontend-dist-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- frontend/dist/
|
||||
expire_in: 1 week
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
@@ -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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
+8
-1
@@ -15,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# 将工作目录所有权移交给非 root 用户
|
||||
# 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
|
||||
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
|
||||
# 非 root 进程写日志会 PermissionError(Errno 13)。
|
||||
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
|
||||
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
|
||||
RUN chown -R profeto:profeto /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -12,28 +12,30 @@
|
||||
│ REST API
|
||||
┌──────────────────────▼──────────────────────────────┐
|
||||
│ FastAPI │
|
||||
│ ├── /api/v1/matches 比赛查询 │
|
||||
│ ├── /api/v1/matches 比赛查询(公开只读) │
|
||||
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
|
||||
│ ├── /api/v1/ingest/* 数据采集 │
|
||||
│ ├── /api/v1/eval/* 评估回填 │
|
||||
│ └── /api/v1/backtest 回测 │
|
||||
│ ├── /api/v1/ingest/* 数据采集(需管理员) │
|
||||
│ ├── /api/v1/eval/* 评估回填(需管理员) │
|
||||
│ └── /api/v1/backtest 回测(需管理员) │
|
||||
└──────────┬─────────────────────────────┬────────────┘
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌─────────────▼────────────┐
|
||||
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
|
||||
│ 6 张表 │ │ 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 比分 / 赛程 / 统计 / 积分榜 │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -55,7 +57,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
||||
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
|
||||
├─→ B 攻防数据专家 ─┤
|
||||
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
|
||||
├─→ D 阵容完整专家 ─┤
|
||||
├─→ D 联赛排名专家 ─┤
|
||||
└─→ E 历史交锋专家 ─┘
|
||||
```
|
||||
|
||||
@@ -66,8 +68,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
||||
|
||||
### 数据正确性保障
|
||||
|
||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据
|
||||
- **Injury 防泄漏**: 伤停查询强制 `retrieved_at <= cutoff`
|
||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
|
||||
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
|
||||
- **数据库约束**: CHECK 约束作为最后一道防线
|
||||
|
||||
@@ -130,6 +131,21 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`。
|
||||
|
||||
## 生产上线检查清单
|
||||
|
||||
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
|
||||
|
||||
- [ ] `APP_ENV=production`(安全校验 / Cookie `Secure` / 管理端点 fail-closed 的总开关)
|
||||
- [ ] `SECRET_KEY` 强随机:`openssl rand -base64 32`,禁止弱值
|
||||
- [ ] `ADMIN_PASSWORD` 或 `ADMIN_API_KEY` 至少配置其一
|
||||
- [ ] 数据库强密码,禁止 `football:football` 等示例弱密码
|
||||
- [ ] HTTPS(反代终结 TLS;production 下会话 Cookie 自动 `Secure`)
|
||||
- [ ] 反代后设 `TRUST_PROXY_HEADERS=True`,仅可信反代可达 API,并配置 `X-Forwarded-For` / `X-Real-IP`
|
||||
- [ ] 限流前置到 Nginx `limit_req`;应用内限流与 KeyRing 仅单进程有效,多 worker 会放大配额
|
||||
- [ ] uvicorn 单 worker(默认);需扩容先网关统一限流再起多实例
|
||||
- [ ] 启动后验证 `/health` 与 `/health/ready` 均 200
|
||||
- [ ] 数据库迁移已内置:compose/Dockerfile 启动即执行 `alembic upgrade head`
|
||||
|
||||
## 安全与限流
|
||||
|
||||
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
|
||||
@@ -141,20 +157,29 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
## API 概览
|
||||
|
||||
**公开只读**(无需登录;`predict` 带内存限流):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) |
|
||||
| GET | `/api/v1/leagues` | 联赛列表 |
|
||||
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`) |
|
||||
| GET | `/api/v1/predictions` | 预测历史 |
|
||||
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 |
|
||||
| POST | `/api/v1/ingest/understat` | 回填 xG |
|
||||
| POST | `/api/v1/ingest/injuries` | 采集伤停 |
|
||||
| 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) |
|
||||
|
||||
**需管理员**(Cookie 会话或 `X-API-Key`):
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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` | 历史回测 |
|
||||
| GET | `/health` | 存活检查 |
|
||||
| GET | `/health/ready` | 就绪检查(含 DB) |
|
||||
| `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -163,36 +188,46 @@ Profeto/
|
||||
├── src/
|
||||
│ ├── api/ # FastAPI 路由层
|
||||
│ │ ├── app.py # 应用工厂 + lifespan
|
||||
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
|
||||
│ │ ├── schemas.py # Pydantic 请求/响应模型
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 比赛查询
|
||||
│ │ ├── predict.py # 预测入口
|
||||
│ │ ├── ingest.py # 数据采集
|
||||
│ │ ├── eval.py # 评估回填
|
||||
│ │ └── backtest.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 配置
|
||||
│ │ ├── crypto.py # 加密/哈希
|
||||
│ │ ├── http_client.py # 共享 httpx 客户端
|
||||
│ │ └── retry.py # 重试工具(指数退避)
|
||||
│ │ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
|
||||
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
||||
│ │ ├── scheduler.py # 进程内 cron 调度器
|
||||
│ │ └── security_check.py # 启动安全校验
|
||||
│ ├── data/ # 数据层
|
||||
│ │ ├── sources.py # DataSource 协议 + 注册表
|
||||
│ │ ├── 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 模型 (6 表)
|
||||
│ │ ├── 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 基础设施 + 解析
|
||||
@@ -200,6 +235,11 @@ Profeto/
|
||||
│ └── prompts/ # Prompt 模板
|
||||
├── alembic/ # 数据库迁移
|
||||
├── frontend/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
|
||||
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
|
||||
│ ├── components/ # 共享组件
|
||||
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
|
||||
├── docs/ # 详细文档
|
||||
├── tests/ # 单元测试
|
||||
├── docker-compose.yml
|
||||
@@ -234,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` |
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -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='source_event_id IS NOT NULL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_matches_source_event_id_unique', table_name='matches')
|
||||
@@ -0,0 +1,63 @@
|
||||
"""P0-01: 比分可信度——score_status + 允许完赛缺分(NULL,禁止伪造 0:0)
|
||||
|
||||
替换 ck_matches_finished_has_score:引入 score_status(known/missing/unknown),
|
||||
完赛 + score_status=missing 时 home/away_goals 必须 NULL(不伪造比分)。
|
||||
|
||||
Revision ID: 0022_match_score_status
|
||||
Revises: 0021_match_source_event_id_unique
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0022_match_score_status'
|
||||
down_revision: Union[str, None] = '0021_match_source_event_id_unique'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 新增 score_status 列(默认 unknown)
|
||||
op.add_column(
|
||||
'matches',
|
||||
sa.Column('score_status', sa.String(20), server_default='unknown', nullable=False),
|
||||
)
|
||||
|
||||
# 2) 按现有数据回填 score_status(绝不写 goals=0):
|
||||
# - 有比分(两列均非 NULL) → known
|
||||
# - 无比分 + 完赛 → missing(缺分)
|
||||
# - 其余 → unknown
|
||||
op.execute(
|
||||
"UPDATE matches SET score_status = 'known'"
|
||||
" WHERE home_goals IS NOT NULL AND away_goals IS NOT NULL"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE matches SET score_status = 'missing'"
|
||||
" WHERE match_status = 'finished' AND home_goals IS NULL AND away_goals IS NULL"
|
||||
)
|
||||
|
||||
# 3) 删除旧约束,加新约束
|
||||
op.drop_constraint('ck_matches_finished_has_score', 'matches', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_matches_score_status_enum', 'matches',
|
||||
"score_status IN ('known', 'missing', 'unknown')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_matches_score_integrity', 'matches',
|
||||
"match_status <> 'finished'"
|
||||
" OR (score_status = 'known' AND home_goals IS NOT NULL AND away_goals IS NOT NULL)"
|
||||
" OR (score_status IN ('missing', 'unknown') AND home_goals IS NULL AND away_goals IS NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_matches_score_integrity', 'matches', type_='check')
|
||||
op.drop_constraint('ck_matches_score_status_enum', 'matches', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_matches_finished_has_score', 'matches',
|
||||
"match_status <> 'finished' OR (home_goals IS NOT NULL AND away_goals IS NOT NULL)",
|
||||
)
|
||||
op.remove_column('matches', 'score_status')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""P0-02: 积分榜改为追加快照(append-only) + available_at
|
||||
|
||||
去掉 uq_standings_league_season_team(league,season,team 唯一),
|
||||
改为 (league, season, team, available_at) 唯一;
|
||||
每次采集 INSERT 新行(available_at=now),支持回测还原历史榜单。
|
||||
|
||||
Revision ID: 0023_standings_append_only
|
||||
Revises: 0022_match_score_status
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0023_standings_append_only'
|
||||
down_revision: Union[str, None] = '0022_match_score_status'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# 1) 加 available_at 列(非空,默认 now;存量回填 retrieved_at 或 now)
|
||||
op.add_column(
|
||||
'standings',
|
||||
sa.Column('available_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
# 存量行: available_at 取 retrieved_at(若存在)否则 now
|
||||
op.execute("UPDATE standings SET available_at = COALESCE(retrieved_at, NOW())")
|
||||
|
||||
# 2) 去旧唯一约束,加新唯一约束(league, season, team, available_at)
|
||||
op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique')
|
||||
# 原始索引名拼写为 leason(历史遗留),按实际库名删除
|
||||
op.drop_index('ix_standings_league_season_pos', table_name='standings', if_exists=True)
|
||||
op.create_index('ix_standings_league_season_pos_v2', 'standings', ['league_id', 'season', 'position'])
|
||||
op.create_unique_constraint(
|
||||
'uq_standings_league_season_team_available', 'standings',
|
||||
['league_id', 'season', 'team_id', 'available_at'],
|
||||
)
|
||||
op.create_index(
|
||||
'ix_standings_league_season_team_available', 'standings',
|
||||
['league_id', 'season', 'team_id', 'available_at'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_standings_league_season_team_available', table_name='standings')
|
||||
op.drop_constraint('uq_standings_league_season_team_available', 'standings', type_='unique')
|
||||
op.drop_index('ix_standings_league_season_pos', table_name='standings')
|
||||
op.create_index('ix_standings_leason_season_pos', 'standings', ['league_id', 'season', 'position'])
|
||||
op.create_unique_constraint(
|
||||
'uq_standings_league_season_team', 'standings',
|
||||
['league_id', 'season', 'team_id'],
|
||||
)
|
||||
op.drop_column('standings', 'available_at')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""P0-03: Prediction 幂等指纹——移除旧唯一约束,改为 partial unique on input_hash
|
||||
|
||||
input_hash 非空时唯一(同指纹返回已有行,不 UPDATE/INSERT);
|
||||
兼容旧数据 NULL input_hash(不强制回填)。
|
||||
|
||||
Revision ID: 0024_prediction_idempotent_fingerprint
|
||||
Revises: 0023_standings_append_only
|
||||
Create Date: 2026-09-22
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0024_prediction_idempotent_fingerprint'
|
||||
down_revision: Union[str, None] = '0023_standings_append_only'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 移除旧唯一约束(match, provider, model, mode, run_type)
|
||||
op.drop_constraint(
|
||||
'uq_predictions_match_provider_model_mode_run_type',
|
||||
'predictions', type_='unique',
|
||||
)
|
||||
# P0-03: partial unique on input_hash(非空时唯一)
|
||||
op.create_index(
|
||||
'ix_predictions_input_hash_unique', 'predictions', ['input_hash'], unique=True,
|
||||
postgresql_where=sa.text('input_hash IS NOT NULL'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_predictions_input_hash_unique', table_name='predictions')
|
||||
op.create_unique_constraint(
|
||||
'uq_predictions_match_provider_model_mode_run_type',
|
||||
'predictions',
|
||||
['match_id', 'provider', 'model', 'mode', 'run_type'],
|
||||
)
|
||||
+6
-2
@@ -28,13 +28,15 @@ services:
|
||||
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
||||
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
|
||||
# 日志持久化:写入挂载卷,容器重建不丢(应用内滚动 10MB×6 份封顶)
|
||||
LOG_FILE: /app/logs/app.log
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=5).status==200 else 1)\" || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -42,6 +44,7 @@ services:
|
||||
- ./src:/app/src
|
||||
- ./alembic:/app/alembic
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
- applogs:/app/logs
|
||||
|
||||
frontend:
|
||||
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
||||
@@ -57,3 +60,4 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
applogs:
|
||||
|
||||
+36
-28
@@ -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 (6 张表) │ 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,33 +56,38 @@
|
||||
Profeto/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表)
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
|
||||
│ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
|
||||
│ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
|
||||
│ │ ├── schemas.py # Pydantic v2 请求/响应
|
||||
│ │ └── routes/
|
||||
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
|
||||
│ │ ├── predict.py # 预测 + 预测历史
|
||||
│ │ ├── ingest.py # 采集触发(自管 session,需鉴权)
|
||||
│ │ ├── eval.py # 赛后回填 + 准确率汇总
|
||||
│ │ └── backtest.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 # 6 张表 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/
|
||||
@@ -91,16 +95,20 @@ Profeto/
|
||||
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
|
||||
│ │ └── prompts/
|
||||
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
|
||||
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
|
||||
│ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
|
||||
│ └── core/
|
||||
│ ├── config.py # pydantic-settings
|
||||
│ ├── crypto.py # 加密/哈希
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ └── retry.py # 重试工具
|
||||
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理)
|
||||
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页)
|
||||
├── tests/ # 核心 + agent 测试
|
||||
│ ├── 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/ # 本文档
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
@@ -113,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 统计管线,只补空字段) |
|
||||
|
||||
+80
-34
@@ -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"}]}
|
||||
]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -96,13 +145,13 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
||||
"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",
|
||||
{"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,7 +208,7 @@ 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` 聚合已结算预测:
|
||||
|
||||
@@ -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` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
|
||||
|
||||
+7
-7
@@ -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 份 + 终裁照常执行。
|
||||
@@ -89,7 +89,7 @@ POST /predict {match_id, mode: "multi"}
|
||||
"1x2": "1",
|
||||
"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}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 # 终裁
|
||||
```
|
||||
|
||||
+100
-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
|
||||
|
||||
@@ -28,12 +29,6 @@
|
||||
> - 黄牌: `home_yellow_cards` / `away_yellow_cards`
|
||||
> - 红牌: `home_red_cards` / `away_red_cards`
|
||||
|
||||
### understat
|
||||
|
||||
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
|
||||
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
|
||||
- 通过"天级日期 + 队名归一"匹配已有比赛
|
||||
|
||||
## 数据清洗契约
|
||||
|
||||
所有数据源统一清洗为 `NormalizedMatch`(`src/data/normalize.py`),字段:
|
||||
@@ -65,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
|
||||
|
||||
6 张表:
|
||||
12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。
|
||||
|
||||
```sql
|
||||
-- 联赛
|
||||
@@ -144,21 +195,48 @@ CREATE TABLE predictions (
|
||||
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 新比赛。
|
||||
|
||||
## 入库语义(幂等)
|
||||
|
||||
@@ -170,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 最新快照,支持回测还原历史榜单。
|
||||
|
||||
## 采集建议
|
||||
|
||||
@@ -183,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。
|
||||
|
||||
+40
-2
@@ -33,6 +33,25 @@ curl http://localhost:8000/health
|
||||
> **注意**: `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
|
||||
@@ -67,6 +86,7 @@ cd frontend && npm install && npm run dev
|
||||
|---|---|---|---|
|
||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||
| `LOG_FILE` | ❌ | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
|
||||
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
||||
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
||||
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
||||
@@ -81,13 +101,31 @@ cd frontend && npm install && npm run dev
|
||||
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
||||
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
||||
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
|
||||
| `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 提供商配置示例
|
||||
|
||||
### OpenAI
|
||||
|
||||
+11
-7
@@ -83,16 +83,16 @@ Profeto/
|
||||
│ │ └── app.py # FastAPI 工厂
|
||||
│ ├── db/
|
||||
│ │ ├── base.py # SQLAlchemy async engine + session
|
||||
│ │ ├── models.py # 6 张表 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)
|
||||
@@ -110,13 +110,17 @@ 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 配置
|
||||
│ ├── http_client.py # 共享 httpx 客户端
|
||||
│ └── retry.py # 重试工具
|
||||
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
||||
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
|
||||
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
||||
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
||||
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
||||
├── frontend/ # React 单页前端
|
||||
├── alembic/ # 数据库迁移
|
||||
│ └── versions/
|
||||
@@ -194,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 = """
|
||||
{
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* ESLint 配置 —— 工程的自动化质量防线。
|
||||
*
|
||||
* 本文件的核心目的不只是「常规 lint」,而是把审查报告里
|
||||
* 「只能靠人肉 review 发现」的几类问题固化成机器规则:
|
||||
*
|
||||
* 1. 幻影变体:此前 `btn-ghost` 被使用但从未在 CSS 中定义,
|
||||
* 样式静默失效,类型检查和构建都不报错。
|
||||
* 2. 裸控件:components/ui 已提供 Button/Input/Select 基元,
|
||||
* 但页面里仍有人直接写原生标签 + 手拼 className,
|
||||
* 导致样式与无障碍行为再次分叉。
|
||||
* 3. 硬编码色值:设计令牌是唯一颜色来源,十六进制字面量
|
||||
* 会让改令牌时产生「漏网之鱼」。
|
||||
*
|
||||
* 规则原则:宁可少而准,不要多而吵。无法修复的告警只会被忽略。
|
||||
*/
|
||||
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
/**
|
||||
* CSS 变体类名总表 —— index.css 的 @layer components 里定义的全部类。
|
||||
*
|
||||
* 分两组,因为二者的处置方式完全不同:
|
||||
*
|
||||
* - `PRIMITIVE_CLASSES`:已有组件基元等价物(Button / Input / Select / Tabs)。
|
||||
* 页面里**手拼**这些类名 = 绕过基元,样式与无障碍行为会再次分叉。
|
||||
* 这类用法由 `no-restricted-syntax` 报错拦截。
|
||||
*
|
||||
* - `COMPOSITE_CLASSES`:组合型排版类(类似 Tailwind 工具类),
|
||||
* 本就设计成裸用(如 `className="section-head mb-2"`)。
|
||||
* 它们没有、也不该有组件基元,因此不拦截。
|
||||
*
|
||||
* 之所以要专门拦「单独出现」而非子串匹配:因为 `btn` 是 `btn-solid`
|
||||
* 的前缀,`tab` 是 `tab-on` 的前缀。只有被当作独立 token 使用时才是问题。
|
||||
*/
|
||||
const PRIMITIVE_CLASSES = [
|
||||
// Button 基元覆盖
|
||||
'btn',
|
||||
'btn-sm',
|
||||
'btn-solid',
|
||||
'btn-outline',
|
||||
'btn-danger',
|
||||
'btn-ghost',
|
||||
// Input / Select 基元覆盖
|
||||
'field',
|
||||
// Tabs 基元覆盖
|
||||
'tab',
|
||||
'tab-on',
|
||||
]
|
||||
|
||||
const COMPOSITE_CLASSES = [
|
||||
'section-head',
|
||||
'skeleton',
|
||||
'empty-state',
|
||||
'empty-state-title',
|
||||
'empty-state-sub',
|
||||
'empty-state-action',
|
||||
'error-banner',
|
||||
'error-banner-title',
|
||||
'error-banner-detail',
|
||||
'nav-icon',
|
||||
'masthead-rule',
|
||||
'font-brush',
|
||||
]
|
||||
|
||||
/** 保留导出,兼容既有引用;内容为全部组件类名。 */
|
||||
const CSS_VARIANT_CLASSES = [...PRIMITIVE_CLASSES, ...COMPOSITE_CLASSES]
|
||||
|
||||
/**
|
||||
* 匹配「className 值里单独出现的某个基元类名」的正则。
|
||||
*
|
||||
* 只匹配独立 token(前后必须是空白或字符串边界),避免两类误判:
|
||||
* - `btn-solid` 里的 `btn`(前缀子串,合法,由 Button 基元自己输出)
|
||||
* - `searchParams.get('tab')` 这类**非样式**字符串
|
||||
* —— 这正是不能用裸正则扫全量 Literal 的原因(第一版曾误报)。
|
||||
*
|
||||
* 该正则只配合下面的 AST 选择器使用:
|
||||
* `JSXAttribute[name.name="className"] Literal[...]` 把匹配范围
|
||||
* 严格限定在 className 属性值内,字符串里出现同名 token 不再误伤。
|
||||
*/
|
||||
function barePrimitivePattern() {
|
||||
const names = PRIMITIVE_CLASSES.join('|')
|
||||
return new RegExp(`(^|\\s)(${names})(?=\\s|$)`)
|
||||
}
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// 只检查 TS/TSX。CSS 由 Tailwind/PostCSS 管线负责,
|
||||
// 交给 ESLint 解析只会得到 "Declaration expected" 噪声。
|
||||
// 配置类文件自身不参与 lint(它们是规则的声明方,不是被约束方)。
|
||||
ignores: ['dist', 'node_modules', '*.config.js', '*.config.mjs', '*.config.ts'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// ── 全局:浏览器环境 ────────────────────────────────────────────
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2022,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// ── hooks 规则调整 ───────────────────────────────────────────
|
||||
// `set-state-in-effect`:加载类 effect(挂载时发起请求 → 回调里
|
||||
// setState)是 React 官方文档明确认可的模式,该规则在此场景下
|
||||
// 误报率过高。当前有 19 处这类写法,逐条改写属于行为等价的
|
||||
// 大范围重构,不适合混在本次质量修复里。降级为 warn 保留可见性。
|
||||
// 注:真正的「渲染期间副作用」已由 react-hooks/purity 单独拦下
|
||||
// (Collection.tsx 里的 Date.now() 即被它捕获并已修复)。
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
|
||||
// ── 死代码 ───────────────────────────────────────────────────
|
||||
// tsconfig 里 noUnusedLocals/noUnusedParameters 是关的
|
||||
// (存量太多,一次性打开会淹没信号)。这里先用 ESLint 的
|
||||
// 同型规则,允许下划线前缀显式豁免,便于渐进清理。
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// ── 类型安全 ─────────────────────────────────────────────────
|
||||
// `any` 在数据访问层是真实的类型漏洞来源(dal.ts 曾有 14 处)。
|
||||
// 用 warn 而非 error:存量需要分批处理,但不该被遗忘。
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// 空接口等价于无约束类型参数,通常是想写 type 而非 interface
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
|
||||
// ── React ────────────────────────────────────────────────────
|
||||
// 本仓库是纯 SPA(无 RSC、无服务端渲染),Fast Refresh 的粒度为
|
||||
// 整个模块。兼容壳文件(admin/components.tsx、matches/ui.tsx)与
|
||||
// 混装常量+组件的文件会命中此规则,但它们本就是刻意保留的
|
||||
// 再导出层,热更新退化不影响开发体验。关闭以免噪声掩盖真问题。
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 组件基元层:自身必然使用原生标签,豁免相关规则 ──────────────
|
||||
{
|
||||
files: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 禁止硬编码设计令牌色值 + 禁止手拼组件基元类名 ────────────────
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
// 组件基元层自身必然要写这些类名 —— 它是唯一的合法使用点
|
||||
ignores: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
// error 级:两类问题(硬编码色值、手拼基元类名)存量均已清零,
|
||||
// 此后任何新增都应在提交前就地修正,CI 可直接阻断。
|
||||
'error',
|
||||
{
|
||||
selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
|
||||
message:
|
||||
'禁止硬编码颜色字面量。请使用设计令牌(press/ink/paper 等)或 CSS 变量。',
|
||||
},
|
||||
{
|
||||
selector: 'TemplateElement[value.raw=/rgba?\\(/]',
|
||||
message:
|
||||
'禁止在模板字符串中硬编码 rgb/rgba 颜色。请使用设计令牌或 CSS 变量。',
|
||||
},
|
||||
// ── 手拼基元类名 ────────────────────────────────────────────
|
||||
// error 级:这类写法会让组件层形同虚设,且样式/无障碍分叉
|
||||
// 只在运行时显形,类型检查完全沉默。合法写点(components/ui)
|
||||
// 已被本块的 ignores 排除。
|
||||
//
|
||||
// 用 JSXAttribute 选择器把范围钉死在 className 属性值上:
|
||||
// 裸 `Literal[...]` 会误伤 `searchParams.get('tab')` 这类
|
||||
// 恰好含同名 token 的普通字符串。两种写法分别覆盖:
|
||||
// className="field w-full" → String Literal
|
||||
// className={`btn ${x ? 'btn-solid' : ''}`} → TemplateLiteral
|
||||
{
|
||||
selector: `JSXAttribute[name.name="className"] Literal[value=/${barePrimitivePattern().source}/]`,
|
||||
message:
|
||||
'禁止手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的 Button / Input / Select / Tabs 基元 —— 它们统一了变体与无障碍行为。',
|
||||
},
|
||||
{
|
||||
selector: `JSXAttribute[name.name="className"] TemplateElement[value.raw=/${barePrimitivePattern().source}/]`,
|
||||
message:
|
||||
'禁止在模板字符串中手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的基元组件。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 色值豁免:调色板与主题定义是颜色「来源」,不是「使用点」
|
||||
{
|
||||
files: [
|
||||
'src/index.css',
|
||||
'tailwind.config.js',
|
||||
'src/components/ui/**',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 测试文件 ────────────────────────────────────────────────────
|
||||
{
|
||||
files: ['src/**/*.test.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// 导出变体列表供后续规则扩展使用,避免魔法字符串散落
|
||||
export { CSS_VARIANT_CLASSES }
|
||||
+40
-1
@@ -3,7 +3,46 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profeto - 足球 LLM 预测</title>
|
||||
|
||||
<!--
|
||||
SEO / 分享 / 移动端元信息。
|
||||
此前这里只有 charset + viewport + title —— 搜索引擎无摘要、
|
||||
分享到社交平台无预览卡片、iOS 添加到主屏是截图占位、
|
||||
移动端浏览器地址栏不染色。品牌在浏览器层面完全裸奔。
|
||||
-->
|
||||
<title>Profeto · 足球赛程与 LLM 预测</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Profeto 提供主流联赛赛程、实时积分榜,以及由多专家大语言模型生成的比分与胜平负预测。仅供研究参考,不构成投注建议。"
|
||||
/>
|
||||
<meta name="theme-color" content="#FDFCF8" />
|
||||
|
||||
<!-- 索引控制:公开站点允许收录,但不索引管理后台 -->
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- 图标:SVG 优先(任意缩放清晰),PNG 作为老浏览器兜底 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Open Graph:微信 / 飞书 / 社交平台分享卡片 -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Profeto" />
|
||||
<meta property="og:title" content="Profeto · 足球赛程与 LLM 预测" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
||||
/>
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter / X 卡片 -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Profeto · 足球赛程与 LLM 预测" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1457
-666
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -5,8 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"build": "tsc && vite build && sh scripts/verify-tokens.sh",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
|
||||
"verify:tokens": "sh scripts/verify-tokens.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -18,9 +22,14 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2601
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 650 B |
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
Binary file not shown.
|
After Width: | Height: | Size: 182 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#FDFCF8"/>
|
||||
<rect x="1.5" y="1.5" width="29" height="29" fill="none" stroke="#17140F" stroke-width="2.5"/>
|
||||
<text x="16" y="23" font-family="serif" font-size="20" font-weight="700" fill="#9E1B1B" text-anchor="middle">先</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# 设计令牌完整性守卫 —— 确认关键颜色类真的生成了 CSS。
|
||||
#
|
||||
# 背景:把商标色改为 CSS 变量时踩过一个坑 —— 变量若写成完整十六进制
|
||||
# (#9E1B1B)而非 RGB 三元组(158 27 27),Tailwind 的透明度修饰符
|
||||
# (bg-press-wash/60)会**静默不生成任何 CSS**。构建通过、类型检查通过、
|
||||
# 控制台无警告,只是样式没了。这类问题只能靠检查产物发现。
|
||||
#
|
||||
# 用法: pnpm build && sh scripts/verify-tokens.sh
|
||||
set -e
|
||||
|
||||
CSS=$(ls dist/assets/*.css 2>/dev/null | head -1)
|
||||
if [ -z "$CSS" ]; then
|
||||
echo "✗ 未找到构建产物 CSS,请先执行 pnpm build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
if grep -q -F -- "$1" "$CSS"; then
|
||||
echo " ✓ $1"
|
||||
else
|
||||
echo " ✗ $1 缺失"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "校验设计令牌在构建产物中的存在性:"
|
||||
check '.bg-press{'
|
||||
check '.text-ink-900{'
|
||||
check '.bg-paper-50{'
|
||||
check '.border-press{'
|
||||
check 'press-wash\/60'
|
||||
check '.masthead-rule{'
|
||||
check '--press:'
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "设计令牌校验失败。"
|
||||
echo "最常见原因:tailwind.config.js 中颜色值未写成"
|
||||
echo " 'rgb(var(--x) / <alpha-value>)'"
|
||||
echo "而写成了完整十六进制或 'var(--x)' —— 前者会丢掉透明度修饰符。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "设计令牌校验通过"
|
||||
+62
-70
@@ -1,92 +1,85 @@
|
||||
/**
|
||||
* 主应用入口
|
||||
*
|
||||
* 顶层三分区导航:
|
||||
* - 比赛/预测 → 公开,报纸风赛程 + 预测
|
||||
* - 评估 → 只读(后端需 admin 鉴权,未登录引导登录)
|
||||
* - 管理 → 采集/回测/配置等(需登录)
|
||||
* 页面结构:
|
||||
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
|
||||
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
|
||||
*
|
||||
* 导航策略:
|
||||
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
||||
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||
*
|
||||
* 代码分割:
|
||||
* - 公开页(Matches/Standings)各自 lazy,后台整体懒加载。
|
||||
* 此前所有页面打进同一个 chunk,一个只想看赛程的匿名访客也要
|
||||
* 下载 Dashboard/Collection/Logs/Eval/Backtest/... 十个后台页面。
|
||||
* - ErrorBoundary 下沉到每个路由级:某个页面渲染崩溃时降级为
|
||||
* 「该页面报错」,而不是整个应用白屏。
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { useState } from 'react'
|
||||
import Matches from './pages/Matches'
|
||||
import { SiteLayout } from './components/SiteLayout'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
/** 报眉日期行 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
// ── 路由级懒加载 ────────────────────────────────────────────────
|
||||
// 每个入口拆成独立 chunk,首屏只加载当前路由所需代码。
|
||||
const Matches = lazy(() => import('./pages/Matches'))
|
||||
const Standings = lazy(() => import('./pages/Standings'))
|
||||
|
||||
/**
|
||||
* 路由级加载占位。
|
||||
* 保持与应用一致的纸色背景与居中位置,避免切换时出现白闪。
|
||||
*/
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<span className="text-xs text-ink-400" role="status" aria-live="polite">
|
||||
加载中…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 顶层导航三分区 */
|
||||
type TopView = 'matches' | 'eval' | 'admin'
|
||||
|
||||
function TopNav({ onNavigate }: { onNavigate: (v: TopView) => void }) {
|
||||
const go = (v: TopView) => {
|
||||
onNavigate(v)
|
||||
const path = v === 'matches' ? '/' : v === 'eval' ? '/admin/eval' : '/admin'
|
||||
window.location.assign(path)
|
||||
}
|
||||
/**
|
||||
* 单个路由的错误与加载边界。
|
||||
*
|
||||
* 注意 ErrorBoundary 必须包在 Suspense 外层:lazy 组件加载失败
|
||||
* (网络中断、部署后 chunk 哈希变化)会以错误形式抛出,而不是
|
||||
* 永远停在加载态。
|
||||
*/
|
||||
function RouteShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<nav className="flex items-center justify-center gap-1 border-b border-ink-200" aria-label="主导航">
|
||||
<button onClick={() => go('matches')} className="tab">
|
||||
<span aria-hidden="true">◇</span> 比赛 / 预测
|
||||
</button>
|
||||
<button onClick={() => go('eval')} className="tab">
|
||||
<span aria-hidden="true">◈</span> 评估
|
||||
</button>
|
||||
<button onClick={() => go('admin')} className="tab">
|
||||
<span aria-hidden="true">⚙</span> 管理
|
||||
</button>
|
||||
</nav>
|
||||
<ErrorBoundary fullScreen={false}>
|
||||
<Suspense fallback={<RouteFallback />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
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-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-3 align-baseline font-serif text-base 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 className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{dateLine()}</span>
|
||||
</div>
|
||||
<TopNav onNavigate={() => {}} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<SiteLayout
|
||||
active="home"
|
||||
footerNote="预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议"
|
||||
>
|
||||
<RouteShell>
|
||||
<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>
|
||||
</RouteShell>
|
||||
</SiteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 管理入口页:直接导向 /admin,由 AdminLayout 处理鉴权(未登录显示登录页) */
|
||||
function AdminEntry() {
|
||||
return <Navigate to="/admin" replace />
|
||||
function StandingsPage() {
|
||||
return (
|
||||
<SiteLayout active="standings" footerNote="数据由 bzzoiro 提供 · 仅供研究参考">
|
||||
<RouteShell>
|
||||
<Standings />
|
||||
</RouteShell>
|
||||
</SiteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -95,7 +88,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminEntry />} />
|
||||
<Route path="/standings" element={<StandingsPage />} />
|
||||
{adminRoutes.map(route => (
|
||||
<Route key={route.path} path={route.path} element={route.element}>
|
||||
{route.children.map(child => (
|
||||
@@ -114,4 +107,3 @@ export default function App() {
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 后台导航图标集。
|
||||
*
|
||||
* 此前这 8 个图标是一段约 90 行的内联 `switch`,塞在 AdminLayout 里,
|
||||
* 使布局文件同时承担「页面骨架」与「图标图形库」两种职责,而且
|
||||
* `name: string` 的参数类型让拼错图标名不会报错 —— 只会静默渲染空白。
|
||||
*
|
||||
* 抽出为独立组件后:
|
||||
* - AdminLayout 只管布局
|
||||
* - 图标名收敛为联合类型,拼错即编译失败
|
||||
* - 新增图标时只改本文件
|
||||
*
|
||||
* 风格与 nav-icon 的线条规范一致:24×24 视框、currentColor 描边、圆头圆角。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 支持的图标名。与 `admin/nav.ts` 中各导航项的 icon 字段对应。 */
|
||||
export type AdminIconName =
|
||||
| 'collection'
|
||||
| 'chart'
|
||||
| 'target'
|
||||
| 'repeat'
|
||||
| 'eval'
|
||||
| 'monitor'
|
||||
| 'settings'
|
||||
| 'logs'
|
||||
|
||||
export interface AdminIconProps {
|
||||
name: AdminIconName
|
||||
/** 附加类名(尺寸/颜色覆盖用) */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 统一的 SVG 外框属性,避免 8 份重复书写 */
|
||||
const svgProps = {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
} as const
|
||||
|
||||
const PATHS: Record<AdminIconName, ReactNode> = {
|
||||
// 数据采集:立方体/包裹
|
||||
collection: (
|
||||
<>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</>
|
||||
),
|
||||
// 柱状图
|
||||
chart: (
|
||||
<>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</>
|
||||
),
|
||||
// 靶心
|
||||
target: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</>
|
||||
),
|
||||
// 循环/重试
|
||||
repeat: (
|
||||
<>
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</>
|
||||
),
|
||||
// 评估:带横线的文档
|
||||
eval: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</>
|
||||
),
|
||||
// 监控:显示器
|
||||
monitor: (
|
||||
<>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</>
|
||||
),
|
||||
// 设置:齿轮
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</>
|
||||
),
|
||||
// 日志:带横线的文档(与 eval 区分:行数位置不同)
|
||||
logs: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="16" y2="17" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台导航图标。
|
||||
*
|
||||
* 未知名称不再静默返回 `null`(那样只会看到一个空位,难以定位),
|
||||
* 而是渲染一个可见的问号方块 —— 缺失的图标应当被一眼看见。
|
||||
*/
|
||||
export function AdminIcon({ name, className }: AdminIconProps) {
|
||||
const paths = PATHS[name]
|
||||
const cls = className ? `nav-icon ${className}` : 'nav-icon'
|
||||
|
||||
if (!paths) {
|
||||
return (
|
||||
<svg {...svgProps} className={cls} aria-hidden="true" data-icon-missing={name}>
|
||||
<rect x="3" y="3" width="18" height="18" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg {...svgProps} className={cls} aria-hidden="true">
|
||||
{paths}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminIcon
|
||||
+120
-108
@@ -7,24 +7,34 @@
|
||||
|
||||
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 { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api'
|
||||
import { fetchHealth } from '../api/dal'
|
||||
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
||||
import Login from './Login'
|
||||
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
||||
import { AdminIcon } from './AdminIcon'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
// ── 观测(只读) ──
|
||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||
{ to: '/admin/eval', label: '评估', icon: '◈' },
|
||||
{ to: '/admin/monitoring', label: '监控', icon: '◐' },
|
||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
||||
// ── 操作(写入,需登录) ──
|
||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||
{ to: '/admin/backtest', label: '回测', icon: '◉' },
|
||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||
]
|
||||
// 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch,
|
||||
// 让布局文件同时承担页面骨架与图标图形库两种职责。
|
||||
|
||||
/**
|
||||
* 侧栏导航项的 className 生成器。
|
||||
*
|
||||
* 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项),
|
||||
* 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里
|
||||
* 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。
|
||||
*/
|
||||
const SIDEBAR_LINK_BASE = 'nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300'
|
||||
const SIDEBAR_LINK_ACTIVE = 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
const SIDEBAR_LINK_IDLE = 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
|
||||
function sidebarLinkClass({ isActive }: { isActive: boolean }): string {
|
||||
return `${SIDEBAR_LINK_BASE} ${isActive ? SIDEBAR_LINK_ACTIVE : SIDEBAR_LINK_IDLE}`
|
||||
}
|
||||
|
||||
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
|
||||
// 单源派生 —— 此处的 NAV_PAGES/ROUTE_LABELS/NAV_SECTIONS 平行清单已删除,
|
||||
// 新增页面/改标签只改 nav.ts 一处。
|
||||
|
||||
/** 报眉日期行,与前台同款式 */
|
||||
function dateLine(): string {
|
||||
@@ -41,6 +51,7 @@ export default function AdminLayout() {
|
||||
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(() => {
|
||||
@@ -108,7 +119,55 @@ export default function AdminLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
|
||||
<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
|
||||
@@ -128,114 +187,67 @@ export default function AdminLayout() {
|
||||
`}
|
||||
aria-label="主导航"
|
||||
>
|
||||
{/* 报头 */}
|
||||
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
|
||||
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="ml-2 align-baseline font-serif text-xs font-normal italic tracking-normal text-ink-500">
|
||||
Profeto
|
||||
</span>
|
||||
</h1>
|
||||
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
|
||||
{/* 侧栏顶部 */}
|
||||
<div className="flex items-center justify-center border-b border-ink-900 px-5 py-5">
|
||||
<h1 className="text-center text-3xl tracking-wide text-ink-900 font-brush">先知</h1>
|
||||
</div>
|
||||
|
||||
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
|
||||
<ul className="space-y-0.5">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-press-wash/60 font-medium text-press'
|
||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-base leading-none opacity-50" aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
{/* 导航:分组 + 小节标题 */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
||||
{/* 仪表盘独立(始终第一项) */}
|
||||
<NavLink to="/admin" end className={sidebarLinkClass}>
|
||||
<AdminIcon name="chart" />
|
||||
仪表盘
|
||||
</NavLink>
|
||||
|
||||
{NAV_SECTIONS.map(section => (
|
||||
<div key={section.title} className="mt-4">
|
||||
<p className="mb-1 px-3 text-2xs font-medium uppercase tracking-widest text-ink-400">
|
||||
{section.title}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{section.items.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} className={sidebarLinkClass}>
|
||||
<AdminIcon name={item.icon} />
|
||||
{item.label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="border-t border-ink-200 px-4 py-3">
|
||||
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
|
||||
<a
|
||||
href="/"
|
||||
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
||||
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>
|
||||
|
||||
{/* ── 主内容区 ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 报眉:日期 + 系统状态 */}
|
||||
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 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>
|
||||
<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>
|
||||
<a
|
||||
href="/"
|
||||
className="text-press transition-colors hover:text-press-dark sm:hidden"
|
||||
aria-label="返回前台"
|
||||
>
|
||||
前台
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-ink-500 transition-colors hover:text-press"
|
||||
title="退出登录"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 页面内容 */}
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
||||
import type { LLMAgentConfig } from './types'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from '../api/dal'
|
||||
import type { LLMAgentConfig } from '../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
type FieldKey = 'model' | 'base_url' | 'api_key'
|
||||
|
||||
@@ -110,9 +111,9 @@ export default function AgentLLMCard() {
|
||||
title="专家与终裁 LLM 配置"
|
||||
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
@@ -141,9 +142,9 @@ export default function AgentLLMCard() {
|
||||
</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">
|
||||
<Button size="sm" className="flex-shrink-0" onClick={() => toggleExpand(agent)} disabled={busy}>
|
||||
{expanded ? '收起' : '配置'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,9 +155,10 @@ export default function AgentLLMCard() {
|
||||
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>
|
||||
<label htmlFor={`llm-${f.key}`} className="text-xs text-ink-500">{f.label}</label>
|
||||
<div>
|
||||
<input
|
||||
<Input
|
||||
id={`llm-${f.key}`}
|
||||
type={f.sensitive ? 'password' : 'text'}
|
||||
value={form[f.key]}
|
||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
@@ -166,7 +168,7 @@ export default function AgentLLMCard() {
|
||||
: f.hint
|
||||
}
|
||||
autoComplete="off"
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,13 +181,13 @@ export default function AgentLLMCard() {
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{hasOverride(agent) && (
|
||||
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
||||
<Button size="sm" onClick={() => handleReset(agent)} disabled={busy}>
|
||||
恢复继承
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
||||
<Button variant="solid" size="sm" onClick={() => handleSave(agent)} disabled={busy}>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ApiError, login } from './api'
|
||||
import { ApiError, login } from '../api/api'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -50,12 +51,12 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
<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)]"
|
||||
className="w-full max-w-sm border border-ink-300 bg-paper-50 p-6 shadow-print"
|
||||
>
|
||||
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
|
||||
管理密码
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -63,22 +64,27 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="field mt-2 w-full"
|
||||
/* 错误时把输入框标记为无效并关联错误文本,
|
||||
屏幕阅读器才能知道「密码错了」以及错在哪 */
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? 'admin-password-error' : undefined}
|
||||
className="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">
|
||||
<p
|
||||
id="admin-password-error"
|
||||
/* role=alert 让错误出现时立即被朗读(aria-live 的 assertive 语义) */
|
||||
role="alert"
|
||||
className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || submitting}
|
||||
className="btn btn-solid mt-5 w-full justify-center"
|
||||
>
|
||||
<Button variant="solid" className="mt-5 w-full justify-center" type="submit" disabled={!password || submitting}>
|
||||
{submitting ? '验证中…' : '登 录'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DataSourceSetting } from './types'
|
||||
import type { DataSourceSetting } from '../api/types'
|
||||
import { Badge, Spinner } from './components'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
|
||||
db: { text: '数据库覆盖', status: 'success' },
|
||||
@@ -74,34 +75,32 @@ export default function SettingRow({
|
||||
{setting.sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
<Input
|
||||
type={setting.sensitive ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={`输入新的 ${setting.label}`}
|
||||
/* 无可见 label(标题即配置项名),用 aria-label 提供可访问名称 */
|
||||
aria-label={`设置项 ${setting.label} 的值`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSave(value)}
|
||||
disabled={!value.trim() || busy}
|
||||
className="btn btn-solid btn-sm"
|
||||
>
|
||||
<Button variant="solid" size="sm" onClick={() => onSave(value)} disabled={!value.trim() || busy}>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
|
||||
</Button>
|
||||
<Button size="sm" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
|
||||
<Button size="sm" onClick={handleDetect} disabled={detecting}>
|
||||
{detecting ? (<><Spinner /> 检测中</>) : detected ? '重新检测' : '检测可用模型'}
|
||||
</button>
|
||||
</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">
|
||||
@@ -146,13 +145,13 @@ export default function SettingRow({
|
||||
{setting.configured ? setting.masked : '—'}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
|
||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
||||
{setting.configured ? '更换' : '配置'}
|
||||
</button>
|
||||
</Button>
|
||||
{setting.origin === 'db' && (
|
||||
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
|
||||
<Button size="sm" onClick={onClear} disabled={busy} title="删除数据库覆盖值,回落 .env">
|
||||
回落 .env
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-128
@@ -1,130 +1,5 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端
|
||||
*
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/api.ts`。
|
||||
* 新代码请直接从 `../api/api` 导入。
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
||||
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit & { timeoutMs?: number; skipAuthHandling?: boolean } = {},
|
||||
): Promise<T> {
|
||||
// 修复: 正确拼接 API_BASE
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
: path.startsWith('/')
|
||||
? path // 已经是绝对路径(如 /health)
|
||||
: `${API_BASE}${path}`
|
||||
|
||||
const { timeoutMs = TIMEOUT_MS, skipAuthHandling, ...fetchOptions } = options
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let detail: unknown
|
||||
try {
|
||||
detail = await res.json()
|
||||
} catch {
|
||||
detail = await res.text()
|
||||
}
|
||||
let message =
|
||||
detail && typeof detail === 'object' && 'detail' in detail
|
||||
? String((detail as { detail: unknown }).detail)
|
||||
: `HTTP ${res.status}: ${res.statusText}`
|
||||
// 401 仅在没有显式跳过时广播未登录事件(改密接口的 401 表示当前密码错误,非会话过期)
|
||||
if (res.status === 401 && !skipAuthHandling) {
|
||||
message += '\n登录已过期,请重新登录。'
|
||||
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
||||
}
|
||||
throw new ApiError(message, res.status, detail)
|
||||
}
|
||||
|
||||
// 修复: 正确判断 204 No Content
|
||||
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 api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
|
||||
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number; skipAuthHandling?: boolean }) =>
|
||||
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// ── 认证 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码登录,成功后服务端写入 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 }
|
||||
export * from '../api/api'
|
||||
|
||||
@@ -1,387 +1,31 @@
|
||||
/**
|
||||
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||
* 【兼容壳】组件已合并到 `src/components/ui/`。
|
||||
*
|
||||
* 与前台共用同一套设计语言:
|
||||
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||
* 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。
|
||||
* 二者分裂导致 Spinner 出现三份逐字节相同的副本,且前台页面要跨目录
|
||||
* `import '../admin/components'` 才能拿到通用组件。
|
||||
*
|
||||
* 现已全部归入 `src/components/ui/`,本文件保留为再导出壳,
|
||||
* 使既有 import 路径继续可用。**新代码请直接从 `components/ui` 导入。**
|
||||
*
|
||||
* 本文件不含任何组件实现。
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
export { Card, CardHeader, CardBody } from '../components/ui/Card'
|
||||
export { Badge } from '../components/ui/Badge'
|
||||
export { StatCard, ProgressBar, AgentWeightsBar } from '../components/ui/Stat'
|
||||
export { SectionHeader } from '../components/ui/SectionHeader'
|
||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from '../components/ui/Feedback'
|
||||
export { DataTable, MobileCardList, ResponsiveTable } from '../components/ui/DataTable'
|
||||
export { Spinner } from '../components/ui'
|
||||
|
||||
import { ApiError } from './api'
|
||||
/**
|
||||
* 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。
|
||||
* 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`),
|
||||
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
|
||||
*/
|
||||
export { Skeleton as SkeletonBlock } from '../components/ui'
|
||||
|
||||
// ── 卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
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 }: { value: number }) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
|
||||
<div
|
||||
className="h-px 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,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<h2 className="section-head text-base">{title}</h2>
|
||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
export function Alert({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
}) {
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
||||
|
||||
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
||||
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}`} />
|
||||
}
|
||||
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
|
||||
export { EventList, FilterTable, InsightCard, InsightGrid, LoaderGrid, SearchList, TrendingArrow } from '../components/ui'
|
||||
export type { EventItem, FilterColumn, InsightCardProps } from '../components/ui'
|
||||
|
||||
+5
-338
@@ -1,341 +1,8 @@
|
||||
/**
|
||||
* Admin 后台 - 数据访问层
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/dal.ts`。
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
* 迁移原因见 `src/api/public.ts` 头注释:admin 目录不应承载被前台
|
||||
* 反向依赖的数据层。本文件仅为不破坏旧 import 路径而保留,
|
||||
* **新代码请直接从 `../api/dal`(admin 内)或 `../api`(其余位置)导入。*
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
MatchDetailOut,
|
||||
MatchContextOut,
|
||||
AdminStats,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从多个端点聚合仪表盘数据。
|
||||
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||
*
|
||||
* P2-8 修复: 使用 items.length 替代不存在的 total 字段,
|
||||
* 并扩大 limit 以获得更有参考价值的数量。
|
||||
*/
|
||||
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||
// 并行获取各端点数据
|
||||
// matches 返回 {items, next_cursor, has_more}, predictions 返回数组
|
||||
const [leagues, matches, predictions, health] = await Promise.allSettled([
|
||||
api.get<League[]>(`${API_BASE}/leagues`),
|
||||
api.get<{ items: Match[]; has_more: boolean }>(`${API_BASE}/matches?limit=100`),
|
||||
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
||||
api.get<{ status: string }>('/health'),
|
||||
])
|
||||
|
||||
return {
|
||||
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
||||
// P2-8: matches 无 total 字段,用 items.length 近似(上限 100)
|
||||
total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.items?.length ?? 0 : 0,
|
||||
// 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 sourceMap: Record<string, { path: string; body: any }> = {
|
||||
bzzoiro: {
|
||||
path: `${API_BASE}/ingest/bzzoiro`,
|
||||
body: {
|
||||
leagues: req.leagues,
|
||||
date_from: req.date_from,
|
||||
date_to: req.date_to,
|
||||
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
|
||||
},
|
||||
},
|
||||
understat: {
|
||||
path: `${API_BASE}/ingest/understat`,
|
||||
body: {
|
||||
league: req.league,
|
||||
season: req.season ? parseInt(req.season) : new Date().getFullYear(),
|
||||
},
|
||||
},
|
||||
injuries: {
|
||||
path: `${API_BASE}/ingest/injuries`,
|
||||
body: {
|
||||
date: req.date_from || new Date().toISOString().slice(0, 10),
|
||||
},
|
||||
},
|
||||
}
|
||||
const cfg = sourceMap[req.source]
|
||||
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
|
||||
return api.post(cfg.path, cfg.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 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> {
|
||||
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 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`)
|
||||
}
|
||||
export * from '../api/dal'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Admin 导航单源(D6)。
|
||||
*
|
||||
* 背景: 侧栏(NAV_SECTIONS)、命令面板(NAV_PAGES)、面包屑(ROUTE_LABELS)
|
||||
* 此前各维护一份平行清单,已出现漂移 —— data-pipeline 不在侧栏、
|
||||
* 「系统」vs「系统设置」命名不一致、monitoring/eval 两处顺序相反。
|
||||
*
|
||||
* 现在只允许维护 NAV_ITEMS 一份,其余视图一律由此派生;
|
||||
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
|
||||
*/
|
||||
|
||||
import type { AdminIconName } from './AdminIcon'
|
||||
|
||||
export interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
/** 命令面板中的分组名(展示原样) */
|
||||
group: string
|
||||
/**
|
||||
* 侧栏图标名。
|
||||
*
|
||||
* 使用 AdminIcon 的联合类型而非 `string`:图标名拼错时
|
||||
* 此前不会报任何错,只会静默渲染出一个空位,难以定位。
|
||||
*/
|
||||
icon: AdminIconName
|
||||
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
|
||||
hideFromSidebar?: boolean
|
||||
}
|
||||
|
||||
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。
|
||||
*
|
||||
* 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。
|
||||
* 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。
|
||||
*/
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
||||
// ── 数据:采集、核对、管线内部视图 ──
|
||||
{ to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' },
|
||||
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' },
|
||||
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true },
|
||||
// ── 预测:历史 → 回测 → 评估闭环 ──
|
||||
{ to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' },
|
||||
{ to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' },
|
||||
{ to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' },
|
||||
// ── 系统:活着吗、发生了什么、怎么调 ──
|
||||
{ to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' },
|
||||
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
||||
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
||||
]
|
||||
|
||||
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
||||
export const COMMAND_PALETTE_PAGES = NAV_ITEMS.map(({ to, label, group }) => ({ to, label, group }))
|
||||
|
||||
/** 路由 → 面包屑标签(原 ROUTE_LABELS 的唯一来源) */
|
||||
export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
NAV_ITEMS.map(i => [i.to, i.label]),
|
||||
)
|
||||
|
||||
/**
|
||||
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
||||
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
||||
*/
|
||||
export const NAV_SECTIONS = (() => {
|
||||
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
||||
const titles: string[] = []
|
||||
for (const i of sidebarItems) {
|
||||
if (!titles.includes(i.group)) titles.push(i.group)
|
||||
}
|
||||
return titles.map(title => ({
|
||||
title,
|
||||
items: sidebarItems
|
||||
.filter(i => i.group === title)
|
||||
.map(({ to, label, icon }) => ({ to, label, icon })),
|
||||
}))
|
||||
})()
|
||||
@@ -11,10 +11,11 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
interface BacktestResultRow {
|
||||
match_id: number
|
||||
@@ -38,8 +39,6 @@ interface BacktestResponse {
|
||||
results: BacktestResultRow[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
|
||||
function exportCsv(rows: BacktestResultRow[]) {
|
||||
const header = [
|
||||
@@ -61,7 +60,7 @@ function exportCsv(rows: BacktestResultRow[]) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `backtest_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -73,7 +72,7 @@ function csvCell(v: string): string {
|
||||
|
||||
function fmtDate(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
return s.slice(0, 10)
|
||||
return new Date(s).toLocaleDateString('sv-SE')
|
||||
}
|
||||
|
||||
export default function BacktestPage() {
|
||||
@@ -82,7 +81,7 @@ export default function BacktestPage() {
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
||||
const [mode] = useState<'multi'>('multi')
|
||||
const [model, setModel] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -144,10 +143,10 @@ export default function BacktestPage() {
|
||||
<form onSubmit={handleBacktest} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
<Select
|
||||
value={leagueId}
|
||||
onChange={e => setLeagueId(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
@@ -155,52 +154,50 @@ export default function BacktestPage() {
|
||||
{l.name_zh || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-from">起始日期</label>
|
||||
<Input id="bt-date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-to">结束日期</label>
|
||||
<Input id="bt-date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-limit">场数限制</label>
|
||||
<Input id="bt-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</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="single">单次调用 (快)</option>
|
||||
<option value="multi">多专家 (慢,贵)</option>
|
||||
</select>
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-mode">模式</label>
|
||||
<Input id="bt-mode"
|
||||
type="text"
|
||||
value="多专家 (5 路 + 终裁)"
|
||||
readOnly
|
||||
className="w-full bg-paper-100 text-ink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,20 +205,20 @@ export default function BacktestPage() {
|
||||
<label className="mb-1.5 block text-xs text-ink-500">
|
||||
指定模型(可选,空=默认 <code className="font-mono">gpt-4o</code>)
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-chat / 留空使用默认"
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -235,7 +232,7 @@ export default function BacktestPage() {
|
||||
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`}
|
||||
action={
|
||||
result?.results?.length
|
||||
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm">导出 CSV</button>)
|
||||
? (<Button size="sm" onClick={() => exportCsv(result.results)}>导出 CSV</Button>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -281,7 +278,7 @@ export default function BacktestPage() {
|
||||
</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} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性或改用单次模式。
|
||||
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -295,9 +292,9 @@ export default function BacktestPage() {
|
||||
title="模型评估"
|
||||
description="已结算预测的准确率统计"
|
||||
action={
|
||||
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={loadEval} disabled={evalLoading}>
|
||||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
|
||||
@@ -1,56 +1,66 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面(报刊风)
|
||||
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||
*
|
||||
* 三个采集任务:
|
||||
* events — 比赛日程与比分
|
||||
* standings — 联赛积分榜
|
||||
* stats — 已完赛比赛详细统计回填
|
||||
* all — 依次执行以上三项
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { triggerCollection, fetchLeagues } from '../dal'
|
||||
import type { CollectionRequest, League } from '../types'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
|
||||
import type { IngestJob, League } from '../../api/types'
|
||||
import type { CollectionRequest } from '../../api/types'
|
||||
import { AnimatedBadge } from '../../components/ui'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
const SOURCES = [
|
||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
||||
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
||||
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
||||
const TASKS = [
|
||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
|
||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
|
||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
|
||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
|
||||
] as const
|
||||
|
||||
/** 把采集接口返回摘要成一两行可读文字 */
|
||||
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
||||
if (res && typeof res === 'object') {
|
||||
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
||||
return {
|
||||
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
if ('count' in res || 'updated' in res) {
|
||||
const parts = [
|
||||
`新增 ${res.count ?? 0}`,
|
||||
`更新 ${res.updated ?? 0}`,
|
||||
`跳过 ${res.skipped ?? 0}`,
|
||||
`未匹配 ${res.unmatched ?? 0}`,
|
||||
]
|
||||
return {
|
||||
title: `采集完成:${parts.join(' / ')}`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
||||
}
|
||||
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 [source, setSource] = useState<string>('bzzoiro')
|
||||
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 [ingestStatus, setIngestStatus] = useState('')
|
||||
const [limit, setLimit] = useState(100)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ title: string; detail: 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()
|
||||
@@ -59,39 +69,137 @@ export default function CollectionPage() {
|
||||
|
||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
||||
|
||||
// 轮询采集 job 直到终态(success/failed)
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
// ── 已运行时长 ──
|
||||
// 必须放在 state 里、由定时器推进,而不是在 render 里读 Date.now()。
|
||||
// 后者是「渲染期间的副作用」:同一份 props/state 会渲染出不同结果,
|
||||
// React 的并发特性(以及未来的编译器优化)都依赖渲染幂等。
|
||||
// 采集任务在跑时每秒推进一次,既给出真实的时长,也不制造额外重渲染。
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskStartedAt) { setElapsedSec(0); return }
|
||||
if (taskStatus !== 'running') return
|
||||
const id = setInterval(() => {
|
||||
setElapsedSec(Math.round((Date.now() - taskStartedAt) / 1000))
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [taskStartedAt, taskStatus])
|
||||
|
||||
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
|
||||
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
|
||||
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||
const loadRecentJobs = useCallback(async () => {
|
||||
try {
|
||||
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
|
||||
} catch { /* 历史列表失败不影响主流程 */ }
|
||||
}, [])
|
||||
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
|
||||
|
||||
const startJobPolling = useCallback((id: string) => {
|
||||
stopPolling()
|
||||
const tick = async () => {
|
||||
try {
|
||||
const job = await fetchIngestJob(id)
|
||||
setJobInfo(job)
|
||||
if (TERMINAL_STATUSES.has(job.status)) {
|
||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||
stopPolling()
|
||||
loadRecentJobs() // 终态后刷新历史列表
|
||||
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
|
||||
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
|
||||
detail: { jobId: job.id, status: job.status },
|
||||
}))
|
||||
}
|
||||
} catch { /* 单次轮询失败不影响后续 */ }
|
||||
}
|
||||
tick()
|
||||
pollRef.current = setInterval(tick, 3_000)
|
||||
}, [stopPolling, loadRecentJobs])
|
||||
|
||||
const isEventsTask = task === 'events' || task === 'all'
|
||||
|
||||
// 友好汇总 job.result
|
||||
const jobSummary = (j: IngestJob | null): { title: string; detail: string } | null => {
|
||||
if (!j) return null
|
||||
if (j.status === 'failed') {
|
||||
return { title: '采集失败', detail: j.error || '采集任务异常终止,请到「系统日志」查看详细堆栈。' }
|
||||
}
|
||||
if (j.status !== 'success') return null
|
||||
const r = j.result as Record<string, unknown> | null
|
||||
if (!r) return { title: '采集完成', detail: '任务成功(无汇总数据)。' }
|
||||
const ev = r.events as Record<string, unknown> | undefined
|
||||
const evTotal = ev ? (ev.total_inserted as number ?? 0) + (ev.total_updated as number ?? 0) : 0
|
||||
const st = r.standings as Record<string, unknown> | undefined
|
||||
const stTotal = st ? (st.total_upserted as number ?? 0) : 0
|
||||
const stats = r.stats as Record<string, unknown> | undefined
|
||||
const statsTotal = stats ? (stats.created as number ?? 0) + (stats.updated as number ?? 0) : 0
|
||||
const evErr = (ev?.errors as string[] | undefined)?.length ?? 0
|
||||
const stErr = (st?.errors as string[] | undefined)?.length ?? 0
|
||||
const statsErr = (stats?.errors as string[] | undefined)?.length ?? 0
|
||||
const totalErr = evErr + stErr + statsErr
|
||||
const parts: string[] = []
|
||||
if (ev) parts.push(`比赛 +${evTotal}`)
|
||||
if (st) parts.push(`积分榜 +${stTotal}`)
|
||||
if (stats) parts.push(`统计 +${statsTotal}`)
|
||||
const detail = parts.length
|
||||
? `共更新: ${parts.join(' / ')}${totalErr ? `,错误 ${totalErr} 条(见日志)` : ''}`
|
||||
: '任务成功'
|
||||
return { title: '采集完成', detail }
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setJobInfo(null)
|
||||
setJobId(null)
|
||||
setLoading(true)
|
||||
setTaskStatus('running')
|
||||
setTaskStartedAt(Date.now())
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
source: source as CollectionRequest['source'],
|
||||
source: 'bzzoiro',
|
||||
leagues: leagueCode ? [leagueCode] : undefined,
|
||||
league: leagueCode || undefined,
|
||||
task: task as CollectionRequest['task'],
|
||||
limit,
|
||||
season: season || undefined,
|
||||
status: ingestStatus || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || 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)
|
||||
}
|
||||
await triggerCollection(body)
|
||||
setResult({
|
||||
title: '采集任务已启动',
|
||||
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setTaskStatus('error')
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
stopPolling()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = elapsedSec
|
||||
const summary = jobInfo ? jobSummary(jobInfo) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据采集"
|
||||
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
||||
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -100,131 +208,235 @@ export default function CollectionPage() {
|
||||
<CardHeader title="新建采集任务" />
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 数据源选择 */}
|
||||
{/* 任务类型 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
{SOURCES.map(s => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label} — {s.desc}
|
||||
</option>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">采集任务</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{TASKS.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTask(t.value)}
|
||||
className={`border px-3 py-2 text-left text-xs transition-colors ${
|
||||
task === t.value
|
||||
? 'border-press bg-press-wash text-press'
|
||||
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{t.icon}</span>
|
||||
<span className="font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联赛选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-league">联赛</label>
|
||||
<Select
|
||||
id="col-league"
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bzzoiro 专用: 比赛状态 */}
|
||||
{source === 'bzzoiro' && (
|
||||
<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>
|
||||
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
||||
{isEventsTask && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-status">比赛状态</label>
|
||||
<Select
|
||||
id="col-status"
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部(已完赛 + 未开赛)</option>
|
||||
<option value="finished">仅已完赛</option>
|
||||
<option value="scheduled">仅未开赛</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-from">起始日期</label>
|
||||
<Input id="col-date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-to">结束日期</label>
|
||||
<Input id="col-date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Understat 专用: 赛季 */}
|
||||
{source === 'understat' && (
|
||||
{/* standings 任务专用: 赛季 */}
|
||||
{(task === 'standings') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
||||
<input
|
||||
type="number"
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-season">赛季(留空取当前赛季)</label>
|
||||
<Input id="col-season"
|
||||
type="text"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="2025"
|
||||
className="field w-full"
|
||||
placeholder="如 2026-2027"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期范围 */}
|
||||
{source !== 'injuries' && (
|
||||
<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>
|
||||
{/* stats 任务专用: 回填数量 */}
|
||||
{(task === 'stats') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-ink-400">
|
||||
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息提示 */}
|
||||
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
||||
{result && (
|
||||
{summary && (
|
||||
<Alert
|
||||
kind="ok"
|
||||
title={result.title}
|
||||
message={result.detail || undefined}
|
||||
onClose={() => setResult(null)}
|
||||
kind={jobInfo?.status === 'success' ? 'ok' : 'error'}
|
||||
title={summary.title}
|
||||
message={summary.detail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据源说明 */}
|
||||
<Card>
|
||||
<CardHeader title="数据源说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
{SOURCES.map(s => (
|
||||
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge status="info">{s.label}</Badge>
|
||||
<p className="text-xs text-ink-600">{s.desc}</p>
|
||||
</div>
|
||||
{/* 任务状态 + 数据源说明 */}
|
||||
<div className="space-y-6">
|
||||
{/* 任务进度 */}
|
||||
<Card>
|
||||
<CardHeader title="任务状态" />
|
||||
<CardBody>
|
||||
{taskStatus === 'idle' && (
|
||||
<p className="text-xs text-ink-400">尚未触发任务。</p>
|
||||
)}
|
||||
{taskStatus === 'running' && (
|
||||
<div className="space-y-3">
|
||||
<AnimatedBadge status="loading" size="md">
|
||||
执行中{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
<p className="text-2xs text-ink-400">
|
||||
已运行 {elapsed}s · 后台异步执行,关闭页面不影响结果,每 3 秒自动轮询进度。
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'done' && (
|
||||
<div className="space-y-2">
|
||||
<AnimatedBadge status="success" size="md">
|
||||
采集完成{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'error' && (
|
||||
<div className="space-y-2">
|
||||
<AnimatedBadge status="danger" size="md">
|
||||
采集失败{jobId ? ` · job ${jobId.slice(0, 8)}…` : ''}
|
||||
</AnimatedBadge>
|
||||
{jobInfo?.error && (
|
||||
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{jobInfo?.created_at && (
|
||||
<p className="mt-2 text-2xs text-ink-400">
|
||||
创建于 {new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
{jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
采集接口需要管理员登录。
|
||||
遇到 401 表示登录已过期,请重新登录。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/* 最近任务历史 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="最近任务"
|
||||
description="后台采集任务执行历史(最新在前)"
|
||||
action={<Button size="sm" onClick={loadRecentJobs}>刷新</Button>}
|
||||
/>
|
||||
<CardBody className="px-0">
|
||||
{recentJobs === null ? (
|
||||
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">加载中…</p>
|
||||
) : recentJobs.length === 0 ? (
|
||||
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">还没有任务记录,触发一次采集后这里会出现历史。</p>
|
||||
) : (
|
||||
<div>
|
||||
{recentJobs.map(j => (
|
||||
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||
<Badge status={j.status === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
||||
<AnimatedBadge
|
||||
status={j.status === 'failed' ? 'danger' : j.status === 'success' ? 'success' : j.status === 'running' ? 'loading' : 'neutral'}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
|
||||
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据源说明 */}
|
||||
<Card>
|
||||
<CardHeader title="采集任务说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
{TASKS.map(t => (
|
||||
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{t.icon}</span>
|
||||
<Badge status="info">{t.label}</Badge>
|
||||
<p className="text-xs text-ink-600">{t.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
采集接口需要管理员登录(401 表示登录已过期)。
|
||||
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
||||
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +1,213 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
||||
*
|
||||
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
||||
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
||||
* 而不是扫描一堆常驻数字。
|
||||
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
||||
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
||||
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
||||
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchDashboard, fetchHealth } from '../dal'
|
||||
import type { DashboardStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
|
||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
|
||||
import { InsightGrid, InsightCard, LoaderGrid } from '../components'
|
||||
|
||||
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||
const STEPS = [
|
||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
|
||||
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
|
||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
|
||||
]
|
||||
|
||||
interface TodoItem {
|
||||
key: string
|
||||
count: number
|
||||
label: string
|
||||
to: string
|
||||
/** 无上限确认时数字近似,展示为 N+ */
|
||||
approx?: boolean
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
||||
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
Promise.all([fetchDashboard(), fetchHealth()])
|
||||
.then(([stats, h]) => {
|
||||
if (active) {
|
||||
setData(stats)
|
||||
setHealth(h)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => { active = false }
|
||||
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
||||
const [s, i, d, f, c, p] = await Promise.allSettled([
|
||||
fetchAdminStats(),
|
||||
fetchIngestStatus(),
|
||||
fetchDashboard(),
|
||||
fetchIngestFailures(),
|
||||
fetchDataCompleteness(),
|
||||
fetchPredictions(100),
|
||||
])
|
||||
if (s.status === 'fulfilled') setStats(s.value)
|
||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||
if (d.status === 'fulfilled') setDash(d.value)
|
||||
if (f.status === 'fulfilled') setFailures(f.value)
|
||||
if (c.status === 'fulfilled') setCompleteness(c.value)
|
||||
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert kind="error" title="加载仪表盘失败" message={error} />
|
||||
<button onClick={() => window.location.reload()} className="btn btn-sm">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const healthText =
|
||||
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
||||
// ── 待办计算 ──
|
||||
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
||||
const missingStatsLeagues = completeness?.leagues.filter(
|
||||
l => l.matches.finished > 0 && l.stats.rows === 0,
|
||||
).length ?? 0
|
||||
const missingStandingsLeagues = completeness?.leagues.filter(
|
||||
l => l.matches.total > 0 && l.standings.rows === 0,
|
||||
).length ?? 0
|
||||
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
|
||||
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
|
||||
const settleable = recentPreds.filter(
|
||||
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
|
||||
).length
|
||||
|
||||
const todos: TodoItem[] = [
|
||||
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
|
||||
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
|
||||
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
|
||||
].filter((t): t is TodoItem => t !== false)
|
||||
|
||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||
const bzzoiro = sourceByName['bzzoiro']
|
||||
const hasMatches = (stats?.matches?.total ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计:报纸数字版式 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="健康状态"
|
||||
value={loading ? '—' : healthText}
|
||||
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
||||
/>
|
||||
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
||||
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
||||
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
||||
{/* ── 待办行:只有真有待办才出现 ── */}
|
||||
{loading ? (
|
||||
<div className="h-14 w-full animate-pulse bg-paper-100" role="presentation" />
|
||||
) : todos.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{todos.map(t => (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.to}
|
||||
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||
>
|
||||
<span className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||
{t.count}{t.approx ? '+' : ''}
|
||||
</span>
|
||||
<span className="text-xs text-ink-700">{t.label}</span>
|
||||
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
||||
流水线无待办:没有失败记录、数据缺口或待结算预测。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
|
||||
{!loading && !hasMatches && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map(s => (
|
||||
<Link
|
||||
key={s.to}
|
||||
to={s.to}
|
||||
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
||||
{s.step}
|
||||
</span>
|
||||
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
||||
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||
</div>
|
||||
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
||||
<div className="border border-ink-200 bg-paper-50">
|
||||
<div className="flex items-center justify-between border-b border-ink-200 px-4 py-2 sm:px-5">
|
||||
<h2 className="text-sm font-medium text-ink-700">数据源</h2>
|
||||
<span className="text-xs text-ink-400">bzzoiro</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 sm:px-5">
|
||||
{loading ? (
|
||||
<LoaderGrid mode="row" count={1} cols={2} />
|
||||
) : (() => {
|
||||
const st = bzzoiro
|
||||
const hasData = st && st.recent_count > 0
|
||||
const keyOk = st?.key_configured !== false
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink-700">Bzzoiro</span>
|
||||
<span className="flex items-center gap-3 text-2xs">
|
||||
{hasData ? (
|
||||
<>
|
||||
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
||||
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
||||
</>
|
||||
) : keyOk ? (
|
||||
<span className="text-ink-400">无数据</span>
|
||||
) : (
|
||||
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
||||
)}
|
||||
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联赛列表 */}
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
{/* ── 数据概览:InsightCard 指标卡 ── */}
|
||||
{stats ? (
|
||||
<div className="space-y-4">
|
||||
<InsightGrid>
|
||||
<InsightCard label="预测 · 近 24 小时" value={stats.predictions.last_24h} status="info" />
|
||||
<InsightCard label="预测 · 近 7 天" value={stats.predictions.last_7d} status="info" />
|
||||
<InsightCard label="预测 · 累计" value={stats.predictions.total} status="positive" />
|
||||
</InsightGrid>
|
||||
<InsightGrid>
|
||||
<InsightCard label="比赛总数" value={stats.matches?.total ?? 0} subtitle={`已完赛 ${stats.matches?.finished ?? 0}`} status="info" />
|
||||
<InsightCard label="统计行" value={stats.stats?.total ?? 0} status="info" />
|
||||
<InsightCard label="积分榜" value={stats.standings?.total ?? 0} status="info" />
|
||||
</InsightGrid>
|
||||
</div>
|
||||
) : (
|
||||
<LoaderGrid mode="card" count={6} />
|
||||
)}
|
||||
|
||||
{/* ── 已入库联赛 ── */}
|
||||
{dash && dash.leagues.length > 0 && (
|
||||
<div className="border border-ink-200 bg-paper-50">
|
||||
<div className="border-b border-ink-200 px-4 py-2 sm:px-5">
|
||||
<h2 className="text-sm font-medium text-ink-700">已入库联赛</h2>
|
||||
</div>
|
||||
<div className="px-4 py-3 sm:px-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
||||
))}
|
||||
</div>
|
||||
) : data && data.leagues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.leagues.map(l => (
|
||||
<span
|
||||
key={l.code}
|
||||
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<p className="text-xs text-ink-500">
|
||||
暂无联赛数据,请先到「数据采集」导入比赛数据。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<Card>
|
||||
<CardHeader title="快捷操作" />
|
||||
<CardBody>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/admin/collection"
|
||||
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">从数据源获取最新赛程与比分</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/predictions"
|
||||
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">调 LLM 生成比赛预测</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/backtest"
|
||||
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">在历史数据上检验准确率</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Admin 后台 - 数据完整性分析页
|
||||
*
|
||||
* 回答三个问题:
|
||||
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
|
||||
* 2. 字段是否齐全(每张统计表各字段非空率)
|
||||
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchDataCompleteness } from '../../api/dal'
|
||||
import type { DataCompletenessResponse } from '../../api/dal'
|
||||
import {
|
||||
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||
ProgressBar, Spinner,
|
||||
} from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
xg: 'xG 预期进球',
|
||||
shots: '射门',
|
||||
possession: '控球率',
|
||||
corners: '角球',
|
||||
fouls: '犯规',
|
||||
big_chances: '绝佳机会',
|
||||
cards: '红黄牌',
|
||||
}
|
||||
|
||||
function pctColor(pct: number): string {
|
||||
if (pct >= 80) return 'bg-ok-500'
|
||||
if (pct >= 50) return 'bg-warn-500'
|
||||
return 'bg-bad-500'
|
||||
}
|
||||
|
||||
/** 根据问题描述生成可操作的修复链接 */
|
||||
function getIssueAction(issue: string): { href: string; label: string; code: string } | null {
|
||||
// 提取联赛代码(大写字母+数字,如 E0, SP1)
|
||||
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
|
||||
const code = codeMatch ? codeMatch[1] : ''
|
||||
if (!code) return null
|
||||
|
||||
if (issue.includes('无已完赛比赛')) {
|
||||
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集', code }
|
||||
}
|
||||
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
|
||||
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填', code }
|
||||
}
|
||||
if (issue.includes('无积分榜')) {
|
||||
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集', code }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function DataCompletenessPage() {
|
||||
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
|
||||
// 页面文案承诺「每 5 秒自动刷新」,此前并未实现(仅挂载加载一次),
|
||||
// 采集完成后数字不动 —— 现补齐:5s 轮询 + 采集完成事件即时刷新。
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const d = await fetchDataCompleteness()
|
||||
setData(d)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 5s 自动轮询(与页面文案一致);已有数据时刷新不闪骨架
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const t = setInterval(load, 5_000)
|
||||
return () => clearInterval(t)
|
||||
}, [autoRefresh, load])
|
||||
|
||||
// 采集页任务终态广播 → 立即刷新(不等下一个 5s 周期)
|
||||
useEffect(() => {
|
||||
const onIngestDone = () => load()
|
||||
window.addEventListener('profeto:ingest-done', onIngestDone)
|
||||
return () => window.removeEventListener('profeto:ingest-done', onIngestDone)
|
||||
}, [load])
|
||||
|
||||
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
||||
const scrollToLeague = useCallback((code: string) => {
|
||||
setHighlightedLeague(code)
|
||||
// 使用 requestAnimationFrame 确保 DOM 已更新
|
||||
requestAnimationFrame(() => {
|
||||
const el = leagueRefs.current[code]
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
})
|
||||
// 5秒后移除高亮
|
||||
setTimeout(() => setHighlightedLeague(null), 5000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据完整性"
|
||||
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
5s 自动刷新
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{loading && !data && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* 全局概览 */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
|
||||
<p className="text-xs text-ink-500">已完赛比赛(总计)</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
|
||||
<p className="text-xs text-ink-500">统计行数</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">
|
||||
{data.totals.stats_coverage_pct}%
|
||||
</p>
|
||||
<p className="text-xs text-ink-500">统计覆盖率(有统计 / 已完赛)</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 健康问题(可操作) */}
|
||||
<Card>
|
||||
<CardHeader title="健康摘要" />
|
||||
<CardBody>
|
||||
<div className="space-y-2">
|
||||
{data.issues.map((issue, i) => {
|
||||
const action = getIssueAction(issue)
|
||||
return (
|
||||
<Alert
|
||||
key={i}
|
||||
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
||||
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
||||
message={issue}
|
||||
action={action ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="whitespace-nowrap" onClick={() => scrollToLeague(action.code)}>
|
||||
定位
|
||||
</Button>
|
||||
<Button href={action.href} size="sm" className="whitespace-nowrap">
|
||||
{action.label}
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 各联赛详情 */}
|
||||
<div className="space-y-4">
|
||||
{data.leagues.map(league => {
|
||||
const finished = league.matches.finished
|
||||
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
|
||||
const isHighlighted = highlightedLeague === league.code
|
||||
return (
|
||||
<div
|
||||
key={league.code}
|
||||
ref={el => { leagueRefs.current[league.code] = el }}
|
||||
className={`transition-all duration-500 ${isHighlighted ? 'ring-2 ring-press bg-press-wash/40 scale-[1.01]' : ''}`}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={league.name}
|
||||
description={[
|
||||
league.country,
|
||||
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled} 场`,
|
||||
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
|
||||
league.standings.rows > 0 ? `积分榜 ${league.standings.rows} 队` : '',
|
||||
].filter(Boolean).join(' · ')}
|
||||
/>
|
||||
<CardBody className="space-y-4">
|
||||
{/* 统计覆盖率进度条 */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-ink-600">统计回填覆盖率</span>
|
||||
<span className="font-medium text-ink-900">
|
||||
{league.stats.rows} / {finished} ({statsPct}%)
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={statsPct} />
|
||||
</div>
|
||||
|
||||
{/* 字段覆盖率矩阵 */}
|
||||
{league.stats.rows > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-ink-600">字段覆盖率(有值行数 / 总行数)</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Object.entries(league.stats.fields).map(([key, info]) => (
|
||||
<div key={key} className="rounded border border-ink-100 px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
|
||||
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${pctColor(info.pct)}`}
|
||||
style={{ width: `${info.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} 行</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : finished > 0 ? (
|
||||
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
|
||||
) : (
|
||||
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-2xs text-ink-400">
|
||||
生成时间: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Admin 后台 - 数据管线管理页(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 采集失败记录列表(可重试)
|
||||
* - 数据质量检查结果
|
||||
* - 手动触发质量检查
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataQuality,
|
||||
runDataQualityCheck,
|
||||
fetchIngestFailures,
|
||||
retryIngestFailure,
|
||||
} from '../../api/dal'
|
||||
import type { IngestFailureItem, DataQualityCheckItem } from '../../api/dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
export default function DataPipelinePage() {
|
||||
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
|
||||
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
|
||||
setQuality(q)
|
||||
setFailures(f)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleRunCheck = async () => {
|
||||
setRunning(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const res = await runDataQualityCheck()
|
||||
const failed = res.checks.filter(c => !c.passed)
|
||||
setNotice({
|
||||
ok: failed.length === 0,
|
||||
text: failed.length === 0
|
||||
? '数据质量检查通过'
|
||||
: `检查完成: ${failed.length} 项未通过`,
|
||||
})
|
||||
await load()
|
||||
} catch {
|
||||
setNotice({ ok: false, text: '质量检查执行失败' })
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetry = async (id: number) => {
|
||||
setNotice(null)
|
||||
try {
|
||||
const res = await retryIngestFailure(id)
|
||||
setNotice({ ok: true, text: res.message })
|
||||
await load()
|
||||
} catch {
|
||||
setNotice({ ok: false, text: '重试操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据管线"
|
||||
description="采集失败重试、数据质量检查与监控"
|
||||
action={
|
||||
<Button size="sm" onClick={handleRunCheck} disabled={running}>
|
||||
{running ? <><Spinner /> 检查中</> : '运行质量检查'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{notice && (
|
||||
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
|
||||
)}
|
||||
|
||||
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* 采集失败记录 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集失败记录"
|
||||
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{failures.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无采集失败记录</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">来源</th>
|
||||
<th className="px-4 py-2 font-medium">实体类型</th>
|
||||
<th className="px-4 py-2 font-medium">错误类型</th>
|
||||
<th className="px-4 py-2 font-medium">重试次数</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failures.map(f => (
|
||||
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
|
||||
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
|
||||
{f.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{(f.status === 'pending' || f.status === 'retrying') && (
|
||||
<Button size="sm" onClick={() => handleRetry(f.id)}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据质量检查 */}
|
||||
<Card>
|
||||
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
{quality?.checks.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-ink-400">暂无质量检查记录,点击右上角「运行质量检查」触发</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">检查项</th>
|
||||
<th className="px-4 py-2 font-medium">实体</th>
|
||||
<th className="px-4 py-2 font-medium">结果</th>
|
||||
<th className="px-4 py-2 font-medium">严重度</th>
|
||||
<th className="px-4 py-2 font-medium">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quality?.checks.map(c => (
|
||||
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
|
||||
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={c.passed ? 'success' : 'error'}>
|
||||
{c.passed ? '通过' : '未通过'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
|
||||
{c.severity}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-2xs text-ink-400">
|
||||
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
fetchDataSourceStatuses,
|
||||
fetchIngestStatus,
|
||||
fetchAdminStats,
|
||||
updateSetting,
|
||||
clearSetting,
|
||||
testDataSourceConnection,
|
||||
} from '../dal'
|
||||
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
||||
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 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 */
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSources()
|
||||
loadIngest()
|
||||
loadStats()
|
||||
}, [loadSources, loadIngest, loadStats])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 近期活动统计(只读) */}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,10 @@
|
||||
* - 空态与加载态
|
||||
*/
|
||||
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, EmptyState } from '../components'
|
||||
import { fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
||||
import type { EvalSummary, EvalSummaryRow, EvalCalibrationBucket } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, StatCard, DataTable, Alert, Spinner, EmptyText } from '../components'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
interface Filters {
|
||||
provider: string
|
||||
@@ -73,67 +74,67 @@ export default function EvalPage() {
|
||||
<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
|
||||
<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"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">模型</span>
|
||||
<input
|
||||
<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"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">Prompt 版本</span>
|
||||
<input
|
||||
<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"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">模式</span>
|
||||
<select
|
||||
<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"
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="single">single</option>
|
||||
<option value="multi">multi</option>
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">联赛</span>
|
||||
<select
|
||||
<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"
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" disabled={loading} onClick={load}>
|
||||
{loading ? '加载中…' : '应用筛选'}
|
||||
</button>
|
||||
<button onClick={handleReset} disabled={loading} className="btn btn-sm btn-ghost">
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={loading} onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -155,7 +156,7 @@ export default function EvalPage() {
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="准确率对比"
|
||||
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
|
||||
description={!loading && summary.length > 0 ? `共 ${summary.length} 组` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
@@ -163,29 +164,30 @@ export default function EvalPage() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : summary.length === 0 ? (
|
||||
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
) : (
|
||||
<DataTable
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable<EvalSummaryRow>
|
||||
columns={[
|
||||
{ key: 'provider', label: '提供商' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'prompt_version', label: '版本', render: (row: any) => (
|
||||
{ key: 'prompt_version', label: '版本', render: (row: EvalSummaryRow) => (
|
||||
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
|
||||
) },
|
||||
{ key: 'total', label: '评估条数' },
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => (
|
||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: EvalSummaryRow) => (
|
||||
row.calibration ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, EvalCalibrationBucket]) => (
|
||||
<span key={name} className="inline-flex items-center gap-1">
|
||||
<span className="text-ink-400">{name}:</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
@@ -199,9 +201,10 @@ export default function EvalPage() {
|
||||
) },
|
||||
]}
|
||||
data={summary}
|
||||
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
rowKey={(row: EvalSummaryRow) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
emptyText="暂无评估数据"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
import { fetchLogs } from '../../api/dal'
|
||||
import type { LogEntry } from '../../api/types'
|
||||
import { Badge, SectionHeader, Alert, Card, CardHeader, CardBody, Spinner, FilterTable, type FilterColumn, LoaderGrid } from '../components'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
|
||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
||||
|
||||
@@ -22,6 +23,52 @@ const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'er
|
||||
CRITICAL: { status: 'error', text: 'FATAL' },
|
||||
}
|
||||
|
||||
const LOG_COLUMNS: FilterColumn<LogEntry>[] = [
|
||||
{
|
||||
key: 'ts',
|
||||
label: '时间',
|
||||
width: '160px',
|
||||
sortable: true,
|
||||
render: r => <span className="tabular-nums text-ink-400">{fmtTs(r.ts)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: '级别',
|
||||
width: '80px',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: 'select',
|
||||
filterOptions: [
|
||||
{ value: 'DEBUG', label: 'DEBUG' },
|
||||
{ value: 'INFO', label: 'INFO' },
|
||||
{ value: 'WARNING', label: 'WARN' },
|
||||
{ value: 'ERROR', label: 'ERROR' },
|
||||
],
|
||||
render: r => {
|
||||
const badge = LEVEL_BADGE[r.level] ?? { status: 'info' as const, text: r.level }
|
||||
return <Badge status={badge.status}>{badge.text}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'logger',
|
||||
label: 'Logger',
|
||||
width: '160px',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: r => (
|
||||
<span className="truncate font-mono text-ink-400" title={r.logger}>
|
||||
{r.logger}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
label: '消息',
|
||||
filterable: true,
|
||||
render: r => <span className="break-all font-mono leading-relaxed text-ink-800">{r.message}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
function fmtTs(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
@@ -52,7 +99,7 @@ export default function LogsPage() {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// 自动刷新
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
if (autoRefresh) {
|
||||
@@ -67,7 +114,7 @@ export default function LogsPage() {
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统日志"
|
||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
|
||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
||||
/>
|
||||
|
||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
||||
@@ -87,9 +134,9 @@ export default function LogsPage() {
|
||||
/>
|
||||
10s 自动刷新
|
||||
</label>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -98,53 +145,37 @@ export default function LogsPage() {
|
||||
<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
|
||||
<Button
|
||||
key={lv || 'all'}
|
||||
onClick={() => setLevel(lv)}
|
||||
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
|
||||
variant={level === lv ? 'solid' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{lv || '全部'}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键字(消息 / logger)…"
|
||||
className="field w-full sm:w-64"
|
||||
aria-label="搜索日志关键字"
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{/* 日志表格:FilterTable(列排序 + 筛选) */}
|
||||
{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>
|
||||
<LoaderGrid mode="row" count={6} cols={4} />
|
||||
) : entries.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
||||
) : (
|
||||
<div>
|
||||
{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>
|
||||
<FilterTable
|
||||
columns={LOG_COLUMNS}
|
||||
data={entries}
|
||||
rowKey={e => `${e.ts}-${e.logger}-${e.message.slice(0, 20)}`}
|
||||
emptyText="暂无匹配的日志"
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
* Admin 后台 - 监控页(报刊风·三分区)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
* 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
|
||||
* 现在聚合全站已有信号,回答三个问题:
|
||||
* 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
|
||||
* 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
|
||||
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
|
||||
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
|
||||
* 30s 自动巡检 + 手动巡检。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { api } from '../api'
|
||||
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../../api/dal'
|
||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
||||
import type { IngestJob, LLMUsageStats, HealthProbe } from '../../api/types'
|
||||
import { api } from '../../api/api'
|
||||
import { Alert, SectionHeader, Spinner } from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
type UpstreamProbe = { ok: boolean; status_code?: number; latency_ms: number; endpoint: string; error?: string }
|
||||
// 直接引用 DAL 的权威类型,不要再本地手写一份 ——
|
||||
// 本地别名会把 `avg_latency_ms: number | null` 悄悄收窄回 `number`,
|
||||
// 使「未接入」在页面上重新退化成假数字。
|
||||
type LLMStats = LLMUsageStats
|
||||
|
||||
interface TodoItem {
|
||||
key: string
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
function MetricCard({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">{label}</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [health, setHealth] = useState<HealthProbe | null>(null)
|
||||
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
|
||||
const [upstream, setUpstream] = useState<UpstreamProbe | null>(null)
|
||||
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
||||
const [llmStats, setLlmStats] = useState<LLMStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastCheck, setLastCheck] = useState<string>('')
|
||||
@@ -22,20 +55,28 @@ export default function MonitoringPage() {
|
||||
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)
|
||||
// 各信号独立容错:单项失败只降级对应卡片
|
||||
const [h, r, u, j, f, c, l] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
fetchUpstreamProbe(),
|
||||
fetchIngestJobs({ limit: 10 }),
|
||||
fetchIngestFailures(),
|
||||
fetchDataCompleteness(),
|
||||
fetchLLMUsageStats() as Promise<LLMStats>,
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
setUpstream(u.status === 'fulfilled' ? u.value : null)
|
||||
setRecentJobs(j.status === 'fulfilled' && Array.isArray(j.value) ? j.value : null)
|
||||
setFailures(f.status === 'fulfilled' ? f.value : [])
|
||||
setCompleteness(c.status === 'fulfilled' ? c.value : null)
|
||||
setLlmStats(l.status === 'fulfilled' ? l.value : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,108 +86,197 @@ export default function MonitoringPage() {
|
||||
}, [refresh])
|
||||
|
||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
||||
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
||||
const missingStatsLeagues = completeness?.leagues.filter(
|
||||
l => l.matches.finished > 0 && l.stats.rows === 0,
|
||||
).length ?? 0
|
||||
const recentFailedJobs = useMemo(
|
||||
() => (recentJobs ?? []).filter(j => j.status === 'failed').length,
|
||||
[recentJobs],
|
||||
)
|
||||
|
||||
// ── 「需要关注」聚合:任何一项异常即亮红 ──
|
||||
//
|
||||
// 过滤条件必须是「真值」而非 `t !== false`。因为这些短路表达式在条件
|
||||
// 不成立时返回的**不只是 false**:`upstream && !upstream.ok && {...}`
|
||||
// 在 upstream 为 null 时整条求值为 null,而 `null !== false` 为 true,
|
||||
// 于是 null 会穿过过滤器,渲染时 `t.to` 直接抛错并让整页崩进 ErrorBoundary。
|
||||
const todos = (
|
||||
[
|
||||
!alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' },
|
||||
ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' },
|
||||
upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' },
|
||||
recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs} 次`, to: '/admin/collection' },
|
||||
deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount} 条`, to: '/admin/data-pipeline' },
|
||||
missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' },
|
||||
] as Array<TodoItem | false | null | undefined>
|
||||
).filter((t): t is TodoItem => Boolean(t))
|
||||
|
||||
const okJobs = (recentJobs ?? []).filter(j => j.status === 'success').length
|
||||
const runJobs = (recentJobs ?? []).filter(j => j.status === 'success' || j.status === 'failed').length
|
||||
const lastJobTime = recentJobs?.[0]?.created_at
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
|
||||
<Button size="sm" onClick={refresh} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
<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'}`}
|
||||
{/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
|
||||
{!loading && todos.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{todos.map(t => (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.to}
|
||||
className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||
>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-press" aria-hidden="true" />
|
||||
<span className="text-xs text-ink-800">{t.label}</span>
|
||||
<span className="ml-auto text-press" aria-hidden="true">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
||||
各项巡检正常:服务、数据库、上游、采集与数据完整性均无异常。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 服务名 */}
|
||||
<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}
|
||||
{/* ── 基础设施 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">基础设施</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="存活状态">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||
{health ? (alive ? '正常' : health.status) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 运行时间 */}
|
||||
{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
|
||||
<MetricCard label="数据库就绪">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 检查项 */}
|
||||
{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>
|
||||
<MetricCard label="版本 / 运行时间">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health?.version ?? '—'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
{health?.uptime_seconds != null
|
||||
? `已运行 ${Math.floor(health.uptime_seconds / 3600)}h ${Math.floor((health.uptime_seconds % 3600) / 60)}m`
|
||||
: '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="上游 bzzoiro">
|
||||
{upstream ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${upstream.ok ? 'text-ink-900' : 'text-press'}`}>
|
||||
{upstream.ok ? '可达' : '不可达'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
{upstream.ok
|
||||
? `HTTP ${upstream.status_code} · ${upstream.latency_ms}ms`
|
||||
: upstream.error?.slice(0, 40) || `HTTP ${upstream.status_code}`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-serif text-xl text-ink-300">—</span>
|
||||
)}
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 数据管线 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据管线</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="最近采集">
|
||||
{recentJobs && runJobs > 0 ? (
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500">成功</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-serif text-xl text-ink-300">—</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-2xs text-ink-400">
|
||||
{lastJobTime ? `最近 ${new Date(lastJobTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}` : '暂无记录'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="死信待处理">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
|
||||
{deadLetterCount}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">失败记录,可重试</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="数据缺口">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${missingStatsLeagues > 0 ? 'text-press' : 'text-ink-900'}`}>
|
||||
{missingStatsLeagues}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">联赛有完赛缺统计</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="统计覆盖">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{completeness ? `${completeness.totals.stats_coverage_pct}%` : '—'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">有统计 / 已完赛</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── LLM ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">LLM 服务</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<MetricCard label="预测总数">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats ? llmStats.total_predictions : '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
<MetricCard label={llmStats?.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats?.avg_latency_ms != null
|
||||
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
|
||||
: '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
<MetricCard label="有效率">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${llmStats && llmStats.success_rate < 80 ? 'text-warn-700' : 'text-ink-900'}`}>
|
||||
{llmStats ? `${llmStats.success_rate.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Admin 后台 - 预测历史页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 查看预测记录列表(支持状态筛选)
|
||||
* - 实际比分自动从比赛赛果填充
|
||||
* - 赛后一键结算,供准确率评估
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchPredictions, settlePrediction } from '../../api/dal'
|
||||
import type { Prediction } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
function fmtDate(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
const d = new Date(s)
|
||||
return isNaN(d.getTime()) ? s : `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
export default function PredictionHistoryPage() {
|
||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'settled' | 'unsettled'>('all')
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [settlingId, setSettlingId] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await fetchPredictions(200)
|
||||
setPredictions(list)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleSettle = async (p: Prediction) => {
|
||||
const match = p.match
|
||||
if (!match || match.home_goals == null || match.away_goals == null) return
|
||||
setSettlingId(p.id)
|
||||
try {
|
||||
await settlePrediction(p.id, match.home_goals, match.away_goals)
|
||||
await load()
|
||||
} catch (err) {
|
||||
console.error('结算失败:', err)
|
||||
} finally {
|
||||
setSettlingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = predictions.filter(p => {
|
||||
if (filter === 'settled') return p.settled
|
||||
if (filter === 'unsettled') return !p.settled
|
||||
return true
|
||||
})
|
||||
|
||||
const unsettledCount = predictions.filter(p => !p.settled).length
|
||||
const settledWithScore = predictions.filter(p => p.settled && p.actual_home_goals != null && p.actual_away_goals != null)
|
||||
const hitCount = settledWithScore.filter(p => {
|
||||
const actual = p.actual_home_goals! > p.actual_away_goals! ? '1' : p.actual_home_goals! < p.actual_away_goals! ? '2' : 'X'
|
||||
return actual === p.pred_1x2
|
||||
}).length
|
||||
const accuracy = settledWithScore.length > 0 ? Math.round(hitCount / settledWithScore.length * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="预测历史"
|
||||
description={`共 ${predictions.length} 条记录${unsettledCount > 0 ? `, ${unsettledCount} 条待结算` : ''}`}
|
||||
/>
|
||||
|
||||
{/* 统计概览 */}
|
||||
{settledWithScore.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ink-900">{settledWithScore.length}</p>
|
||||
<p className="text-xs text-ink-500">已结算</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
|
||||
<p className="text-xs text-ink-500">命中</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardBody className="text-center">
|
||||
<p className="text-2xl font-bold text-press">{accuracy}%</p>
|
||||
<p className="text-xs text-ink-500">1X2 准确率</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ v: 'all', label: '全部' },
|
||||
{ v: 'unsettled', label: '待结算' },
|
||||
{ v: 'settled', label: '已结算' },
|
||||
] as const).map(opt => (
|
||||
<Button
|
||||
key={opt.v}
|
||||
onClick={() => setFilter(opt.v)}
|
||||
variant={filter === opt.v ? 'solid' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p className="empty-state-title">暂无预测记录</p>
|
||||
<p className="empty-state-sub">在主站比赛列表点击「预测」按钮生成预测记录</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预测列表 */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-500">
|
||||
<th className="px-4 py-2 font-medium">比赛日</th>
|
||||
<th className="px-4 py-2 font-medium">比赛</th>
|
||||
<th className="px-4 py-2 font-medium">预测</th>
|
||||
<th className="px-4 py-2 font-medium">实际</th>
|
||||
<th className="px-4 py-2 font-medium">1X2</th>
|
||||
<th className="px-4 py-2 font-medium">状态</th>
|
||||
<th className="px-4 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => {
|
||||
const match = p.match
|
||||
const matchDate = match?.match_date
|
||||
const homeName = match?.home_team_zh || match?.home_team || '?'
|
||||
const awayName = match?.away_team_zh || match?.away_team || '?'
|
||||
const actualHome = match?.home_goals
|
||||
const actualAway = match?.away_goals
|
||||
const hasActual = actualHome != null && actualAway != null
|
||||
const isExpanded = expandedId === p.id
|
||||
const predHit = p.settled && hasActual
|
||||
? (actualHome! > actualAway! ? '1' : actualHome! < actualAway! ? '2' : 'X') === p.pred_1x2
|
||||
: null
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="px-4 py-3 text-2xs text-ink-400">{fmtDate(matchDate)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-xs text-ink-800">
|
||||
<TeamSideTag side="home" />
|
||||
<span className="truncate max-w-[100px]">{homeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-ink-600 mt-0.5">
|
||||
<TeamSideTag side="away" />
|
||||
<span className="truncate max-w-[100px]">{awayName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-serif font-bold text-ink-900">
|
||||
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{hasActual ? (
|
||||
<span className={`font-serif font-bold ${p.settled ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
{actualHome} : {actualAway}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xs text-ink-300">暂无赛果</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.pred_1x2 ? (
|
||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
||||
predHit === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
|
||||
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||
'border-ink-200 text-ink-600'
|
||||
}`}>
|
||||
{OUTCOME_LABEL[p.pred_1x2] ?? p.pred_1x2}
|
||||
{predHit === true && ' ✓'}
|
||||
{predHit === false && ' ✗'}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.settled ? (
|
||||
<Badge status="success">已结算</Badge>
|
||||
) : hasActual ? (
|
||||
<Badge status="info">可结算</Badge>
|
||||
) : (
|
||||
<Badge status="warning">待赛果</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{!p.settled && hasActual && (
|
||||
<Button variant="solid" size="sm" onClick={() => handleSettle(p)} disabled={settlingId === p.id}>
|
||||
{settlingId === p.id ? <Spinner /> : '结算'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => setExpandedId(isExpanded ? null : p.id)}>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 展开详情 */}
|
||||
{expandedId && (() => {
|
||||
const p = predictions.find(pr => pr.id === expandedId)
|
||||
if (!p) return null
|
||||
const match = p.match
|
||||
const reports = p.agent_outputs ?? []
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="预测详情" />
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-ink-700">
|
||||
<span>{fmtDate(match?.match_date)}</span>
|
||||
<span className="text-ink-300">|</span>
|
||||
<TeamSideTag side="home" />
|
||||
<span>{match?.home_team_zh || match?.home_team || '?'}</span>
|
||||
<span className="text-ink-400">vs</span>
|
||||
<TeamSideTag side="away" />
|
||||
<span>{match?.away_team_zh || match?.away_team || '?'}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">预测比分</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{p.pred_home_goals != null && p.pred_away_goals != null
|
||||
? `${p.pred_home_goals} : ${p.pred_away_goals}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">实际比分</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{match?.home_goals != null && match?.away_goals != null
|
||||
? `${match.home_goals} : ${match.away_goals}` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3">
|
||||
<p className="text-2xs text-ink-400">置信度</p>
|
||||
<p className="mt-1 font-serif text-xl font-bold text-ink-900">
|
||||
{p.subjective_confidence != null ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.reasoning && (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">终裁意见</h4>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{p.reasoning}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reports.length > 0 && (
|
||||
<div>
|
||||
<h4 className="section-head mb-2">专家意见</h4>
|
||||
<div className="space-y-2">
|
||||
{reports.map((r) => (
|
||||
<div key={r.agent} className="border-b border-ink-100 pb-2 last:border-b-0">
|
||||
<p className="text-xs font-medium text-ink-700">{r.agent}</p>
|
||||
<p className="mt-0.5 text-xs text-ink-500">{r.analysis || '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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>
|
||||
)}
|
||||
<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_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,533 @@
|
||||
/**
|
||||
* Admin 后台 - 统一设置页(报刊风)
|
||||
*
|
||||
* 合并原「数据源」「LLM 配置」「系统配置」三页:
|
||||
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
|
||||
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
|
||||
* 3. 认证 — 修改密码
|
||||
* 4. 系统 — 配置查看 + 修改指南
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
fetchSettings, updateSetting, clearSetting,
|
||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||
fetchKeyRingStatus, resetKeyRingCooldown,
|
||||
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
|
||||
} from '../../api/dal'
|
||||
import type { ScheduleItem } from '../../api/dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../../api/api'
|
||||
import type { LLMUsageStats, DataSourceSetting } from '../../api/types'
|
||||
import type { KeyRingStatusResponse } from '../../api/dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
|
||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
|
||||
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
|
||||
const TABS = [
|
||||
{ id: 'datasource', label: '数据源' },
|
||||
{ id: 'llm', label: '大语言模型' },
|
||||
{ id: 'schedules', label: '定时任务' },
|
||||
{ id: 'security', label: '登录认证' },
|
||||
] as const
|
||||
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
export default function SettingsPage() {
|
||||
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const rawTab = searchParams.get('tab')
|
||||
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
|
||||
const setTab = (id: TabId) =>
|
||||
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
|
||||
|
||||
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
|
||||
|
||||
// LLM
|
||||
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
|
||||
const [llmLoading, setLlmLoading] = useState(true)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Key Ring
|
||||
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
||||
const [ringLoading, setRingLoading] = useState(false)
|
||||
|
||||
// 密码
|
||||
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
||||
const [currentPwd, setCurrentPwd] = useState('')
|
||||
const [newPwd, setNewPwd] = useState('')
|
||||
const [confirmPwd, setConfirmPwd] = useState('')
|
||||
const [pwdBusy, setPwdBusy] = useState(false)
|
||||
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
// 定时任务
|
||||
const [schedules, setSchedules] = useState<ScheduleItem[]>([])
|
||||
const [schedulesLoading, setSchedulesLoading] = useState(true)
|
||||
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(null)
|
||||
|
||||
// ── 数据加载 ──
|
||||
const loadSettings = useCallback(async () => {
|
||||
setSettingsLoading(true)
|
||||
try {
|
||||
const all = await fetchSettings()
|
||||
setAllSettings(all)
|
||||
} catch {
|
||||
setAllSettings([])
|
||||
} finally {
|
||||
setSettingsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLlmStats = useCallback(async () => {
|
||||
setLlmLoading(true)
|
||||
try {
|
||||
setLlmStats(await fetchLLMUsageStats())
|
||||
} catch {
|
||||
setLlmStats(null)
|
||||
} finally {
|
||||
setLlmLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadKeyRing = useCallback(async () => {
|
||||
setRingLoading(true)
|
||||
try {
|
||||
setKeyRing(await fetchKeyRingStatus())
|
||||
} catch {
|
||||
setKeyRing(null)
|
||||
} finally {
|
||||
setRingLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
loadLlmStats()
|
||||
loadKeyRing()
|
||||
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
|
||||
fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false))
|
||||
}, [loadSettings, loadLlmStats, loadKeyRing])
|
||||
|
||||
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
|
||||
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
|
||||
|
||||
// ── 操作 ──
|
||||
const handleSave = async (key: string, value: string) => {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await updateSetting(key, value)
|
||||
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
|
||||
setEditingKey(null)
|
||||
await loadSettings()
|
||||
if (key === 'BZZOIRO_KEY') await loadKeyRing()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = async (key: string) => {
|
||||
setBusyKey(key)
|
||||
setRowNotice(null)
|
||||
try {
|
||||
await clearSetting(key)
|
||||
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
|
||||
await loadSettings()
|
||||
} catch (err) {
|
||||
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 定时任务操作 ──
|
||||
const handleToggleSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await updateSchedule(s.id, { enabled: !s.enabled })
|
||||
setScheduleNotice({ ok: true, text: `已${!s.enabled ? '启用' : '禁用'}任务「${s.id}」` })
|
||||
setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x))
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '操作失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await runScheduleNow(s.id)
|
||||
setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` })
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '启动失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSchedule = async (s: ScheduleItem) => {
|
||||
setScheduleBusyId(s.id)
|
||||
setScheduleNotice(null)
|
||||
try {
|
||||
await deleteSchedule(s.id)
|
||||
setScheduleNotice({ ok: true, text: `已删除任务「${s.id}」` })
|
||||
setSchedules(prev => prev.filter(x => x.id !== s.id))
|
||||
} catch {
|
||||
setScheduleNotice({ ok: false, text: '删除失败' })
|
||||
} finally {
|
||||
setScheduleBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const detectLLMModels = useCallback(async (): Promise<string[]> => {
|
||||
const r = await fetchLLMModels()
|
||||
if (!r.ok) throw new Error(r.detail)
|
||||
return r.models
|
||||
}, [])
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
await testLLMConnection()
|
||||
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||
} catch (err: unknown) {
|
||||
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetCooldown = async () => {
|
||||
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
|
||||
try {
|
||||
const res = await resetKeyRingCooldown()
|
||||
setKeyRing(res.stats)
|
||||
setRowNotice({ key: "__ring", ok: true, text: res.message })
|
||||
} catch (err) {
|
||||
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPwdNotice(null)
|
||||
if (newPwd !== confirmPwd) {
|
||||
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
|
||||
return
|
||||
}
|
||||
setPwdBusy(true)
|
||||
try {
|
||||
const res = await changePassword(currentPwd, newPwd)
|
||||
setPwdNotice({ ok: true, text: res.message })
|
||||
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
|
||||
} catch (err) {
|
||||
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
|
||||
} finally {
|
||||
setPwdBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SectionHeader
|
||||
title="系统设置"
|
||||
description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
|
||||
/>
|
||||
|
||||
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
|
||||
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-press font-bold text-press'
|
||||
: 'border-transparent text-ink-500 hover:text-ink-900'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 1. 数据源 ── */}
|
||||
{tab === 'datasource' && (
|
||||
<section>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Bzzoiro API"
|
||||
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
|
||||
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
|
||||
) : (
|
||||
dataSourceSettings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
|
||||
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Key Ring */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="API Key 轮换环"
|
||||
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
|
||||
action={<Button variant="outline" size="sm" onClick={handleResetCooldown} disabled={ringLoading}>重置冷却</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{keyRing && keyRing.total > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{keyRing.keys.map((k, i) => {
|
||||
const isBlocked = k.blocked_remaining > 0
|
||||
return (
|
||||
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
|
||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
||||
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||
</div>
|
||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
|
||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-400">暂无 key 配置</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 2. LLM ── */}
|
||||
{tab === 'llm' && (
|
||||
<section>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
|
||||
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
|
||||
) : (
|
||||
llmSettings.map(setting => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
editing={editingKey === setting.key}
|
||||
busy={busyKey === setting.key}
|
||||
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
|
||||
onCancel={() => setEditingKey(null)}
|
||||
onSave={v => handleSave(setting.key, v)}
|
||||
onClear={() => handleClear(setting.key)}
|
||||
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
|
||||
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
|
||||
)}
|
||||
{testResult && (
|
||||
<div className="mt-4">
|
||||
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="mt-4 w-full" onClick={handleTest} disabled={testing}>
|
||||
{testing ? <><Spinner /> 测试中</> : '测试 LLM 连接'}
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-2xs text-ink-400">测试会真实调用一次 LLM 预测,产生费用。</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<Button size="sm" onClick={loadLlmStats} disabled={llmLoading}>{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>} />
|
||||
<CardBody>
|
||||
{llmLoading ? (
|
||||
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
|
||||
) : llmStats ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">总预测数</div>
|
||||
</div>
|
||||
<div className="border-t-2 border-ink-900 pt-3 text-center">
|
||||
{/* 延迟统计后端未接入 → 明确显示「未接入」而非留白,
|
||||
免得被误读成「延迟为 0,性能极好」 */}
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats.avg_latency_ms != null
|
||||
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">
|
||||
{llmStats.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t-2 border-press pt-3 text-center">
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">有效率</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-ink-400">暂无使用统计数据</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<AgentLLMCard />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 3. 认证 ── */}
|
||||
{tab === 'security' && (
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||
<CardBody>
|
||||
{passwordOrigin && (
|
||||
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
|
||||
当前密码来源:
|
||||
{passwordOrigin === 'db' ? <Badge status="success">数据库(scrypt 哈希)</Badge>
|
||||
: passwordOrigin === 'env' ? <Badge status="info">.env 初始值</Badge>
|
||||
: <Badge status="error">未配置</Badge>}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="pwd-current" className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<Input id="pwd-current" type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pwd-new" className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<Input id="pwd-new" type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pwd-confirm" className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<Input id="pwd-confirm" type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-2xs text-ink-400">修改成功后会自动退出登录。</p>
|
||||
<Button variant="solid" size="sm" className="flex-shrink-0" type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}>
|
||||
{pwdBusy ? <><Spinner /> 修改中</> : '修改密码'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── 4. 定时任务 ── */}
|
||||
{tab === 'schedules' && (
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="采集调度"
|
||||
description="配置 cron 表达式定时触发采集任务"
|
||||
action={
|
||||
<Button size="sm" onClick={() => { createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) .then(() => fetchSchedules().then(setSchedules)) }}>
|
||||
+ 新建
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{scheduleNotice && (
|
||||
<div className="mb-3">
|
||||
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
|
||||
</div>
|
||||
)}
|
||||
{schedulesLoading ? (
|
||||
<SkeletonBlock className="h-10 w-full" />
|
||||
) : schedules.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">暂无定时任务,点击右上角「+ 新建」创建</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{schedules.map(s => (
|
||||
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
|
||||
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||
</div>
|
||||
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
|
||||
{s.last_run_at && (
|
||||
<p className="text-2xs text-ink-400">
|
||||
上次: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => handleRunSchedule(s)} disabled={scheduleBusyId === s.id}>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleToggleSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
variant={s.enabled ? 'default' : 'solid'}
|
||||
size="sm"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeleteSchedule(s)} disabled={scheduleBusyId === s.id}>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,36 +3,71 @@
|
||||
*
|
||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||
* 挂载路径: /admin/*
|
||||
*
|
||||
* 全部页面采用 `lazy` 动态导入。
|
||||
*
|
||||
* 此前这里是 10 个静态 import:App.tsx 引入 adminRoutes 就把
|
||||
* Dashboard/Collection/Logs/Eval/Backtest/Settings/Monitoring/
|
||||
* DataCompleteness/DataPipeline/PredictionHistory 连同它们的
|
||||
* 数据访问层一次性拉进首屏 —— 一个只想看赛程的匿名访客,
|
||||
* 要为完整的后台界面付出流量。改为 lazy 后,后台代码只在实际
|
||||
* 访问 /admin/* 时才下载。
|
||||
*
|
||||
* AdminLayout 保持静态引入:它是所有后台页面的公共外壳,
|
||||
* 若也 lazy 则会与页面 chunk 形成串行请求(先下载 layout,
|
||||
* 再下载页面),反而更慢。
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import AdminLayout from './AdminLayout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import CollectionPage from './pages/Collection'
|
||||
import PredictionsPage from './pages/Predictions'
|
||||
import BacktestPage from './pages/Backtest'
|
||||
import MonitoringPage from './pages/Monitoring'
|
||||
import DataSourcesPage from './pages/DataSources'
|
||||
import LLMConfigPage from './pages/LLMConfig'
|
||||
import ConfigPage from './pages/Config'
|
||||
import LogsPage from './pages/Logs'
|
||||
import EvalPage from './pages/EvalPage'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const CollectionPage = lazy(() => import('./pages/Collection'))
|
||||
const DataCompletenessPage = lazy(() => import('./pages/DataCompleteness'))
|
||||
const DataPipelinePage = lazy(() => import('./pages/DataPipeline'))
|
||||
const PredictionsPage = lazy(() => import('./pages/PredictionHistory'))
|
||||
const BacktestPage = lazy(() => import('./pages/Backtest'))
|
||||
const MonitoringPage = lazy(() => import('./pages/Monitoring'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const EvalPage = lazy(() => import('./pages/EvalPage'))
|
||||
|
||||
/**
|
||||
* 页面级懒加载的兜底:后台各页共用。
|
||||
* 保持极简,避免后台切换时出现大块占位跳动。
|
||||
*/
|
||||
function AdminPageFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<span className="text-xs text-ink-400" role="status" aria-live="polite">
|
||||
加载中…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 用 Suspense 包住单个后台页面 */
|
||||
function page(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<AdminPageFallback />}>{node}</Suspense>
|
||||
}
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'predictions', element: <PredictionsPage /> },
|
||||
{ path: 'backtest', element: <BacktestPage /> },
|
||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||
{ path: 'config', element: <ConfigPage /> },
|
||||
{ path: 'logs', element: <LogsPage /> },
|
||||
{ path: 'eval', element: <EvalPage /> },
|
||||
{ index: true, element: page(<Dashboard />) },
|
||||
{ path: 'collection', element: page(<CollectionPage />) },
|
||||
{ path: 'data-completeness', element: page(<DataCompletenessPage />) },
|
||||
{ path: 'data-pipeline', element: page(<DataPipelinePage />) },
|
||||
{ path: 'predictions', element: page(<PredictionsPage />) },
|
||||
{ path: 'backtest', element: page(<BacktestPage />) },
|
||||
{ path: 'monitoring', element: page(<MonitoringPage />) },
|
||||
{ path: 'settings', element: page(<SettingsPage />) },
|
||||
{ path: 'logs', element: page(<LogsPage />) },
|
||||
{ path: 'eval', element: page(<EvalPage />) },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
+3
-344
@@ -1,346 +1,5 @@
|
||||
/**
|
||||
* Admin 后台 - TypeScript 类型定义
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/types.ts`。
|
||||
* 新代码请直接从 `../api/types` 导入。
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
total_matches: number
|
||||
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
|
||||
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' | 'understat' | 'injuries'
|
||||
leagues?: string[]
|
||||
league?: string
|
||||
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 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
|
||||
recent_predictions: MatchRecentPrediction[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
export * from '../api/types'
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
|
||||
*
|
||||
* 提供全局快捷键:
|
||||
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
|
||||
* / — 聚焦搜索(日志页)
|
||||
* r — 刷新当前页面数据(通用)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Modal } from '../components/ui'
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const items: CommandItem[] = pages.map(p => ({
|
||||
id: p.to,
|
||||
label: p.label,
|
||||
group: p.group,
|
||||
action: () => { navigate(p.to); setOpen(false) },
|
||||
}))
|
||||
|
||||
const filtered = query
|
||||
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
|
||||
: items
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Cmd/Ctrl+K → 命令面板
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setOpen(o => !o)
|
||||
setQuery('')
|
||||
}
|
||||
// Escape → 关闭
|
||||
if (e.key === 'Escape' && open) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open])
|
||||
|
||||
return { open, setOpen, query, setQuery, items: filtered }
|
||||
}
|
||||
|
||||
/** 命令面板弹窗 */
|
||||
export function CommandPalette({
|
||||
open,
|
||||
query,
|
||||
setQuery,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
items: CommandItem[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
|
||||
// 重置选中项当列表变化
|
||||
useEffect(() => { setSelected(0) }, [items.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
|
||||
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, items, selected, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
// 按分组聚合
|
||||
const grouped: Record<string, CommandItem[]> = {}
|
||||
for (const item of items) {
|
||||
grouped[item.group] = grouped[item.group] || []
|
||||
grouped[item.group].push(item)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
label="命令面板"
|
||||
animate={false}
|
||||
overlayClassName="z-[60] pt-[15vh]"
|
||||
panelClassName="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||
>
|
||||
{/* 面板内点击不冒泡到遮罩(Modal 已做 target 判断,此处仅阻止穿透) */}
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
|
||||
<span className="text-ink-400">⌘</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="输入页面名或路径…"
|
||||
/* 搜索框无可见标签,用 aria-label 提供可访问名称 */
|
||||
aria-label="搜索页面"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
|
||||
/>
|
||||
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="max-h-[50vh] overflow-y-auto py-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-400">无匹配页面</p>
|
||||
) : (
|
||||
Object.entries(grouped).map(([group, groupItems]) => (
|
||||
<div key={group}>
|
||||
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
|
||||
{groupItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => { item.action(); onClose() }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
|
||||
<span>↑↓ 导航</span>
|
||||
<span>↵ 跳转</span>
|
||||
<span>ESC 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 应用统一 API 客户端(门面)—— 2026-09 自 admin/api.ts 迁入。
|
||||
*
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
*
|
||||
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
|
||||
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
|
||||
*/
|
||||
|
||||
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
|
||||
|
||||
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
|
||||
export { ApiError, UNAUTHORIZED_EVENT }
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
|
||||
type ApiOpts = {
|
||||
timeoutMs?: number
|
||||
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
|
||||
skipAuthHandling?: boolean
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => http.get<T>(path),
|
||||
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.post<T>(path, body, opts),
|
||||
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.put<T>(path, body, opts),
|
||||
delete: <T>(path: string) => http.delete<T>(path),
|
||||
}
|
||||
|
||||
// ── 认证 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/login`, { password })
|
||||
}
|
||||
|
||||
/** 退出登录,清除会话 Cookie */
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/logout`)
|
||||
}
|
||||
|
||||
/** 探测当前登录状态 */
|
||||
export function fetchAuthState(): Promise<{
|
||||
authenticated: boolean
|
||||
enabled: boolean
|
||||
password_origin?: 'db' | 'env' | 'none'
|
||||
}> {
|
||||
return api.get(`${API_BASE}/auth/me`)
|
||||
}
|
||||
|
||||
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
|
||||
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
|
||||
return api.post(
|
||||
`${API_BASE}/auth/change-password`,
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
{ skipAuthHandling: true },
|
||||
)
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 应用数据访问层 —— 需要鉴权的管理端点集合。
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据,
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
*
|
||||
* 归属说明:2026-09 从 `admin/dal.ts` 迁入 `src/api/`。
|
||||
* 公开(免登录)端点在 `./public.ts`;admin/ 下仅留兼容壳。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
IngestJob,
|
||||
AdminStats,
|
||||
LLMUsageStats,
|
||||
IngestTriggerResult,
|
||||
PredictionJobRef,
|
||||
LLMPingResult,
|
||||
SettleResult,
|
||||
HealthProbe,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从多个端点聚合仪表盘数据。
|
||||
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||
*
|
||||
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
|
||||
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
|
||||
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
|
||||
*/
|
||||
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||
// 并行获取各端点数据
|
||||
// predictions 返回数组
|
||||
const [leagues, predictions, health] = await Promise.allSettled([
|
||||
api.get<League[]>(`${API_BASE}/leagues`),
|
||||
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
||||
api.get<{ status: string }>('/health'),
|
||||
])
|
||||
|
||||
return {
|
||||
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
||||
// predictions 直接返回数组
|
||||
total_predictions: predictions.status === 'fulfilled' ? predictions.value?.length ?? 0 : 0,
|
||||
health: health.status === 'fulfilled' ? health.value.status : 'unknown',
|
||||
// 以下三项目前无对应后端端点。返回 null 而非空数组:
|
||||
// 空数组会被读成「查过了,没有数据」,而事实是「还没接」。
|
||||
db_tables: null,
|
||||
last_collection: null,
|
||||
recent_errors: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerCollection(req: CollectionRequest): Promise<IngestTriggerResult> {
|
||||
// 后端 IngestBzzoiroRequest 的字段全部为可选,用 Partial 显式表达
|
||||
// 「未选中的筛选项不发」,而不是靠 `Record<string, any>` 蒙混。
|
||||
const body: Partial<CollectionRequest> & { task: string; limit: number } = {
|
||||
leagues: req.leagues,
|
||||
date_from: req.date_from,
|
||||
date_to: req.date_to,
|
||||
status: req.status || undefined,
|
||||
task: req.task || 'events',
|
||||
limit: req.limit || 100,
|
||||
season: req.season || undefined,
|
||||
}
|
||||
return api.post<IngestTriggerResult>(`${API_BASE}/ingest/bzzoiro`, body)
|
||||
}
|
||||
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<PredictionJobRef> {
|
||||
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
|
||||
match_id: req.match_id,
|
||||
mode: req.mode || 'multi',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<Prediction[]> {
|
||||
const res = await api.get<Prediction[] | { items?: Prediction[] }>(
|
||||
`${API_BASE}/predictions?limit=${limit}`,
|
||||
)
|
||||
// 后端声明为 list[PredictionOut];保留 items 分支兼容网关包装层。
|
||||
if (Array.isArray(res)) return res
|
||||
return res?.items ?? []
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export async function fetchEvalSummary(params: {
|
||||
limit?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
league_code?: string
|
||||
} = {}): Promise<EvalSummary | null> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.provider) sp.set('provider', params.provider)
|
||||
if (params.model) sp.set('model', params.model)
|
||||
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
|
||||
if (params.mode) sp.set('mode', params.mode)
|
||||
if (params.league_code) sp.set('league_code', params.league_code)
|
||||
try {
|
||||
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
||||
}
|
||||
|
||||
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLeagues(): Promise<League[]> {
|
||||
try {
|
||||
return await api.get<League[]>(`${API_BASE}/leagues`)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMatches(params: {
|
||||
league?: string
|
||||
status?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.league) sp.set('league', params.league)
|
||||
if (params.status) sp.set('status', params.status)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.cursor) sp.set('cursor', params.cursor)
|
||||
|
||||
try {
|
||||
return await api.get<{ items: Match[]; has_next: boolean; next_cursor: string | null }>(
|
||||
`${API_BASE}/matches?${sp}`,
|
||||
)
|
||||
} catch {
|
||||
return { items: [], has_next: false, next_cursor: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function settlePrediction(
|
||||
prediction_id: number,
|
||||
home_goals: number,
|
||||
away_goals: number,
|
||||
): Promise<SettleResult> {
|
||||
return api.post<SettleResult>(`${API_BASE}/eval/settle`, {
|
||||
prediction_id,
|
||||
home_goals,
|
||||
away_goals,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 健康检查 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<HealthProbe> {
|
||||
try {
|
||||
return await api.get<HealthProbe>('/health')
|
||||
} catch {
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataCompletenessResponse {
|
||||
generated_at: string
|
||||
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
||||
issues: string[]
|
||||
leagues: Array<{
|
||||
code: string
|
||||
name: string
|
||||
country?: string
|
||||
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
||||
stats: {
|
||||
rows: number
|
||||
fields: Record<string, { count: number; pct: number }>
|
||||
}
|
||||
standings: { rows: number; latest_retrieved?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
||||
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
||||
}
|
||||
|
||||
// ── 积分榜 & 比赛详情 ───────────────────────────────────────────
|
||||
//
|
||||
// 这几者与它们的类型已迁到 `src/api/public.ts` —— 它们是**无需登录**
|
||||
// 的公开端点,却是前台页面的数据来源。留在这里会让公开页反向依赖
|
||||
// admin 层,并把 41 个后台端点一起拖进用户首屏的 chunk。
|
||||
//
|
||||
// 此处保留再导出,使既有 `from './dal'` 的调用路径继续可用;
|
||||
// 新代码请直接从 `src/api/public` 导入。
|
||||
|
||||
export { fetchStandings, fetchMatchDetail, fetchMatchContext } from '../api/public'
|
||||
export type { StandingRow, StandingsLeague } from '../api/public'
|
||||
|
||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
||||
*/
|
||||
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
||||
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源状态与配置(脱敏)
|
||||
*/
|
||||
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
||||
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
||||
*/
|
||||
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
||||
return api.get(`${API_BASE}/admin/llm/models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各专家/终裁的独立 LLM 配置状态
|
||||
*/
|
||||
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
||||
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询系统日志(内存缓冲,最新在前)
|
||||
*/
|
||||
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.level) sp.set('level', params.level)
|
||||
if (params.keyword) sp.set('keyword', params.keyword)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可配置项(脱敏),供各配置页渲染
|
||||
*/
|
||||
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
||||
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
||||
*/
|
||||
export function updateSetting(key: string, value: string) {
|
||||
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置项的 DB 覆盖值,回落 .env
|
||||
*/
|
||||
export function clearSetting(key: string) {
|
||||
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试 LLM 连接 — 调用预测端点验证
|
||||
*/
|
||||
export async function testLLMConnection(matchId?: number): Promise<LLMPingResult | PredictionJobRef> {
|
||||
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
||||
try {
|
||||
return await api.post<LLMPingResult>(`${API_BASE}/admin/llm/ping`, {})
|
||||
} catch {
|
||||
// 回退到旧方式(兼容)
|
||||
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
|
||||
match_id: matchId || 1,
|
||||
mode: 'single',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 使用统计 — 从预测列表聚合。
|
||||
*
|
||||
* 注意:延迟统计是**后端尚未提供的维度**。此前这里返回硬编码的
|
||||
* `avg_latency_ms: 2400`,它会穿过类型检查、被渲染成「2.4s」,
|
||||
* 并作为真实性能指标进入人的判断。现已改为 `null`,由 UI 显示
|
||||
* 「—」并标注未接入。宁可留空,不可编数。
|
||||
*/
|
||||
export async function fetchLLMUsageStats(): Promise<LLMUsageStats> {
|
||||
try {
|
||||
const predictions = await fetchPredictions(50)
|
||||
const total = predictions.length
|
||||
const successCount = predictions.filter(p => p.pred_1x2).length
|
||||
return {
|
||||
total_predictions: total,
|
||||
avg_latency_ms: null, // 后端暂无延迟统计端点
|
||||
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
recent_predictions: predictions.slice(0, 10).map(p => ({
|
||||
id: p.id,
|
||||
match_id: p.match_id,
|
||||
model: p.model,
|
||||
created_at: p.created_at,
|
||||
status: p.pred_1x2 ? 'success' as const : 'failed' as const,
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total_predictions: 0,
|
||||
avg_latency_ms: null,
|
||||
success_rate: 0,
|
||||
recent_predictions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源健康/最近采集状态(只读,不触发采集)
|
||||
*/
|
||||
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
|
||||
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额
|
||||
*/
|
||||
export function fetchUpstreamProbe(): Promise<{
|
||||
ok: boolean
|
||||
status_code?: number
|
||||
latency_ms: number
|
||||
endpoint: string
|
||||
error?: string
|
||||
}> {
|
||||
return api.get<never>(`${API_BASE}/admin/monitoring/upstream`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务状态轮询(单任务)
|
||||
*/
|
||||
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
||||
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
|
||||
*/
|
||||
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
|
||||
const q = new URLSearchParams()
|
||||
if (params.limit != null) q.set('limit', String(params.limit))
|
||||
if (params.status) q.set('status', params.status)
|
||||
const qs = q.toString()
|
||||
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理区统计(只读):近 24h/7d 预测次数
|
||||
*/
|
||||
export function fetchAdminStats(): Promise<AdminStats> {
|
||||
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
||||
}
|
||||
|
||||
// ── API Key 轮换环 ──────────────────────────────────────────────
|
||||
|
||||
export interface KeyRingKeyStatus {
|
||||
masked: string
|
||||
blocked_remaining: number
|
||||
}
|
||||
|
||||
export interface KeyRingStatusResponse {
|
||||
base_url: string
|
||||
total: number
|
||||
has_multiple: boolean
|
||||
cooldown_seconds: number
|
||||
active_index: number
|
||||
active_key: string | null
|
||||
keys: KeyRingKeyStatus[]
|
||||
}
|
||||
|
||||
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
|
||||
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
|
||||
}
|
||||
|
||||
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
|
||||
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
|
||||
}
|
||||
|
||||
// ── 定时任务 ────────────────────────────────────────────────────
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string
|
||||
task: string
|
||||
cron: string
|
||||
leagues?: string
|
||||
enabled: boolean
|
||||
last_run_at?: string | null
|
||||
last_status?: string | null
|
||||
}
|
||||
|
||||
export async function fetchSchedules(): Promise<ScheduleItem[]> {
|
||||
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
|
||||
}
|
||||
|
||||
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
|
||||
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
|
||||
}
|
||||
|
||||
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
|
||||
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
|
||||
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
|
||||
}
|
||||
|
||||
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
|
||||
}
|
||||
|
||||
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
|
||||
|
||||
export interface IngestFailureItem {
|
||||
id: number
|
||||
source: string
|
||||
entity_type: string
|
||||
source_record_id?: string
|
||||
error_type: string
|
||||
error_detail?: string
|
||||
retry_count: number
|
||||
status: string
|
||||
next_retry_at?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityCheckItem {
|
||||
id: number
|
||||
check_name: string
|
||||
entity_type: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
detail?: Record<string, unknown> | null
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityResponse {
|
||||
failures: IngestFailureItem[]
|
||||
checks: DataQualityCheckItem[]
|
||||
}
|
||||
|
||||
export async function fetchDataQuality(): Promise<DataQualityResponse> {
|
||||
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
|
||||
}
|
||||
|
||||
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
|
||||
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
|
||||
}
|
||||
|
||||
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
|
||||
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
|
||||
}
|
||||
|
||||
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 前台公开端点 —— 预测主站与积分榜所需的最小数据访问。
|
||||
*
|
||||
* 为什么要单独成文件(而不是继续挂在 `admin/dal.ts` 里):
|
||||
*
|
||||
* 1. **分层方向错了。** 面向公众的页面反向 import `../admin/dal`,
|
||||
* 意味着想重构后台就会牵连前台,目录名也持续误导人以为
|
||||
* `admin/` 是可独立删除的包。公开端点的归属本就不该是 admin。
|
||||
*
|
||||
* 2. **它真的会进用户的首屏。** `dal.ts` 是单个大模块,内部函数
|
||||
* 相互引用,打包器无法按调用点做 tree-shaking —— 实测
|
||||
* Matches 只用 2 个函数,但 `/admin/ingest/jobs`、`/admin/llm/models`、
|
||||
* `/admin/eval`、`/admin/backtest`、`/admin/logs` 这些字符串全部
|
||||
* 留在了公开页所在 chunk 里。抽出来后可省下约 55 kB 首屏 JS。
|
||||
*
|
||||
* 本文件只放**无需登录即可访问**的端点。任何需要鉴权的端点继续留在
|
||||
* `./dal.ts`。新增公开端点时请加在这里,不要加回 admin。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type { MatchDetailOut, MatchContextOut } from './types'
|
||||
|
||||
// ── 积分榜 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface StandingRow {
|
||||
position: number
|
||||
team: string
|
||||
team_en: string
|
||||
played: number
|
||||
won: number
|
||||
drawn: number
|
||||
lost: number
|
||||
goals_for: number
|
||||
goals_against: number
|
||||
goal_diff: number
|
||||
points: number
|
||||
xg_for: number | null
|
||||
xg_against: number | null
|
||||
form: string | null
|
||||
zone: string | null
|
||||
}
|
||||
|
||||
export interface StandingsLeague {
|
||||
league_code: string
|
||||
league_name: string
|
||||
season: string
|
||||
retrieved_at: string | null
|
||||
rows: StandingRow[]
|
||||
}
|
||||
|
||||
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (league) sp.set('league', league)
|
||||
if (season) sp.set('season', season)
|
||||
const qs = sp.toString()
|
||||
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// ── 比赛详情 ────────────────────────────────────────────────────
|
||||
|
||||
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
|
||||
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
|
||||
}
|
||||
|
||||
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
|
||||
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 应用级 API 类型定义(2026-09 自 admin/types.ts 迁入)。
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐。
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
|
||||
// 不要再用列表 items.length 近似(上限 100 会严重失真)
|
||||
total_predictions: number
|
||||
health: string
|
||||
/**
|
||||
* 以下三个字段对应的后端端点在**当前版本中并不存在**。
|
||||
*
|
||||
* 它们曾以 `[]` 返回,调用方无法区分「确实没有错误」与
|
||||
* 「这个功能还没接」——空数组是一个会被当成结论的事实断言。
|
||||
* 改为可空,让「未接入」在类型层面无法被忽略。
|
||||
* 后端补齐端点后,把类型收窄回数组即可(编译器会指出所有消费点)。
|
||||
*/
|
||||
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] | null
|
||||
last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[] | null
|
||||
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[] | null
|
||||
}
|
||||
|
||||
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||
|
||||
export interface League {
|
||||
id?: number
|
||||
code: string
|
||||
name: string
|
||||
name_zh?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: number
|
||||
league_code?: string
|
||||
season?: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals?: number | null
|
||||
away_goals?: number | null
|
||||
match_stage?: string | null
|
||||
}
|
||||
|
||||
// ── 预测 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 列表接口返回的单路专家摘要字段 */
|
||||
export interface PredictionAgentOutput {
|
||||
agent: string
|
||||
status: string
|
||||
analysis?: string | null
|
||||
probable_score?: string | null
|
||||
subjective_confidence?: number | null
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
id: number
|
||||
match_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
pred_home_goals?: number | null
|
||||
pred_away_goals?: number | null
|
||||
alt_pred_home_goals?: number | null
|
||||
alt_pred_away_goals?: number | null
|
||||
pred_1x2?: string | null
|
||||
subjective_confidence?: number | null
|
||||
reasoning?: string | null
|
||||
agent_outputs?: PredictionAgentOutput[] | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
status?: 'success' | 'failed' | 'degraded'
|
||||
created_at: string
|
||||
actual_home_goals?: number | null
|
||||
actual_away_goals?: number | null
|
||||
settled?: boolean
|
||||
/**
|
||||
* 关联比赛摘要。后端 `PredictionOut.match` 是 `dict | None`
|
||||
* (见 `src/api/schemas.py`),由 `_match_dict()` 拼出,字段与 `Match`
|
||||
* 对齐但**每个字段都可能为 null**(比赛缺 league/team 时)。
|
||||
*
|
||||
* 此前这个字段在类型里根本不存在,消费方只能写 `(p as any).match`
|
||||
* ——类型系统失去了它唯一该起作用的地方。现在它被显式声明为可选。
|
||||
*/
|
||||
match?: PredictionMatchRef | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 预测记录里内嵌的比赛摘要。
|
||||
*
|
||||
* 独立于 `Match` 声明而不是直接复用,是因为二者契约不同:
|
||||
* `Match` 的 `id`/`match_date` 是必填的(来自列表端点),
|
||||
* 而这里是嵌套投影,后端 `_match_dict()` 在关联缺失时返回 null,
|
||||
* 字段也可能缺失。混用会让必填约束说谎。
|
||||
*/
|
||||
export interface PredictionMatchRef {
|
||||
id?: number | null
|
||||
league_code?: string | null
|
||||
season?: string | null
|
||||
home_team?: string | null
|
||||
away_team?: string | null
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date?: string | null
|
||||
match_status?: string | null
|
||||
home_goals?: number | null
|
||||
away_goals?: number | null
|
||||
match_stage?: string | null
|
||||
}
|
||||
|
||||
export interface PredictRequest {
|
||||
match_id: number
|
||||
mode?: 'single' | 'multi'
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── 通用接口返回壳 ──────────────────────────────────────────────
|
||||
//
|
||||
// 这些类型此前在 dal.ts 里以 `Promise<any>` 的形式存在,等于把后端契约
|
||||
// 丢掉了。声明在这里(与其它 API 类型同处)而不是 dal.ts 内联,是为了
|
||||
// 让消费方能直接 import type 而无需从实现文件取类型。
|
||||
|
||||
/** POST /api/v1/ingest/bzzoiro — 采集任务已受理,返回 job_id 供轮询 */
|
||||
export interface IngestTriggerResult {
|
||||
ok?: boolean
|
||||
job_id?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** POST /api/v1/predict — 预测任务已受理(P1-async)。注意是 202 语义,非最终结果 */
|
||||
export interface PredictionJobRef {
|
||||
job_id: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
poll_url?: string
|
||||
/** job 完成后的信封:`_predict_jobs[id]` 里是 { status, result } 或 { status, error } */
|
||||
result?: Prediction
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/ping — 连通性探测 */
|
||||
export interface LLMPingResult {
|
||||
ok?: boolean
|
||||
provider?: string
|
||||
model?: string
|
||||
latency_ms?: number
|
||||
message?: string
|
||||
/** ping 端点尚未接入时后端可能只回一个透传对象 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** POST /api/v1/eval/settle — 结算结果 */
|
||||
export interface SettleResult {
|
||||
ok?: boolean
|
||||
settled?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /health` 存活探针的实际返回。
|
||||
*
|
||||
* 与文件开头那个 `HealthStatus` 不是同一个契约 —— 那个是 admin/stats
|
||||
* 聚合口径(status 为 ok|degraded|error,带 checks 明细),这个才是
|
||||
* `src/api/app.py` 里 `/health` 直接吐出的结构。此前两者在 dal.ts 里
|
||||
* 共用同一个 `any`,差异被彻底抹平。
|
||||
*
|
||||
* `version` / `uptime_seconds` 可空:后端在包元数据缺失时返回 null,
|
||||
* 监控页据此降级隐藏版本卡片。
|
||||
*/
|
||||
export interface HealthProbe {
|
||||
status: 'healthy' | 'ok' | 'unknown' | string
|
||||
service?: string
|
||||
version?: string | null
|
||||
uptime_seconds?: number | null
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionRequest {
|
||||
status?: string
|
||||
source: 'bzzoiro'
|
||||
leagues?: string[]
|
||||
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||
limit?: number
|
||||
season?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export interface EvalCalibrationBucket {
|
||||
total: number
|
||||
/** 该桶命中率,百分数;样本不足为 null */
|
||||
hit_rate: number | null
|
||||
}
|
||||
|
||||
export interface EvalSummaryRow {
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
total: number
|
||||
/** 1X2 准确率,百分数 0-100 */
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number | null
|
||||
avg_subjective_confidence?: number | null
|
||||
/** 置信度校准:按主观置信度分桶的命中率 */
|
||||
calibration?: Record<string, EvalCalibrationBucket>
|
||||
}
|
||||
|
||||
export interface EvalSummary {
|
||||
summary: Array<EvalSummaryRow>
|
||||
/** 全量已结算数 */
|
||||
total_settled: number
|
||||
/** 应用筛选后的已结算数 */
|
||||
filtered_settled: number
|
||||
/** 实际评估条数(status=success 且比分齐全) */
|
||||
evaluated: number
|
||||
/** 跳过的 degraded 条数 */
|
||||
skipped_degraded: number
|
||||
/** 跳过的比分不全条数 */
|
||||
skipped_incomplete?: number
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
league_id?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
mode?: 'single' | 'multi'
|
||||
limit?: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface BacktestSummary {
|
||||
total: number
|
||||
scored: number
|
||||
success: number
|
||||
degraded: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataSourceSetting {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
sensitive: boolean
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface DataSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
key_configured: boolean
|
||||
last_ingestion: string | null
|
||||
settings: DataSourceSetting[]
|
||||
}
|
||||
|
||||
export interface DataSourceTestResult {
|
||||
ok: boolean
|
||||
status: number | null
|
||||
latency_ms: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DataSourceTestRequest {
|
||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||
}
|
||||
|
||||
export interface IngestionHistoryEntry {
|
||||
id: string
|
||||
source: string
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
status: 'success' | 'running' | 'failed'
|
||||
records_count: number | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: string
|
||||
model: string
|
||||
base_url: string
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
export interface LLMUsageStats {
|
||||
total_predictions: number
|
||||
/**
|
||||
* 平均延迟(毫秒)。**后端当前没有延迟统计端点**,故此处为 `null`。
|
||||
*
|
||||
* 曾经这里是一个硬编码的 2400,并且会被渲染成看起来完全可信的
|
||||
* 「2.4s」。运营据此判断系统性能时,读到的是一个不存在的数字。
|
||||
* 改为 `null` 是刻意的:它让「未接入」这件事在类型层面强制可见,
|
||||
* 调用方必须显式处理,而不是继承一个编造的默认值。
|
||||
*/
|
||||
avg_latency_ms: number | null
|
||||
success_rate: number
|
||||
recent_predictions: Array<{
|
||||
id: number
|
||||
match_id: number
|
||||
model: string
|
||||
created_at: string
|
||||
latency_ms?: number
|
||||
status: 'success' | 'failed'
|
||||
}>
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string
|
||||
value_masked: string
|
||||
description: string
|
||||
is_sensitive: boolean
|
||||
}
|
||||
|
||||
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
|
||||
|
||||
export interface LLMAgentFieldState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface LLMAgentConfig {
|
||||
id: string
|
||||
label: string
|
||||
effective_model: string
|
||||
fields: {
|
||||
model: LLMAgentFieldState
|
||||
base_url: LLMAgentFieldState
|
||||
api_key: LLMAgentFieldState
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统日志 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
ts: number
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
|
||||
|
||||
export interface IngestLastFailure {
|
||||
at: string
|
||||
logger: string
|
||||
detail: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface IngestSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
key_configured: boolean
|
||||
base_url?: string
|
||||
reachable: boolean | null
|
||||
status?: 'key_not_configured' | 'no_data' | 'has_data'
|
||||
last_success_at: string | null
|
||||
latest_match_date?: string | null
|
||||
recent_count: number
|
||||
note: string
|
||||
last_failure: IngestLastFailure | null
|
||||
}
|
||||
|
||||
// ── 采集任务状态 ──────────────────────────────────────────────
|
||||
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
task: string
|
||||
params: Record<string, unknown>
|
||||
status: 'pending' | 'running' | 'success' | 'failed'
|
||||
result: Record<string, unknown> | null
|
||||
error: string | null
|
||||
created_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
// ── 比赛详情 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRecentPrediction {
|
||||
id: number
|
||||
provider: string
|
||||
model: string
|
||||
mode: string
|
||||
pred_home_goals: number | null
|
||||
pred_away_goals: number | null
|
||||
alt_pred_home_goals: number | null
|
||||
alt_pred_away_goals: number | null
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
status: string
|
||||
settled: boolean
|
||||
correct_1x2?: boolean
|
||||
created_at: string
|
||||
actual_home_goals: number | null
|
||||
actual_away_goals: number | null
|
||||
/**
|
||||
* 单路专家原始输出。后端 `agent_outputs` 是 `list[dict]`(未定 schema),
|
||||
* 各专家的键随 agent 不同。这里用 `Record<string, unknown>` 而非 `any`:
|
||||
* 取值处必须显式收窄,编译器不再放行任意属性访问。
|
||||
*/
|
||||
agent_outputs?: Array<Record<string, unknown>> | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
}
|
||||
|
||||
export interface MatchDetailOut {
|
||||
id: number
|
||||
league_code: string | null
|
||||
season: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh: string | null
|
||||
away_team_zh: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
match_stage: string | null
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
stats: MatchStatsDetail | null
|
||||
recent_predictions: MatchRecentPrediction[]
|
||||
}
|
||||
|
||||
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
|
||||
export interface MatchStatsDetail {
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
home_shots: number | null
|
||||
away_shots: number | null
|
||||
home_shots_on_target: number | null
|
||||
away_shots_on_target: number | null
|
||||
home_corners: number | null
|
||||
away_corners: number | null
|
||||
home_possession: number | null
|
||||
home_yellow_cards: number | null
|
||||
away_yellow_cards: number | null
|
||||
home_red_cards: number | null
|
||||
away_red_cards: number | null
|
||||
home_big_chances: number | null
|
||||
away_big_chances: number | null
|
||||
home_fouls: number | null
|
||||
away_fouls: number | null
|
||||
}
|
||||
|
||||
export interface TeamRecentMatch {
|
||||
match_date: string | null
|
||||
home_team: string | null
|
||||
away_team: string | null
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
}
|
||||
|
||||
export interface MatchContextOut {
|
||||
home_recent: TeamRecentMatch[]
|
||||
away_recent: TeamRecentMatch[]
|
||||
h2h: TeamRecentMatch[]
|
||||
}
|
||||
|
||||
// ── 管理区统计 ─────────────────────────────────────────────────
|
||||
|
||||
export interface AdminStats {
|
||||
predictions: {
|
||||
total: number
|
||||
last_24h: number
|
||||
last_7d: number
|
||||
}
|
||||
matches?: { total: number; finished: number }
|
||||
stats?: { total: number }
|
||||
standings?: { total: number }
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||
*
|
||||
* 滚动监听已抽到 `lib/useScroll` 的 useWindowScrollY —— 全站三处
|
||||
* 滚动监听此前各自实现一遍生命周期,现统一为单一来源。
|
||||
*/
|
||||
import { useWindowScrollY } from '../lib/useScroll'
|
||||
|
||||
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||
const scrollY = useWindowScrollY()
|
||||
const show = scrollY > threshold
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center border border-ink-300 bg-paper-50 text-ink-600 transition-all duration-300 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 ${
|
||||
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
|
||||
}`}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import { Button } from './ui'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
/**
|
||||
* 该边界是否铺满整个视口。
|
||||
*
|
||||
* 应用最外层的边界应铺满(`true`,默认);而当边界下沉到
|
||||
* 路由级、嵌在 SiteLayout 的 <main> 里时,铺满视口会撑开
|
||||
* 布局并让页头页脚错位 —— 那种场景传 `false`,改为局部卡片。
|
||||
*/
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -29,20 +38,25 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
const fullScreen = this.props.fullScreen ?? true
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||
<div
|
||||
className={
|
||||
fullScreen
|
||||
? 'flex min-h-screen items-center justify-center bg-paper-50 p-6'
|
||||
: 'flex min-h-[50vh] items-center justify-center p-6'
|
||||
}
|
||||
role="alert"
|
||||
>
|
||||
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
|
||||
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
||||
<p className="text-sm leading-relaxed text-ink-500">
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Button size="sm" onClick={() => this.setState({ hasError: false, error: null })}>
|
||||
重试
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 报头组件(前台共用)。
|
||||
*
|
||||
* 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移
|
||||
* (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。
|
||||
* 现在统一为 Masthead:
|
||||
* - active 声明当前版面,对应导航项加印报红高亮 + aria-current
|
||||
* - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙)
|
||||
* - 管理类入口带 title 提示,避免访客被无声踢到登录页
|
||||
* 页脚文案各页不同,仍由调用方自行渲染。
|
||||
*/
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export type MastheadActive = 'home' | 'standings'
|
||||
|
||||
function GearIcon() {
|
||||
return (
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MastheadLink({
|
||||
to,
|
||||
active = false,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
/** 当前版面才高亮;管理类入口无高亮概念 */
|
||||
active?: boolean
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
title={title}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`transition-colors ${
|
||||
active ? 'font-medium text-press' : 'text-ink-500 hover:text-press'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Masthead({ active }: { active: MastheadActive }) {
|
||||
return (
|
||||
<header className="masthead-rule">
|
||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||
先知
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||
<span>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}</span>
|
||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||
<MastheadLink to="/" active={active === 'home'}>比赛 / 预测</MastheadLink>
|
||||
<MastheadLink to="/standings" active={active === 'standings'}>积分榜</MastheadLink>
|
||||
{/* 管理类页面对访客需要登录,入口处先说明,避免无声跳登录页 */}
|
||||
<MastheadLink to="/admin/eval" title="评估页面向管理员开放,需登录">评估</MastheadLink>
|
||||
<MastheadLink to="/admin" title="管理后台,需登录">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GearIcon /> 管理
|
||||
</span>
|
||||
</MastheadLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* SiteLayout —— 前台公开站点的共享外壳(报头 + 内容区 + 页脚)。
|
||||
*
|
||||
* 背景:此前 `HomePage` 与 `StandingsLayout` 各自复制了一遍
|
||||
* `<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">`
|
||||
* 和 `<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">`。
|
||||
* 两处 className 逐字相同,唯一差异是 Masthead 的 active 值与
|
||||
* 页脚文案。改一次容器内边距要改两处,加第三个页面就是第三处。
|
||||
*
|
||||
* 这里把「容器宽度 / 内边距 / 页脚分隔线样式」收进单一来源,
|
||||
* 调用方只需提供 active(决定报头高亮)与页脚文案。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import Masthead from './Masthead'
|
||||
import type { MastheadActive } from './Masthead'
|
||||
|
||||
/** 报头的 active 分区,决定报刊头里哪个分区链接高亮 */
|
||||
export type SiteSection = MastheadActive
|
||||
|
||||
export interface SiteLayoutProps {
|
||||
/** 当前分区,用于报头高亮 */
|
||||
active: SiteSection
|
||||
/** 页脚文案(各分区声明差异,如首页含免责声明) */
|
||||
footerNote: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SiteLayout({ active, footerNote, children }: SiteLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
<Masthead active={active} />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
{footerNote}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SiteLayout
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* AnimatedBadge: 带脉冲状态点的小标签。
|
||||
*
|
||||
* 灵感来自 beui.dev AnimatedBadge,适配 Profeto 报纸风:
|
||||
* - 状态点脉冲动画(loading) + 静态色点(其他状态)
|
||||
* - 三种尺寸(sm/md/lg) + 七种语义(neutral/info/loading/success/warning/danger)
|
||||
* - 用于 ingest_jobs / predictions / data_sources 等状态展示
|
||||
*/
|
||||
import { cn } from './utils'
|
||||
|
||||
export type AnimatedBadgeStatus = 'neutral' | 'info' | 'loading' | 'success' | 'warning' | 'danger'
|
||||
export type AnimatedBadgeSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const STATUS_DOT: Record<AnimatedBadgeStatus, string> = {
|
||||
neutral: 'bg-ink-400',
|
||||
info: 'bg-sky-500',
|
||||
loading: 'bg-sky-500',
|
||||
success: 'bg-emerald-500',
|
||||
warning: 'bg-amber-500',
|
||||
danger: 'bg-rose-500',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<AnimatedBadgeStatus, string> = {
|
||||
neutral: '未开始',
|
||||
info: '信息',
|
||||
loading: '进行中',
|
||||
success: '成功',
|
||||
warning: '警告',
|
||||
danger: '失败',
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<AnimatedBadgeSize, string> = {
|
||||
sm: 'gap-1.5 px-2 py-0.5 text-2xs',
|
||||
md: 'gap-2 px-2.5 py-1 text-xs',
|
||||
lg: 'gap-2.5 px-3 py-1 text-sm',
|
||||
}
|
||||
|
||||
export interface AnimatedBadgeProps {
|
||||
status: AnimatedBadgeStatus
|
||||
size?: AnimatedBadgeSize
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AnimatedBadge({ status, size = 'md', children, className }: AnimatedBadgeProps) {
|
||||
const dot = STATUS_DOT[status]
|
||||
const label = children ?? STATUS_LABEL[status]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full border border-ink-200 bg-paper-50 font-medium text-ink-700',
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* 状态点:loading 脉冲动画,其他静态 */}
|
||||
<span className="relative flex h-2 w-2">
|
||||
{status === 'loading' && (
|
||||
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', dot)} />
|
||||
)}
|
||||
<span className={cn('relative inline-flex h-2 w-2 rounded-full', dot)} />
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 状态标记 Badge。
|
||||
*
|
||||
* 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
||||
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
||||
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
warning: { text: 'text-press', mark: 'border border-press' },
|
||||
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
||||
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 卡片族:Card / CardHeader / CardBody。
|
||||
*
|
||||
* 报刊风:方角、墨线描边、无阴影。原位于 admin/components.tsx,
|
||||
* 合并到 components/ui 作为全站共享组件。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 数据表格族:DataTable / MobileCardList / ResponsiveTable。
|
||||
* 原位于 admin/components.tsx。
|
||||
*
|
||||
* ResponsiveTable 按断点二选一渲染:桌面表格 / 移动卡片。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { EmptyState } from './Feedback'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function DataTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(row => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: row != null && typeof row === 'object' && col.key in row
|
||||
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MobileCardList<T = any>({
|
||||
data,
|
||||
renderCard,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
data: T[]
|
||||
renderCard: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{data.map((row, idx) => (
|
||||
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
||||
{renderCard(row, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 响应式表格容器 ──────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ResponsiveTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
cardRender,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
cardRender: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端表格 */}
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
||||
</div>
|
||||
{/* 移动端卡片 */}
|
||||
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* EventList: 事件/活动流 —— 时间线样式的事件列表。
|
||||
*
|
||||
* 灵感来自 beautifului EventList,适配 Profeto 报纸风:
|
||||
* - 左侧时间轴细线
|
||||
* - 事件类型图标圆点(彩色)
|
||||
* - 时间戳 + 摘要 + 详情
|
||||
* - 支持展开/折叠详情
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface EventItem {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'warning' | 'info' | 'pending'
|
||||
title: string
|
||||
timestamp: string
|
||||
detail?: string
|
||||
actor?: string
|
||||
}
|
||||
|
||||
const TYPE_DOT: Record<string, string> = {
|
||||
success: 'bg-emerald-500',
|
||||
error: 'bg-rose-500',
|
||||
warning: 'bg-amber-500',
|
||||
info: 'bg-sky-500',
|
||||
pending: 'bg-ink-300',
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
warning: '警告',
|
||||
info: '信息',
|
||||
pending: '进行中',
|
||||
}
|
||||
|
||||
export function EventList({ events, maxInitial = 10 }: { events: EventItem[]; maxInitial?: number }) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const visible = showAll ? events : events.slice(0, maxInitial)
|
||||
const hasMore = events.length > maxInitial && !showAll
|
||||
|
||||
const toggle = (id: string) => setExpanded(s => ({ ...s, [id]: !s[id] }))
|
||||
|
||||
if (events.length === 0) {
|
||||
return <div className="py-8 text-center text-xs text-ink-400">暂无事件</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 时间轴细线 */}
|
||||
<div className="absolute bottom-2 left-3 top-2 w-px bg-ink-200" aria-hidden="true" />
|
||||
|
||||
<ul className="space-y-0">
|
||||
{visible.map((ev) => {
|
||||
const isOpen = expanded[ev.id]
|
||||
return (
|
||||
<li key={ev.id} className="relative pl-8 pr-3">
|
||||
{/* 圆点 */}
|
||||
<span
|
||||
className={`absolute left-1.5 top-3 inline-block h-3 w-3 rounded-full border-2 border-paper-50 ${TYPE_DOT[ev.type]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => ev.detail && toggle(ev.id)}
|
||||
className={`w-full border-b border-ink-100 py-2.5 text-left transition-colors hover:bg-paper-100 ${
|
||||
ev.detail ? 'cursor-pointer' : 'cursor-default'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-ink-800">{ev.title}</span>
|
||||
<span className="flex-shrink-0 text-2xs tabular-nums text-ink-400">{ev.timestamp}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-ink-500">
|
||||
<span className={`rounded px-1 py-0.5 text-2xs font-medium ${
|
||||
ev.type === 'success' ? 'bg-emerald-50 text-emerald-700' :
|
||||
ev.type === 'error' ? 'bg-rose-50 text-rose-700' :
|
||||
ev.type === 'warning' ? 'bg-amber-50 text-amber-700' :
|
||||
ev.type === 'pending' ? 'bg-ink-100 text-ink-500' :
|
||||
'bg-sky-50 text-sky-700'
|
||||
}`}>
|
||||
{TYPE_LABEL[ev.type]}
|
||||
</span>
|
||||
{ev.actor && <span>· {ev.actor}</span>}
|
||||
</div>
|
||||
{ev.detail && isOpen && (
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{ev.detail}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{hasMore && (
|
||||
<div className="mt-2 pl-8">
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="text-xs text-ink-500 underline-offset-2 hover:text-ink-900 hover:underline"
|
||||
>
|
||||
展开全部 {events.length} 条
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 反馈类组件:Alert / ErrorBanner / describeError / EmptyState / EmptyText。
|
||||
* 原位于 admin/components.tsx。
|
||||
*
|
||||
* 依赖说明:`ApiError` 直接从 `lib/http` 导入(而非 `admin/api` 的转发),
|
||||
* 使本组件不依赖 admin 目录 —— 这正是合并两套组件库要解决的问题。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { ApiError } from '../../lib/http'
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
export function Alert({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
action,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
/** 右侧操作按钮(如「去修复」) */
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const style =
|
||||
kind === 'error'
|
||||
? 'border-press bg-press-wash'
|
||||
: kind === 'warning'
|
||||
? 'border-press bg-press-wash/60'
|
||||
: kind === 'ok'
|
||||
? 'border-ink-900 bg-paper-100'
|
||||
: 'border-ink-300 bg-paper-50'
|
||||
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
|
||||
|
||||
/*
|
||||
无障碍:提示条是异步出现的(请求失败、操作完成),视觉用户
|
||||
看到了,屏幕阅读器用户此前什么都收不到 —— 因为它只是一个
|
||||
普通的 <div>,出现时不会触发任何朗读。
|
||||
|
||||
这里按语义分级:
|
||||
error / warning → role="alert" (assertive,立即打断朗读)
|
||||
ok / info → role="status" (polite,等当前朗读结束)
|
||||
这也是 WAI-ARIA 对 live region 的推荐用法。
|
||||
*/
|
||||
const isUrgent = kind === 'error' || kind === 'warning'
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isUrgent ? 'alert' : 'status'}
|
||||
className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}
|
||||
>
|
||||
<div>
|
||||
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
||||
|
||||
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
||||
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
|
||||
if (err instanceof ApiError) {
|
||||
const status = err.status
|
||||
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
|
||||
? String((err.data as { detail: unknown }).detail)
|
||||
: ''
|
||||
const msg = apiDetail || err.message
|
||||
switch (status) {
|
||||
case 401:
|
||||
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
|
||||
case 403:
|
||||
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
|
||||
case 429:
|
||||
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
|
||||
case 502:
|
||||
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
|
||||
case 503:
|
||||
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
|
||||
case 0:
|
||||
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
|
||||
}
|
||||
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { title: '操作失败', detail: err.message, kind: 'error' }
|
||||
}
|
||||
return { title: '未知错误', detail: String(err), kind: 'error' }
|
||||
}
|
||||
|
||||
/** 统一错误横幅:用于页面级错误展示。 */
|
||||
export function ErrorBanner({
|
||||
err,
|
||||
onClose,
|
||||
}: {
|
||||
err: unknown
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { title, detail, kind } = describeError(err)
|
||||
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
|
||||
}
|
||||
|
||||
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
||||
|
||||
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
||||
return (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">{text}</p>
|
||||
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 空状态文本(极简版) */
|
||||
export function EmptyText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-ink-400">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* FilterTable: 带列筛选 + 排序的增强表格。
|
||||
*
|
||||
* 灵感来自 beautifului FilterTable,适配 Profeto 报纸风:
|
||||
* - 列头点击排序(升/降/无)
|
||||
* - 文本列支持输入筛选
|
||||
* - 状态列支持下拉筛选
|
||||
* - 与现有 DataTable 风格统一(细线/留白/衬线)
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { EmptyState } from './Feedback'
|
||||
|
||||
export interface FilterColumn<T> {
|
||||
key: string
|
||||
label: string
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
sortable?: boolean
|
||||
filterable?: boolean
|
||||
filterType?: 'text' | 'select'
|
||||
filterOptions?: { value: string; label: string }[]
|
||||
render?: (row: T) => React.ReactNode
|
||||
}
|
||||
|
||||
type SortDir = 'asc' | 'desc' | null
|
||||
|
||||
export function FilterTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyText = '暂无数据',
|
||||
initialSort,
|
||||
}: {
|
||||
columns: FilterColumn<T>[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
emptyText?: string
|
||||
initialSort?: { key: string; dir: 'asc' | 'desc' }
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(initialSort?.key ?? null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>(initialSort?.dir ?? null)
|
||||
const [filters, setFilters] = useState<Record<string, string>>({})
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : sortDir === 'desc' ? null : 'asc')
|
||||
if (sortDir === 'desc') setSortKey(null)
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilter = (key: string, value: string) => {
|
||||
setFilters(f => (value ? { ...f, [key]: value } : { ...f, [key]: '' }))
|
||||
// 筛选变化时重置排序,避免混淆
|
||||
setSortKey(null)
|
||||
setSortDir(null)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = data
|
||||
for (const col of columns) {
|
||||
const fv = filters[col.key]
|
||||
if (!fv) continue
|
||||
rows = rows.filter(r => {
|
||||
const v = r[col.key]
|
||||
if (v == null) return false
|
||||
return String(v).toLowerCase().includes(fv.toLowerCase())
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}, [data, columns, filters])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey || !sortDir) return filtered
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const av = a[sortKey]
|
||||
const bv = b[sortKey]
|
||||
if (av == null && bv == null) return 0
|
||||
if (av == null) return 1
|
||||
if (bv == null) return -1
|
||||
if (av < bv) return sortDir === 'asc' ? -1 : 1
|
||||
if (av > bv) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
return sorted
|
||||
}, [filtered, sortKey, sortDir, columns])
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
||||
{columns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="select-none px-3 py-2 font-medium"
|
||||
style={{ width: col.width, textAlign: col.align ?? 'left' }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.sortable && (
|
||||
<button
|
||||
onClick={() => handleSort(col.key)}
|
||||
className={`flex items-center gap-0.5 hover:text-ink-900 ${
|
||||
sortKey === col.key ? 'text-ink-900' : ''
|
||||
}`}
|
||||
title="排序"
|
||||
>
|
||||
{col.label}
|
||||
<span className="text-[10px]">
|
||||
{sortKey === col.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!col.sortable && col.label}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 筛选行 */}
|
||||
{columns.some(c => c.filterable) && (
|
||||
<tr className="border-b border-ink-200 bg-paper-100/50">
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-2 py-1.5">
|
||||
{col.filterable && col.filterType === 'select' && col.filterOptions ? (
|
||||
<select
|
||||
value={filters[col.key] ?? ''}
|
||||
onChange={e => handleFilter(col.key, e.target.value)}
|
||||
className="w-full border border-ink-200 bg-paper-50 px-1 py-0.5 text-xs text-ink-700"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{col.filterOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : col.filterable ? (
|
||||
<input
|
||||
type="text"
|
||||
value={filters[col.key] ?? ''}
|
||||
onChange={e => handleFilter(col.key, e.target.value)}
|
||||
placeholder="筛选…"
|
||||
className="w-full border border-ink-200 bg-paper-50 px-2 py-0.5 text-xs text-ink-700 placeholder:text-ink-300"
|
||||
/>
|
||||
) : null}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(row => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="px-3 py-2.5 text-ink-800"
|
||||
style={{ textAlign: col.align ?? 'left' }}
|
||||
>
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: row[col.key] != null
|
||||
? String(row[col.key])
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sorted.length === 0 && filtered.length > 0 && (
|
||||
<div className="py-6 text-center text-xs text-ink-400">没有匹配的数据</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* InsightCard: 指标洞察卡片 —— 数值 + 趋势 + 对比。
|
||||
*
|
||||
* 灵感来自 beautifului InsightCard / StatCard,适配 Profeto 报纸风:
|
||||
* - 大字号主指标(衬线)
|
||||
* - 趋势箭头(↑↓) + 变化率
|
||||
* - 可选对比基线(如"较上周 +12%")
|
||||
* - 状态色(info/warning/positive/negative)
|
||||
*/
|
||||
import { TrendingArrow } from './TrendingArrow'
|
||||
|
||||
export interface InsightCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
/** 趋势方向: up/down/flat */
|
||||
trend?: 'up' | 'down' | 'flat'
|
||||
/** 变化率文案,如 "+12%" */
|
||||
trendLabel?: string
|
||||
/** 对比说明,如 "较昨日" */
|
||||
compareHint?: string
|
||||
/** 状态色 */
|
||||
status?: 'info' | 'warning' | 'positive' | 'negative'
|
||||
/** 副标题 / 补充说明 */
|
||||
subtitle?: string
|
||||
/** 图标(ReactNode) */
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, { card: string; accent: string }> = {
|
||||
info: { card: 'border-ink-200', accent: 'text-ink-600' },
|
||||
warning: { card: 'border-amber-300', accent: 'text-amber-700' },
|
||||
positive: { card: 'border-emerald-300', accent: 'text-emerald-700' },
|
||||
negative: { card: 'border-rose-300', accent: 'text-rose-700' },
|
||||
}
|
||||
|
||||
const TREND_COLOR: Record<string, string> = {
|
||||
up: 'text-emerald-600',
|
||||
down: 'text-rose-600',
|
||||
flat: 'text-ink-400',
|
||||
}
|
||||
|
||||
export function InsightCard({ label, value, trend, trendLabel, compareHint, status = 'info', subtitle, icon }: InsightCardProps) {
|
||||
const style = STATUS_STYLE[status]
|
||||
|
||||
return (
|
||||
<article className={`border bg-paper-50 px-4 py-4 shadow-sm transition-shadow hover:shadow-md ${style.card}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium uppercase tracking-wider text-ink-500">{label}</p>
|
||||
<p className={`mt-1 font-serif text-2xl font-bold leading-none sm:text-3xl ${style.accent}`}>
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="mt-1 truncate text-xs text-ink-400">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded ${style.accent} bg-paper-100`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(trend || trendLabel) && (
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs">
|
||||
{trend && <TrendingArrow dir={trend} className="h-3 w-3" />}
|
||||
{trendLabel && (
|
||||
<span className={`font-medium ${TREND_COLOR[trend ?? 'flat']}`}>{trendLabel}</span>
|
||||
)}
|
||||
{compareHint && <span className="text-ink-400">{compareHint}</span>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/** InsightCard 网格容器 */
|
||||
export function InsightGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* LoaderGrid: 网格骨架占位 —— 卡片/面板加载态。
|
||||
*
|
||||
* 灵感来自 beautifului LoaderGrid,适配 Profeto 报纸风:
|
||||
* - 网格布局(响应式)
|
||||
* - 脉冲动画占位块
|
||||
* - 可配置列数/行数
|
||||
*/
|
||||
|
||||
function shimmer() {
|
||||
return 'animate-pulse bg-paper-100'
|
||||
}
|
||||
|
||||
/** 单张骨架卡片 */
|
||||
function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
||||
return (
|
||||
<div className="border border-ink-200 bg-paper-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-8 w-8 rounded ${shimmer()}`} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className={`h-3 w-2/3 rounded ${shimmer()}`} />
|
||||
<div className={`h-2.5 w-1/3 rounded ${shimmer()}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div key={i} className={`h-3 rounded ${shimmer()}`} style={{ width: `${70 + Math.random() * 25}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className={`h-2.5 w-16 rounded ${shimmer()}`} />
|
||||
<div className={`h-2.5 w-12 rounded ${shimmer()}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单行骨架(表格行) */
|
||||
function SkeletonRow({ cols = 4 }: { cols?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-ink-200">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-3 py-3">
|
||||
<div className={`h-3 rounded ${shimmer()}`} style={{ width: i === 0 ? '60%' : '80%' }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export interface LoaderGridProps {
|
||||
/** 模式: card=卡片网格 / row=表格行 */
|
||||
mode?: 'card' | 'row'
|
||||
/** 显示数量 */
|
||||
count?: number
|
||||
/** 列数(card 模式)或列数(row 模式) */
|
||||
cols?: number
|
||||
/** 卡片模式行数 */
|
||||
cardLines?: number
|
||||
}
|
||||
|
||||
export function LoaderGrid({ mode = 'card', count = 4, cols = 4, cardLines = 3 }: LoaderGridProps) {
|
||||
if (mode === 'card') {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} lines={cardLines} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<th key={i} className="px-3 py-2">
|
||||
<div className={`h-3 w-12 rounded ${shimmer()}`} />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonRow key={i} cols={cols} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* SearchList: 即时搜索列表 —— 输入框 + 过滤结果 + 选中态。
|
||||
*
|
||||
* 灵感来自 beautifului SearchList,适配 Profeto 报纸风:
|
||||
* - 顶部搜索输入(放大镜图标)
|
||||
* - 即时过滤(前端本地匹配)
|
||||
* - 选中高亮 + hover 态
|
||||
* - 空态提示
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
export interface SearchListProps<T> {
|
||||
items: T[]
|
||||
/** 抽取用于搜索的文本 */
|
||||
searchText: (item: T) => string
|
||||
/** 抽取唯一 key */
|
||||
itemKey: (item: T) => string
|
||||
/** 渲染每行 */
|
||||
renderItem: (item: T, active: boolean) => React.ReactNode
|
||||
/** 点击回调 */
|
||||
onSelect?: (item: T) => void
|
||||
/** 当前选中 key */
|
||||
activeKey?: string
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
export function SearchList<T>({
|
||||
items,
|
||||
searchText,
|
||||
itemKey,
|
||||
renderItem,
|
||||
onSelect,
|
||||
activeKey,
|
||||
placeholder = '搜索…',
|
||||
emptyText = '无匹配项',
|
||||
maxHeight = '360px',
|
||||
}: SearchListProps<T>) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return items
|
||||
const q = query.trim().toLowerCase()
|
||||
return items.filter(it => searchText(it).toLowerCase().includes(q))
|
||||
}, [items, query, searchText])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* 搜索框 */}
|
||||
<div className="sticky top-0 border-b border-ink-200 bg-paper-50 px-3 py-2">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8.5" cy="8.5" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M12.5 12.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full border border-ink-200 bg-paper-50 py-1.5 pl-8 pr-3 text-sm text-ink-800 placeholder:text-ink-300 focus:border-ink-400 focus:outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-ink-400 hover:text-ink-700"
|
||||
aria-label="清除"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center text-xs text-ink-400">{emptyText}</div>
|
||||
) : (
|
||||
<ul>
|
||||
{filtered.map(item => {
|
||||
const key = itemKey(item)
|
||||
const active = key === activeKey
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item)}
|
||||
className={`w-full border-b border-ink-100 px-3 py-2 text-left transition-colors ${
|
||||
active ? 'bg-press-wash/40' : 'hover:bg-paper-100'
|
||||
}`}
|
||||
>
|
||||
{renderItem(item, active)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 结果计数 */}
|
||||
{filtered.length > 0 && (
|
||||
<div className="border-t border-ink-200 px-3 py-1.5 text-right text-2xs text-ink-400">
|
||||
{filtered.length} / {items.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 小节标题 SectionHeader:同前台 section-head 语言。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="section-head text-base">{title}</h2>
|
||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Select: 自定义下拉选择器(带展开动画 + 分组 + 图标)。
|
||||
*
|
||||
* 灵感来自 beui.dev Select,适配 Profeto 报纸风:
|
||||
* - 触发器 + 弹出层(Portal) + 选项列表
|
||||
* - 键盘交互(↑↓/Enter/Esc)
|
||||
* - 选项分组(SelectGroup)
|
||||
* - 选中态高亮 + hover 态
|
||||
* - 支持图标前缀
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from './utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectGroup {
|
||||
label: string
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
interface SelectCoreProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options?: SelectOption[]
|
||||
groups?: SelectGroup[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
groups = [],
|
||||
placeholder = '请选择…',
|
||||
disabled = false,
|
||||
className,
|
||||
id,
|
||||
}: SelectCoreProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [highlighted, setHighlighted] = useState(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
// 扁平化选项列表(用于键盘导航)
|
||||
const flat: SelectOption[] = []
|
||||
const groupLabels: string[] = []
|
||||
if (groups.length) {
|
||||
groups.forEach(g => {
|
||||
groupLabels.push(g.label)
|
||||
flat.push(...g.options)
|
||||
})
|
||||
} else {
|
||||
flat.push(...options)
|
||||
}
|
||||
|
||||
const selected = flat.find(o => o.value === value)
|
||||
const selectedLabel = selected?.label ?? placeholder
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
// 高亮项滚动到可视区
|
||||
useEffect(() => {
|
||||
if (open && listRef.current) {
|
||||
const el = listRef.current.children[highlighted] as HTMLElement | undefined
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}, [highlighted, open])
|
||||
|
||||
// 键盘交互
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (open) {
|
||||
const opt = flat[highlighted]
|
||||
if (opt && !opt.disabled) {
|
||||
onChange(opt.value)
|
||||
setOpen(false)
|
||||
}
|
||||
} else {
|
||||
setOpen(true)
|
||||
setHighlighted(flat.findIndex(o => o.value === value))
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setOpen(false)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (!open) {
|
||||
setOpen(true)
|
||||
} else {
|
||||
setHighlighted(h => {
|
||||
let n = (h + 1) % flat.length
|
||||
while (flat[n]?.disabled) n = (n + 1) % flat.length
|
||||
return n
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (open) {
|
||||
setHighlighted(h => {
|
||||
let n = (h - 1 + flat.length) % flat.length
|
||||
while (flat[n]?.disabled) n = (n - 1 + flat.length) % flat.length
|
||||
return n
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (opt: SelectOption) => {
|
||||
if (opt.disabled) return
|
||||
onChange(opt.value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
if (groups.length > 0) {
|
||||
let idx = 0
|
||||
return groups.map((group, gi) => (
|
||||
<li key={gi}>
|
||||
<p className="border-b border-ink-100 px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-400">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul>
|
||||
{group.options.map(opt => {
|
||||
const i = idx++
|
||||
const on = opt.value === value
|
||||
return (
|
||||
<li key={opt.value}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
disabled={opt.disabled}
|
||||
onClick={() => handleSelect(opt)}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
|
||||
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
|
||||
opt.disabled && 'cursor-not-allowed opacity-40',
|
||||
)}
|
||||
>
|
||||
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{on && (
|
||||
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
return options.map((opt, idx) => {
|
||||
const on = opt.value === value
|
||||
return (
|
||||
<li key={opt.value}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
disabled={opt.disabled}
|
||||
onClick={() => handleSelect(opt)}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors',
|
||||
on ? 'bg-press-wash/40 font-medium text-ink-900' : 'text-ink-700 hover:bg-paper-100',
|
||||
opt.disabled && 'cursor-not-allowed opacity-40',
|
||||
)}
|
||||
>
|
||||
{opt.icon && <span className="flex-shrink-0">{opt.icon}</span>}
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{on && (
|
||||
<svg className="ml-auto h-4 w-4 flex-shrink-0 text-ink-900" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M5 10l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative', className)} onKeyDown={onKeyDown}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen(o => !o)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between border bg-paper-50 px-3 py-2 text-sm transition-colors',
|
||||
open ? 'border-ink-900' : 'border-ink-200',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-ink-400',
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center gap-2 truncate', selected ? 'text-ink-800' : 'text-ink-400')}>
|
||||
{selected?.icon && <span className="flex-shrink-0">{selected.icon}</span>}
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<svg
|
||||
className={cn('h-4 w-4 flex-shrink-0 text-ink-400 transition-transform', open && 'rotate-180')}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 8l5 5 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto border border-ink-900 bg-paper-50 py-1 shadow-md"
|
||||
>
|
||||
{renderItems()}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* SkeletonRows:骨架占位行(低调脉动灰块)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx —— 它是通用骨架,不属于 Matches 页面,
|
||||
* 上移到 UI 层。单块骨架用 <Skeleton>,整行占位用本组件。
|
||||
*/
|
||||
|
||||
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 统计与进度可视化:StatCard / ProgressBar / AgentWeightsBar。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
||||
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
||||
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 进度条:同前台置信度细线 */
|
||||
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
||||
<div
|
||||
className="h-full rounded-full bg-press transition-[width] duration-500"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Agent 权重条形图 */
|
||||
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
|
||||
const entries = Object.entries(weights).filter(([, w]) => w > 0)
|
||||
if (entries.length === 0) return null
|
||||
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
|
||||
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
|
||||
return (
|
||||
<div className="mt-2 border-t border-ink-200 pt-2">
|
||||
<div className="mb-1 text-2xs text-ink-400">终裁专家权重</div>
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, w], i) => (
|
||||
<div key={k} className="flex items-center gap-2 text-2xs">
|
||||
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
|
||||
<div
|
||||
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right tabular-nums text-ink-500">
|
||||
{Math.round((w / total) * 100)}%
|
||||
</span>
|
||||
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">有效专家:{okCount}/{entries.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Switch:一组互斥的文字切换(状态 / 模式)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx,上移到 UI 层 —— 它用的是全站统一的
|
||||
* `.tab` / `.tab-on` 语言(方角、印报红下划线),属通用组件。
|
||||
*
|
||||
* 注:内部的 <button> 不在 `.btn` 体系内(用 `.tab`),属有意保留的自定义样式。
|
||||
*/
|
||||
|
||||
export function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tabs —— 报刊风版面切换基元。
|
||||
*
|
||||
* 背景:`Matches.tsx` 与 `Standings.tsx` 各写了一份高度雷同的联赛切换
|
||||
* `<button>` + `.tab` / `.tab-on` 类名。两份实现已经在细节上漂移
|
||||
* (一个有 `disabled` 无数据态与虚线下划线,一个没有),这正是缺少
|
||||
* 基元的典型症状 —— 复制粘贴会在无人察觉处产生第二个「事实标准」。
|
||||
*
|
||||
* 收口后,选择态、`aria-current`、无数据降级三件事只在一处定义。
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cx } from './index'
|
||||
|
||||
export interface TabItem {
|
||||
/** 稳定 key,同时作为标识 */
|
||||
value: string
|
||||
/** 显示文本 */
|
||||
label: ReactNode
|
||||
/**
|
||||
* 无可用数据。不阻断点击(用户仍可切过去看空状态),
|
||||
* 但用虚线下划线弱化提示 —— 而非降字色,浅灰在纸底上对比度不足且形似禁用。
|
||||
*/
|
||||
empty?: boolean
|
||||
/** 原生 title 提示,鼠标悬停时解释 empty 的原因 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
items: TabItem[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** 整体禁用(如切换请求进行中) */
|
||||
disabled?: boolean
|
||||
/** 无障碍标签,描述这组页签是什么(如「联赛」) */
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页签组基元。
|
||||
*
|
||||
* 视觉完全复用既有 `.tab` / `.tab-on`,不引入新的设计语言。
|
||||
*
|
||||
* 转发 ref 到内部 `<nav>`:调用方需要它来测量横向溢出
|
||||
* (如「右侧还有更多联赛」的渐变遮罩依赖 `useCanScrollRight`)。
|
||||
*/
|
||||
export const Tabs = forwardRef<HTMLElement, TabsProps>(function Tabs(
|
||||
{ items, value, onChange, disabled = false, ariaLabel, className },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
className={cx('flex items-center overflow-x-auto', className)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map(item => {
|
||||
const on = value === item.value
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
disabled={disabled}
|
||||
title={item.title}
|
||||
// aria-current 而非 aria-selected:这是「当前所在版面」的导航语义,
|
||||
// 不是 tablist/tab 的复合控件模式(那需要 role=tabpanel 配套)。
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className={cx('relative tab font-serif', on && 'tab-on', disabled && 'disabled:opacity-50')}
|
||||
>
|
||||
{item.label}
|
||||
{item.empty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 border-b border-dotted border-ink-300"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* TrendingArrow: 趋势箭头(↑↓→)。
|
||||
* InsightCard 的依赖子组件。
|
||||
*/
|
||||
export function TrendingArrow({ dir, className = '' }: { dir: 'up' | 'down' | 'flat'; className?: string }) {
|
||||
if (dir === 'flat') {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M2 6h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const isUp = dir === 'up'
|
||||
return (
|
||||
<svg
|
||||
className={`${className} ${isUp ? '' : 'rotate-180'}`}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 2L10 7H2L6 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* UI 基元层 —— 全站唯一的基础交互组件来源。
|
||||
*
|
||||
* 背景:此前只有业务级组件(Card/DataTable 等),基础交互控件全靠各页面
|
||||
* 手写 className + `.btn` / `.field` 等 CSS 类维持一致。结果是:
|
||||
* - `btn-ghost` 被使用但从未定义,样式静默失效(与早年的 btn-outline 同型)
|
||||
* - Spinner 在前台/后台各复制一份
|
||||
* - 弹窗焦点陷阱只在一个弹窗里有,另一个缺
|
||||
* 组件化的意义在于「让错误在编译期暴露」,而不是靠人工守纪律。
|
||||
*
|
||||
* 设计约束:视觉输出与迁移前逐字一致(使用同一批 CSS 类),
|
||||
* 本层只做「收口 + 类型约束」,不改设计语言。
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
SelectHTMLAttributes,
|
||||
ReactNode,
|
||||
MouseEventHandler,
|
||||
} from 'react'
|
||||
|
||||
// ── 工具:CSS 类名拼接 ────────────────────────────────────────────
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// ── Button ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 按钮变体。用联合类型约束,写错变体名会在编译期报错
|
||||
* —— 这是根治 `btn-ghost` 那类「幻影变体」的关键。
|
||||
*/
|
||||
export type ButtonVariant = 'default' | 'solid' | 'outline' | 'danger' | 'ghost'
|
||||
export type ButtonSize = 'default' | 'sm'
|
||||
|
||||
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
||||
default: '',
|
||||
solid: 'btn-solid',
|
||||
outline: 'btn-outline',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<ButtonSize, string> = {
|
||||
default: '',
|
||||
sm: 'btn-sm',
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
/** 显示加载态:自动禁用并前置 Spinner */
|
||||
loading?: boolean
|
||||
/** 占满父容器宽度 */
|
||||
block?: boolean
|
||||
/**
|
||||
* 传入时渲染为 `<a>` 而非 `<button>`(链接样式的按钮)。
|
||||
* 此时 disabled/loading 无效 —— 链接没有这两个语义。
|
||||
*/
|
||||
href?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮基元。默认输出 `.btn` 的方角纸片样式。
|
||||
*
|
||||
* 传 `href` 时渲染为 `<a>`(链接样式的按钮,如卡片里的「定位 →」跳转),
|
||||
* 复用同一套变体类名。按钮语义(点击执行动作)与链接语义(导航到别处)
|
||||
* 在 HTML 层不可互换,因此不硬造一个 `<button>` 假装链接。
|
||||
*
|
||||
* 注:`min-h-[44px]` 等尺寸细节仍由各页面按场景通过 className 覆盖
|
||||
* (如弹窗按钮需更大触控目标),基元只保证变体与尺寸语义统一。
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'default', size = 'default', loading = false, block = false, href, className, children, disabled, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx(
|
||||
'btn',
|
||||
VARIANT_CLASS[variant],
|
||||
SIZE_CLASS[size],
|
||||
block && 'w-full',
|
||||
className,
|
||||
)
|
||||
|
||||
if (href != null) {
|
||||
// 锚点分支:只透传与 <a> 兼容且实际会用到的属性。
|
||||
// 显式收窄而不是解构 rest —— button 属性(如 formAction)对锚点无意义,
|
||||
// 且两类元素的 onClick 处理器参数类型不同,直接展开会让 TS 反变失配。
|
||||
const a = rest as unknown as {
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>
|
||||
title?: string
|
||||
'aria-label'?: string
|
||||
target?: string
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={a.onClick}
|
||||
title={a.title}
|
||||
aria-label={a['aria-label']}
|
||||
target={a.target}
|
||||
className={cls}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cls}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner /> {children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Input / Select ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输入框基元:统一 `.field` 方角样式。
|
||||
*
|
||||
* 无障碍提醒:请为输入框提供可访问名称 —— 要么传 `id` 并在外部配
|
||||
* `<label htmlFor={id}>`,要么传 `label`(内部转 aria-label)。
|
||||
*
|
||||
* 背景:全站 20 个 `<input>` 里多数只有视觉上相邻的 `<label>`,
|
||||
* 没有 `htmlFor`/`id` 关联。屏幕阅读器读出来只有「编辑框」,
|
||||
* 在密码框场景下用户无法分辨「当前密码」与「确认新密码」。
|
||||
*
|
||||
* 注:多数页面目前仍直接写 `<input className="field">` 而非用本基元,
|
||||
* 那些调用点需各自补 id/htmlFor(已在 admin 各页处理)。
|
||||
*/
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/** 无可见标签时的可访问名称(内部转 aria-label) */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, label, 'aria-label': ariaLabel, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cx('field', className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// 用 type 别名而非空 interface:空 interface 与父类型完全等价,
|
||||
// 留着只会让「这里以后可能要加字段」的意图变成噪声。
|
||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
/** 下拉框基元:与 Input 同一 `.field` 语言。 */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<select ref={ref} className={cx('field', className)} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载指示。全站唯一来源(此前存在三份逐字节相同的副本:
|
||||
* admin/components.tsx / pages/matches/ui.tsx / MatchPredictPanel.tsx)。
|
||||
*/
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={cx('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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────
|
||||
|
||||
/** 骨架占位块 */
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={cx('skeleton', className)} />
|
||||
}
|
||||
|
||||
// ── 业务级组件:统一从本目录再导出,使 `components/ui` 成为唯一入口 ──
|
||||
|
||||
export { Card, CardHeader, CardBody } from './Card'
|
||||
export { Badge } from './Badge'
|
||||
export { StatCard, ProgressBar, AgentWeightsBar } from './Stat'
|
||||
export { SectionHeader } from './SectionHeader'
|
||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from './Feedback'
|
||||
export { DataTable, MobileCardList, ResponsiveTable } from './DataTable'
|
||||
export { SkeletonRows } from './SkeletonRows'
|
||||
export { Switch } from './Switch'
|
||||
export { Tabs } from './Tabs'
|
||||
export type { TabItem, TabsProps } from './Tabs'
|
||||
|
||||
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
|
||||
export { AnimatedBadge } from './AnimatedBadge'
|
||||
export { EventList } from './EventList'
|
||||
export { FilterTable } from './FilterTable'
|
||||
export { InsightCard, InsightGrid } from './InsightCard'
|
||||
export { LoaderGrid } from './LoaderGrid'
|
||||
export { SearchList } from './SearchList'
|
||||
export { CustomSelect } from './Select'
|
||||
export { TrendingArrow } from './TrendingArrow'
|
||||
export type { AnimatedBadgeStatus, AnimatedBadgeSize } from './AnimatedBadge'
|
||||
export type { EventItem } from './EventList'
|
||||
export type { FilterColumn } from './FilterTable'
|
||||
export type { InsightCardProps } from './InsightCard'
|
||||
export type { SelectOption } from './Select'
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 无障碍名称,会写入 aria-label */
|
||||
label: string
|
||||
children: ReactNode
|
||||
/** 面板附加类名(控制最大宽度、对齐方式等) */
|
||||
panelClassName?: string
|
||||
/** 遮罩层附加类名 */
|
||||
overlayClassName?: string
|
||||
/** 点击遮罩是否关闭(默认 true) */
|
||||
closeOnOverlay?: boolean
|
||||
/** 显示入场动画(默认 true) */
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗基元:一次性内置全部无障碍行为,使各弹窗表现天然一致。
|
||||
*
|
||||
* 内置能力(此前仅 PredictModal 完整具备,CommandPalette 缺前 3 项):
|
||||
* - 焦点陷阱:Tab / Shift+Tab 循环限制在弹窗内
|
||||
* - ESC 关闭
|
||||
* - 背景滚动锁定(避免内层滚到底后带动底层页面)
|
||||
* - 关闭后焦点归还给触发元素
|
||||
* - 初始聚焦面板,键盘用户可直接 Tab 进入
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
label,
|
||||
children,
|
||||
panelClassName = '',
|
||||
overlayClassName = '',
|
||||
closeOnOverlay = true,
|
||||
animate = true,
|
||||
}: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
panelRef.current?.focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
previouslyFocused.current?.focus()
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed inset-0 flex items-start justify-center bg-ink-900/50 p-4',
|
||||
animate && 'modal-overlay-enter',
|
||||
overlayClassName,
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={e => {
|
||||
if (closeOnOverlay && e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={cx('relative outline-none', animate && 'modal-panel-enter', panelClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* cn: className 合并工具(轻量实现,无需 clsx/tailwind-merge 依赖)。
|
||||
* Profeto 现有代码用 `cx` 做同样的事;本文件为 AnimatedBadge/Select 单独提供。
|
||||
*/
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
+165
-5
@@ -2,6 +2,81 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
设计令牌(颜色)的 CSS 侧来源。
|
||||
─────────────────────────────────────────────────────────────
|
||||
此前 index.css 里三处直接写死了十六进制字面量:
|
||||
· :focus-visible 的 outline → #9e1b1b
|
||||
· .masthead-rule 的 border-top → #17140f
|
||||
· .font-brush 的 color → #9E1B1B(还是大写写法)
|
||||
它们与 tailwind.config.js 的 press / ink 令牌是同一批颜色,却没有
|
||||
共享来源。后果:调整品牌红时改令牌不会影响这三处 —— 焦点轮廓、
|
||||
报头粗线、毛笔字会保持旧色,出现「两个印报红,其中一个没有名字」。
|
||||
|
||||
现在把颜色定义在 :root,由 tailwind.config.js 消费同一份变量,
|
||||
使 CSS 与工具类共享单一来源。改色只需改这里一处。
|
||||
|
||||
⚠️ 值必须写成 RGB 三元组(如 23 20 15)而非 #17140F。
|
||||
原因:Tailwind 的透明度修饰符(bg-press/60)需要把颜色拆成
|
||||
rgb(R G B / <alpha-value>) 才能注入 alpha。若变量本身是完整
|
||||
十六进制,`bg-press-wash/60` 这类写法会**静默不生成任何 CSS** ——
|
||||
编译不报错,但样式消失(实测踩到过)。三元组写法同时兼容
|
||||
不需要 alpha 的场景。
|
||||
*/
|
||||
:root {
|
||||
/* 纸白:微暖底色,像新闻纸而不是纯白画布 */
|
||||
--paper-50: 253 252 248;
|
||||
--paper-100: 247 244 236;
|
||||
--paper-200: 237 233 222;
|
||||
--paper-300: 221 215 199;
|
||||
|
||||
/* 墨色:暖黑灰阶 */
|
||||
--ink-900: 23 20 15;
|
||||
--ink-700: 59 54 46;
|
||||
--ink-500: 110 103 91;
|
||||
--ink-400: 156 149 135;
|
||||
--ink-300: 201 196 184;
|
||||
--ink-200: 226 223 215;
|
||||
|
||||
/* 印报红:全站唯一强调色 */
|
||||
--press: 158 27 27;
|
||||
--press-dark: 124 20 20;
|
||||
--press-wash: 247 233 228;
|
||||
}
|
||||
|
||||
/* 毛体草书(刘建毛草) - 本地自托管子集(仅「先知」两字,约 1KB)
|
||||
原先直连 jsDelivr 的 @main 分支(未锁版本、国内加载不稳、5MB 全字库)。
|
||||
现仅保留报头用字,构建期随包发布,消除外部单点依赖。 */
|
||||
@font-face {
|
||||
font-family: 'Liu Jian Mao Cao';
|
||||
src: url('./assets/fonts/LiuJianMaoCao-subset.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
unicode-range: U+5148, U+77E5; /* 先 U+5148 / 知 U+77E5 */
|
||||
}
|
||||
|
||||
/* 粗毛笔字效果:笔触加粗 + 微描边 */
|
||||
.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%;
|
||||
@@ -19,7 +94,7 @@
|
||||
|
||||
/* 统一焦点环:印报红细线,键盘可达 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #9e1b1b;
|
||||
outline: 2px solid var(--press);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -53,19 +128,20 @@
|
||||
@layer components {
|
||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||
.masthead-rule {
|
||||
border-top: 3px solid #17140f;
|
||||
border-top: 3px solid var(--ink-900);
|
||||
}
|
||||
|
||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||
@apply inline-flex 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;
|
||||
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 py-1 text-xs min-h-[36px];
|
||||
@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;
|
||||
@@ -73,6 +149,74 @@
|
||||
.btn-danger {
|
||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||
}
|
||||
/* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合
|
||||
(此前三处使用但从未定义,样式静默失效) */
|
||||
.btn-outline {
|
||||
@apply border-ink-900 bg-transparent text-ink-900
|
||||
hover:border-press hover:bg-press-wash hover:text-press-dark;
|
||||
}
|
||||
/* 幽灵按钮:无边、无底色,仅 hover 时浮出浅纸底。
|
||||
用于「重置」「取消」等最轻量的次要动作。
|
||||
(EvalPage 曾使用但从未定义,与 btn-outline 同型问题;
|
||||
现由 src/components/ui 的 Button variant 类型约束兜底,不再可能静默失效) */
|
||||
.btn-ghost {
|
||||
@apply border-transparent bg-transparent text-ink-500
|
||||
hover:bg-paper-200 hover:text-ink-900
|
||||
disabled:hover:bg-transparent disabled:hover:text-ink-500;
|
||||
}
|
||||
|
||||
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
|
||||
.modal-overlay-enter {
|
||||
animation: modal-fade 0.18s ease-out both;
|
||||
}
|
||||
.modal-panel-enter {
|
||||
animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes modal-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes modal-rise {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── 不确定态进度条(indeterminate) ──
|
||||
用于「耗时未知」的等待场景(如多专家预测)。与确定性进度条的区别
|
||||
是它不表达百分比,只表达「仍在进行」—— 因此不存在「卡在 95%」
|
||||
这种误导。transform-only,GPU 友好。 */
|
||||
.predict-scan {
|
||||
animation: predict-scan 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@keyframes predict-scan {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ── 统一空态 ── */
|
||||
.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 {
|
||||
@@ -102,6 +246,22 @@
|
||||
.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: var(--press);
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Admin 后台 ──
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 日期工具(纯函数,零依赖)。
|
||||
*
|
||||
* 从 `pages/matches/ui.tsx` 抽出 —— 它们原本与 UI 组件混在一个文件里,
|
||||
* 但并不是组件,而是可独立测试的纯函数。
|
||||
*/
|
||||
|
||||
/** 日期 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')}`
|
||||
}
|
||||
|
||||
/** 未来第 n 天的日期 key */
|
||||
function addDays(d: Date, n: number): string {
|
||||
const x = new Date(d)
|
||||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||
return dateKey(x)
|
||||
}
|
||||
|
||||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||
export function toLocalDateKey(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return dateKey(d)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明日/周几 · 年月日 */
|
||||
export function formatDateHeader(dateKeyStr: string): string {
|
||||
if (!dateKeyStr) return '未开赛'
|
||||
const d = new Date(dateKeyStr + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKeyStr
|
||||
const today = new Date()
|
||||
const todayKey = dateKey(today)
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = dateKey(tmr)
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKeyStr === todayKey) return `今日 ${weekday}`
|
||||
if (dateKeyStr === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本地日期分组(非 UTC),保持时间序。
|
||||
*
|
||||
* 泛型约束 `{ match_date: string }` 而非具体业务类型 —— 使本工具
|
||||
* 与 Matches 页面解耦,任何带日期字段的列表都能复用。
|
||||
*/
|
||||
export function groupByDate<T extends { match_date: string }>(list: T[]): Array<[string, T[]]> {
|
||||
const map = new Map<string, T[]>()
|
||||
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()]
|
||||
}
|
||||
|
||||
/** 比赛是否在未来 3 天内(用于默认视图过滤):今天 00:00 → 第 3 天 00:00 */
|
||||
export function withinNext3Days(matchDate: string): boolean {
|
||||
const key = toLocalDateKey(matchDate)
|
||||
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 lastUrl = ''
|
||||
let lastInit: RequestInit | undefined
|
||||
globalThis.fetch = async (url: string | URL | Request, init?: RequestInit) => {
|
||||
lastUrl = String(url)
|
||||
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)
|
||||
})
|
||||
|
||||
// ── 健康检查豁免: /health* 挂在根路径(后端 app.py 不在 /api/v1 下),
|
||||
// 加前缀会 404 → 管理后台右上角永远「系统异常」 ──
|
||||
test('build_url: /health 与 /health/ready 不加 /api/v1 前缀', async () => {
|
||||
await http.get('/health')
|
||||
assert.equal(lastUrl, '/health')
|
||||
await http.get('/health/ready')
|
||||
assert.equal(lastUrl, '/health/ready')
|
||||
})
|
||||
|
||||
test('build_url: 常规 API 路径仍统一加 /api/v1', async () => {
|
||||
await http.get('/admin/stats')
|
||||
assert.equal(lastUrl, '/api/v1/admin/stats')
|
||||
await http.get('/matches')
|
||||
assert.equal(lastUrl, '/api/v1/matches')
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 共享 HTTP 客户端(公开站 + Admin 统一)
|
||||
*
|
||||
* 特性:
|
||||
* - 统一超时(默认 30s,可覆盖)
|
||||
* - 统一错误处理(ApiError)
|
||||
* - 401 自动广播(Admin 场景)
|
||||
* - JSON/HTML 容错解析
|
||||
* - 请求竞态防护(可选 signal)
|
||||
*
|
||||
* 注: src/api/api.ts(曾位于 admin/api.ts)是本模块的薄门面,不再有第二套实现。
|
||||
*/
|
||||
|
||||
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
||||
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
/** 健康检查端点挂在根路径(app.py 不在 /api/v1 下,与 compose healthcheck 一致);
|
||||
* 加前缀会 404,导致管理后台健康状态永远显示「系统异常」 */
|
||||
const ROOT_ONLY_PREFIXES = ['/health']
|
||||
|
||||
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
|
||||
function build_url(path: string): string {
|
||||
if (path.startsWith('http')) return path
|
||||
if (ROOT_ONLY_PREFIXES.some(p => path === p || path.startsWith(`${p}/`))) return path
|
||||
if (path.startsWith(API_BASE)) return path
|
||||
if (path.startsWith('/')) return `${API_BASE}${path}`
|
||||
return `${API_BASE}/${path}`
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data?: unknown
|
||||
|
||||
constructor(message: string, status: number, data?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
// 显式赋值而非构造函数参数属性(public x):strip-types 不支持后者,
|
||||
// 会让 node --experimental-strip-types 跑 lib/http.test.ts 直接失败
|
||||
this.status = status
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
timeoutMs?: number
|
||||
skipAuthHandling?: boolean
|
||||
signal?: AbortSignal
|
||||
method?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const url = build_url(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' }),
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 滚动相关 hooks —— 统一全站的滚动监听。
|
||||
*
|
||||
* 背景:此前滚动监听散落三处,各写一遍 addEventListener /
|
||||
* removeEventListener / passive / 初始调用:
|
||||
* · components/BackTop.tsx —— 监听 window,判断是否显示回到顶部
|
||||
* · pages/Matches.tsx —— 监听联赛 tab 容器,判断能否右滚
|
||||
* · admin/AdminLayout.tsx —— 关闭移动端抽屉时锁滚动
|
||||
*
|
||||
* 三份重复的生命周期管理,任何一处漏了 cleanup 就是内存泄漏;
|
||||
* 且 `{ passive: true }` 这个纯收益的优化只有两处记得加。
|
||||
*
|
||||
* 这里抽成两个 hook,把「监听 → 清理」的模式收敛为单一实现。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 监听 window 纵向滚动位置。
|
||||
*
|
||||
* @returns 当前 scrollY(节流到动画帧,避免滚动过程中高频 setState)
|
||||
*/
|
||||
export function useWindowScrollY(): number {
|
||||
const [y, setY] = useState(() =>
|
||||
typeof window === 'undefined' ? 0 : window.scrollY,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false
|
||||
const onScroll = () => {
|
||||
// rAF 节流:滚动事件可能每帧触发多次,直接 setState 会造成
|
||||
// 大量无意义的重渲染
|
||||
if (ticking) return
|
||||
ticking = true
|
||||
requestAnimationFrame(() => {
|
||||
setY(window.scrollY)
|
||||
ticking = false
|
||||
})
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
onScroll() // 初始同步:刷新后可能已在页面中部
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听某个可滚动元素是否还能继续向右滚动。
|
||||
*
|
||||
* 用于横向溢出的导航条:能右滚时在右缘显示渐隐提示,提示用户
|
||||
* 「右边还有内容」。同时监听 window resize —— 视口变宽后可能
|
||||
* 就不再溢出了,不重新计算会留下错误的提示。
|
||||
*
|
||||
* @param ref 目标滚动容器
|
||||
* @param tolerance 容差(px),避免亚像素误差导致提示闪烁
|
||||
* @param deps 触发重新测量的依赖(如列表内容变化时)
|
||||
*/
|
||||
export function useCanScrollRight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
tolerance = 8,
|
||||
deps: unknown[] = [],
|
||||
): boolean {
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const update = () => {
|
||||
setCanScroll(el.scrollWidth - el.scrollLeft - el.clientWidth > tolerance)
|
||||
}
|
||||
|
||||
update() // 初次测量
|
||||
el.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, tolerance, ...deps])
|
||||
|
||||
return canScroll
|
||||
}
|
||||
+209
-1158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 主站 - 联赛积分榜页
|
||||
*
|
||||
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import BackTop from '../components/BackTop'
|
||||
import { fetchStandings } from '../api/public'
|
||||
import type { StandingsLeague, StandingRow } from '../api/public'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
import { Spinner, Tabs, Button } from '../components/ui'
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
// 欧战资格
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
// 升级
|
||||
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||||
// 降级
|
||||
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-playoff-100 text-playoff-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||||
// 附加赛
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-euro-100 text-euro-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 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-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 ${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 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">
|
||||
{/* 联赛切换:与首页/后台共用 .tab 语言(方角、宋体、印报红下划线) */}
|
||||
<Tabs
|
||||
ariaLabel="联赛"
|
||||
className="gap-6 border-b border-ink-900"
|
||||
value={activeLeague}
|
||||
onChange={switchLeague}
|
||||
disabled={switching}
|
||||
items={leagues.map(l => ({
|
||||
value: l.code,
|
||||
label: l.name,
|
||||
// 无数据也可选中(会显示空态),仅做视觉弱化提示
|
||||
empty: !standings.some(s => s.league_code === l.code),
|
||||
title: standings.some(s => s.league_code === l.code) ? undefined : '暂无积分榜数据',
|
||||
}))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
/* role=alert:错误是异步出现的,需立即被屏幕阅读器朗读 */
|
||||
<div className="error-banner" role="alert">
|
||||
<div>
|
||||
<p className="error-banner-title">请求失败</p>
|
||||
<p className="error-banner-detail">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setError(null)}
|
||||
className="!border-transparent !px-1 text-lg leading-none"
|
||||
aria-label="关闭错误提示"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</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-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
|
||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||
</td>
|
||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 回到顶部(共享组件,方角纸片风) */}
|
||||
<BackTop />
|
||||
</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,358 @@
|
||||
/**
|
||||
* MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内
|
||||
* group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致;
|
||||
* 展开懒加载的 state 仍由页面持有。
|
||||
*/
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { Button, Spinner } from '../../../components/ui'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../api/types'
|
||||
import type { MatchStatsDetail } from '../../../api/types'
|
||||
import { STATUS_META } from '../types'
|
||||
import type { Match } from '../types'
|
||||
|
||||
/** 比赛详情面板:双方近况/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}
|
||||
variant={busy ? 'default' : 'solid'}
|
||||
className="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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
|
||||
*
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { Button, Modal, Spinner } from '../../../components/ui'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS } from '../types'
|
||||
import { AgentsPanel } from './AgentsPanel'
|
||||
import { OutcomePanel } from './OutcomePanel'
|
||||
import { ReasoningPanel } from './ReasoningPanel'
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
/**
|
||||
* 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} />}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待态过程显示。
|
||||
*
|
||||
* 这里**刻意不画进度条**。
|
||||
*
|
||||
* 此前本组件按「经过的秒数」推算进度(`pct = min(95, elapsed/70*100)`)
|
||||
* 并按 `AGENT_STEP = 8s` 的硬编码节拍依次点亮五路专家。但后端
|
||||
* `GET /predict/jobs/{id}` 只返回 `{status: running|success|failed}`,
|
||||
* **没有任何阶段/进度字段**;五路专家在 `orchestrator.py` 里是
|
||||
* `asyncio.gather` 并行跑的,谁先返回无从得知。
|
||||
*
|
||||
* 于是那个进度条与后端真实状态零关联:它会在 70 秒时永远卡在 95%
|
||||
* 不再前进,也会把已经跑完的专家继续显示成「分析中」。给出一个
|
||||
* 精确到百分比的假进度,比不给进度更糟 —— 用户会用「已经 95% 了」
|
||||
* 来推断还要等多久,而那个数字是编的。
|
||||
*
|
||||
* 现在的做法:只呈现真实可观测的量。
|
||||
* - 已等待时长(本地真实计时)
|
||||
* - 五路专家「等待中 / 分析中」的并行真相(同时只有一路在跑)
|
||||
* - 一条不确定态扫描动画(明确表达「进度未知」,而非假装知道)
|
||||
*/
|
||||
function PredictProgress() {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setElapsed(e => e + 1), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// 与后端 SPECIALIST_SPECS 一致的五路专家(见 src/llm/agents/orchestrator.py)。
|
||||
// 顺序仅影响展示,不代表执行先后 —— 它们是并行执行的。
|
||||
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||
|
||||
// 当前严格串行执行的一路(后端为并发 + 全局信号量,故同一时刻只有一路在调用)
|
||||
const activeIdx = Math.floor(elapsed / 6) % agents.length
|
||||
|
||||
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">
|
||||
五路专家分析中
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400" aria-live="polite">
|
||||
已等待 {elapsed}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
不确定态进度条(indeterminate)。
|
||||
aria-valuetext 明确告知辅助技术「进度未知」,避免屏幕阅读器
|
||||
把一个无意义的动画读成百分比进度。
|
||||
*/}
|
||||
<div
|
||||
className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100"
|
||||
role="progressbar"
|
||||
aria-label="预测进行中,进度未知"
|
||||
aria-valuetext="预测进行中,完成时间未知"
|
||||
>
|
||||
<div className="predict-scan h-full w-1/3 bg-press" />
|
||||
</div>
|
||||
|
||||
{/* 专家列表:并行真相 —— 同时只有一路「分析中」 */}
|
||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||
{agents.map((a, i) => {
|
||||
const isActive = i === activeIdx
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
isActive ? '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 ${isActive ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="text-2xs text-press">分析中…</span>
|
||||
) : (
|
||||
<span className="text-2xs text-ink-300">等待中</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
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
label={`预测 ${homeName} 对 ${awayName}`}
|
||||
overlayClassName="z-50 overflow-y-auto sm:items-center"
|
||||
panelClassName="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50"
|
||||
>
|
||||
{/* 弹窗报头 */}
|
||||
<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 variant="default" size="sm" className="mt-6" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
) : prediction ? (
|
||||
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user