Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476156524f | ||
|
|
2190ea2af6 | ||
|
|
1a37c75907 |
@@ -1,37 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.egg-info
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
|
|
||||||
# 虚拟环境
|
|
||||||
.venv
|
|
||||||
venv
|
|
||||||
|
|
||||||
# 环境配置(含敏感信息,绝不能打入镜像)
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# 测试与工具
|
|
||||||
tests
|
|
||||||
.pytest_cache
|
|
||||||
.mypy_cache
|
|
||||||
.ruff_cache
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# 文档(运行时不需要)
|
|
||||||
docs
|
|
||||||
*.md
|
|
||||||
LICENSE*
|
|
||||||
.dockerignore
|
|
||||||
docker-compose.yml
|
|
||||||
nginx.conf
|
|
||||||
+4
-35
@@ -1,33 +1,12 @@
|
|||||||
# ---- 应用 ----
|
# ---- 应用 ----
|
||||||
# 运行环境: development | production
|
|
||||||
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
|
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
# 日志持久化:空=仅 stdout + Admin 内存日志页(重启清零);填路径则额外写滚动文件(10MB×5)。
|
|
||||||
# 本地开发示例: LOG_FILE=./logs/app.log (容器内由 compose 默认设为 /app/logs/app.log 并挂卷)
|
|
||||||
LOG_FILE=
|
|
||||||
API_PORT=8000
|
|
||||||
FRONTEND_PORT=3000
|
|
||||||
|
|
||||||
# ---- 生产环境启动必填(缺少则拒绝启动) ----
|
|
||||||
# 1. SECRET_KEY: 加密主密钥与会话签名根密钥。
|
|
||||||
# 生成: openssl rand -base64 32
|
|
||||||
# 严禁使用 changeme/secret/123456 等弱值;变更后已加密配置无法解密。
|
|
||||||
SECRET_KEY=
|
|
||||||
|
|
||||||
# 2. 管理鉴权(至少一项):
|
|
||||||
# - ADMIN_PASSWORD: 后台登录初始密码(启动后自动 scrypt 哈希入库,之后在「系统配置」页修改)
|
|
||||||
# - ADMIN_API_KEY: 脚本直连接口用的密钥(请求头 X-API-Key)
|
|
||||||
# 两者都留空 = 不启用鉴权(仅本地开发)。
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
ADMIN_API_KEY=
|
|
||||||
|
|
||||||
# ---- 数据库 ----
|
# ---- 数据库 ----
|
||||||
POSTGRES_USER=football
|
POSTGRES_USER=football
|
||||||
POSTGRES_PASSWORD=football
|
POSTGRES_PASSWORD=football
|
||||||
POSTGRES_DB=football
|
POSTGRES_DB=football
|
||||||
POSTGRES_PORT=5433
|
POSTGRES_PORT=5432
|
||||||
# 本地开发用 localhost;Docker Compose 内会被 environment 覆盖为 postgres 服务名
|
|
||||||
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
DATABASE_URL=postgresql+asyncpg://football:football@localhost:5432/football
|
||||||
|
|
||||||
# ---- LLM (OpenAI-compatible,必填一个) ----
|
# ---- LLM (OpenAI-compatible,必填一个) ----
|
||||||
@@ -43,22 +22,12 @@ LLM_TIMEOUT=60
|
|||||||
|
|
||||||
# ---- 数据源 ----
|
# ---- 数据源 ----
|
||||||
BZZOIRO_KEY=
|
BZZOIRO_KEY=
|
||||||
|
API_FOOTBALL_KEY=
|
||||||
|
|
||||||
# ---- CORS ----
|
# ---- CORS ----
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
# ---- 加密主密钥 ----
|
|
||||||
# 敏感配置(数据源/LLM API Key)入库加密与会话签名均由它派生。
|
|
||||||
# 只存部署机 .env,切勿入库或提交;生成: openssl rand -base64 32
|
|
||||||
# 变更后已加密配置无法解密,需在后台重新保存。
|
|
||||||
SECRET_KEY=
|
|
||||||
|
|
||||||
# ---- 管理接口鉴权 ----
|
# ---- 管理接口鉴权 ----
|
||||||
# 管理后台登录密码(/admin 页面与采集/回测/回填等管理接口)。
|
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。
|
||||||
# 此处为初始值:启动时自动迁移为 scrypt 哈希入库,迁移后本行可删除。
|
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值。
|
||||||
# 之后请在后台「系统配置」页修改密码。留空 = 不启用密码登录(本地开发默认)。
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
# 管理接口会话有效期(小时),默认 7 天
|
|
||||||
ADMIN_SESSION_TTL_HOURS=168
|
|
||||||
# 备选: 机器/脚本直接调接口用的密钥(请求头 X-API-Key),与密码二选一即可
|
|
||||||
ADMIN_API_KEY=
|
ADMIN_API_KEY=
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
logs/
|
|
||||||
|
|
||||||
# AI 助手上下文文件(不入库)
|
# AI 助手上下文文件(不入库)
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
__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 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
|
||||||
# 自定义规则写在下方(本行提示可删除)
|
|
||||||
+4
-25
@@ -2,33 +2,12 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 直连官方源不稳定,固定使用清华 PyPI 镜像
|
RUN pip install --no-cache-dir hatchling
|
||||||
ENV PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
COPY pyproject.toml README.md ./
|
||||||
|
|
||||||
# P2-5: 创建非 root 用户(容器安全最佳实践)
|
|
||||||
RUN groupadd --system profeto && useradd --system --gid profeto profeto
|
|
||||||
|
|
||||||
# 可复现构建:先安装锁定版本的依赖(含哈希校验),再安装本项目
|
|
||||||
COPY pyproject.toml requirements.txt ./
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
|
|
||||||
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
|
|
||||||
# 非 root 进程写日志会 PermissionError(Errno 13)。
|
|
||||||
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
|
|
||||||
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
|
|
||||||
RUN mkdir -p /app/logs
|
|
||||||
|
|
||||||
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
|
|
||||||
RUN chown -R profeto:profeto /app
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 以非 root 用户运行
|
CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
USER profeto
|
|
||||||
|
|
||||||
# 启动时先跑迁移,再启 uvicorn
|
|
||||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000"]
|
|
||||||
|
|||||||
@@ -12,30 +12,28 @@
|
|||||||
│ REST API
|
│ REST API
|
||||||
┌──────────────────────▼──────────────────────────────┐
|
┌──────────────────────▼──────────────────────────────┐
|
||||||
│ FastAPI │
|
│ FastAPI │
|
||||||
│ ├── /api/v1/matches 比赛查询(公开只读) │
|
│ ├── /api/v1/matches 比赛查询 │
|
||||||
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
|
│ ├── /api/v1/predict LLM 预测 (单/多 Agent) │
|
||||||
│ ├── /api/v1/ingest/* 数据采集(需管理员) │
|
│ ├── /api/v1/ingest/* 数据采集 │
|
||||||
│ ├── /api/v1/eval/* 评估回填(需管理员) │
|
│ ├── /api/v1/eval/* 评估回填 │
|
||||||
│ └── /api/v1/backtest 回测(需管理员) │
|
│ └── /api/v1/backtest 回测 │
|
||||||
└──────────┬─────────────────────────────┬────────────┘
|
└──────────┬─────────────────────────────┬────────────┘
|
||||||
│ │
|
│ │
|
||||||
┌──────────▼──────────┐ ┌─────────────▼────────────┐
|
┌──────────▼──────────┐ ┌─────────────▼────────────┐
|
||||||
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
|
│ PostgreSQL │ │ LLM (OpenAI-compatible) │
|
||||||
│ 12 张表 │ │ OpenAI / Deepseek / │
|
│ 6 张表 │ │ OpenAI / Deepseek / │
|
||||||
│ leagues/teams/ │ │ Ollama / 任意网关 │
|
│ leagues/teams/ │ │ Ollama / 任意网关 │
|
||||||
│ matches/match_ │ └──────────────────────────┘
|
│ matches/match_ │ └──────────────────────────┘
|
||||||
│ stats/standings/ │
|
│ stats/predictions/ │
|
||||||
│ predictions/ │
|
│ injuries │
|
||||||
│ app_settings/ │
|
|
||||||
│ schedules + │
|
|
||||||
│ raw_events 等 4 张 │
|
|
||||||
│ 数据治理表 │
|
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
▲
|
▲
|
||||||
│ 采集
|
│ 采集
|
||||||
┌──────────┴─────────────────────────────────────────┐
|
┌──────────┴─────────────────────────────────────────┐
|
||||||
│ 数据源 (DataSource 协议 + 注册表) │
|
│ 数据源 (DataSource 协议 + 注册表) │
|
||||||
│ └── bzzoiro 比分 / 赛程 / 统计 / 积分榜 │
|
│ ├── bzzoiro 比分 / 统计 / xG │
|
||||||
|
│ ├── understat xG 回填 │
|
||||||
|
│ └── injuries 伤停数据 (api-football) │
|
||||||
└────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +55,7 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
|||||||
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
|
比赛数据 → 切片 ─┬─→ A 近期状态专家 ─┐
|
||||||
├─→ B 攻防数据专家 ─┤
|
├─→ B 攻防数据专家 ─┤
|
||||||
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
|
├─→ C 主客因素专家 ─┼─→ 终裁专家 ─→ 最终预测
|
||||||
├─→ D 联赛排名专家 ─┤
|
├─→ D 阵容完整专家 ─┤
|
||||||
└─→ E 历史交锋专家 ─┘
|
└─→ E 历史交锋专家 ─┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -68,7 +66,8 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
|||||||
|
|
||||||
### 数据正确性保障
|
### 数据正确性保障
|
||||||
|
|
||||||
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据(近况/交锋/统计/积分榜切片统一生效)
|
- **Cutoff 机制**: 回测时只使用 `cutoff_at` 之前已采集的数据
|
||||||
|
- **Injury 防泄漏**: 伤停查询强制 `retrieved_at <= cutoff`
|
||||||
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
|
- **LLM 输出校验**: Pydantic 严格校验 + 语义一致性检查
|
||||||
- **数据库约束**: CHECK 约束作为最后一道防线
|
- **数据库约束**: CHECK 约束作为最后一道防线
|
||||||
|
|
||||||
@@ -80,106 +79,55 @@ API Route → Application Service → Repository → UnitOfWork → DB
|
|||||||
- Docker (运行 PostgreSQL)
|
- Docker (运行 PostgreSQL)
|
||||||
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
- LLM API Key (OpenAI / Deepseek / Ollama 等)
|
||||||
|
|
||||||
### 方式一:Docker Compose 部署(推荐)
|
### 1. 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆仓库
|
|
||||||
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
||||||
cd Profeto
|
cd Profeto
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 后端依赖
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
|
||||||
|
|
||||||
# 3. 启动全部服务(自动构建 + 执行迁移)
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# 4. 验证
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
|
||||||
|
|
||||||
启动后访问:
|
|
||||||
- API 文档: http://localhost:8000/docs
|
|
||||||
- 前端界面: http://localhost:3000
|
|
||||||
|
|
||||||
> **说明**: `api` 容器启动时自动执行 `alembic upgrade head`,无需手动运行迁移。
|
|
||||||
|
|
||||||
### 方式二:本地开发部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 克隆 + 安装
|
|
||||||
git clone https://git.bilidili.cn/shangfangjian/Profeto.git
|
|
||||||
cd Profeto
|
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
# 2. 配置环境变量
|
# 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
# 编辑 .env,填入 LLM_API_KEY 和 BZZOIRO_KEY
|
||||||
|
```
|
||||||
|
|
||||||
# 3. 启动 PostgreSQL
|
### 2. 启动数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
|
alembic upgrade head # 首次运行需要执行迁移
|
||||||
|
```
|
||||||
|
|
||||||
# 4. 执行迁移
|
### 3. 启动服务
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# 5. 启动后端 (终端 1)
|
```bash
|
||||||
|
# 后端 (终端 1)
|
||||||
uvicorn src.api.app:app --reload
|
uvicorn src.api.app:app --reload
|
||||||
|
|
||||||
# 6. 启动前端 (终端 2)
|
# 前端 (终端 2)
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`。
|
后端运行在 `http://localhost:8000`,前端在 `http://localhost:5173`。
|
||||||
|
|
||||||
## 生产上线检查清单
|
|
||||||
|
|
||||||
公网部署前逐项确认(第 1–4 项由启动校验强制,不满足拒绝启动;详见 [docs/06-deployment.md](docs/06-deployment.md#生产上线检查清单)):
|
|
||||||
|
|
||||||
- [ ] `APP_ENV=production`(安全校验 / Cookie `Secure` / 管理端点 fail-closed 的总开关)
|
|
||||||
- [ ] `SECRET_KEY` 强随机:`openssl rand -base64 32`,禁止弱值
|
|
||||||
- [ ] `ADMIN_PASSWORD` 或 `ADMIN_API_KEY` 至少配置其一
|
|
||||||
- [ ] 数据库强密码,禁止 `football:football` 等示例弱密码
|
|
||||||
- [ ] HTTPS(反代终结 TLS;production 下会话 Cookie 自动 `Secure`)
|
|
||||||
- [ ] 反代后设 `TRUST_PROXY_HEADERS=True`,仅可信反代可达 API,并配置 `X-Forwarded-For` / `X-Real-IP`
|
|
||||||
- [ ] 限流前置到 Nginx `limit_req`;应用内限流与 KeyRing 仅单进程有效,多 worker 会放大配额
|
|
||||||
- [ ] uvicorn 单 worker(默认);需扩容先网关统一限流再起多实例
|
|
||||||
- [ ] 启动后验证 `/health` 与 `/health/ready` 均 200
|
|
||||||
- [ ] 数据库迁移已内置:compose/Dockerfile 启动即执行 `alembic upgrade head`
|
|
||||||
|
|
||||||
## 安全与限流
|
|
||||||
|
|
||||||
- `/api/v1/predict`: 内存滑动窗口限流(10 次/分钟/IP),多 worker 时每进程独立计数
|
|
||||||
- 登录防爆破: 进程内内存计数,同上
|
|
||||||
- 公网部署建议 Nginx 层限流 + `TRUST_PROXY_HEADERS=True`
|
|
||||||
- 生产环境必须配置 `ADMIN_PASSWORD` 或 `ADMIN_API_KEY`(否则管理接口 503)
|
|
||||||
|
|
||||||
详见 [docs/06-deployment.md](docs/06-deployment.md#安全与限流)。
|
|
||||||
|
|
||||||
## API 概览
|
## API 概览
|
||||||
|
|
||||||
**公开只读**(无需登录;`predict` 带内存限流):
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/v1/leagues` | 联赛列表(仅 id/code/name/country) |
|
| GET | `/api/v1/matches` | 比赛查询(筛选/分页) |
|
||||||
| GET | `/api/v1/matches` | 比赛查询(筛选/游标分页) |
|
| GET | `/api/v1/leagues` | 联赛列表 |
|
||||||
| GET | `/api/v1/matches/{id}` | 比赛详情(含统计与最近预测) |
|
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`) |
|
||||||
| GET | `/api/v1/matches/{id}/context` | 比赛上下文(双方近况 + 历史交锋) |
|
| GET | `/api/v1/predictions` | 预测历史 |
|
||||||
| GET | `/api/v1/standings` | 联赛积分榜 |
|
| POST | `/api/v1/ingest/bzzoiro` | 采集比分/统计 |
|
||||||
| POST | `/api/v1/predict` | LLM 预测 (`mode=single`/`multi`/`baseline`) |
|
| POST | `/api/v1/ingest/understat` | 回填 xG |
|
||||||
| GET | `/health`、`/health/ready` | 存活 / 就绪检查(含 DB) |
|
| POST | `/api/v1/ingest/injuries` | 采集伤停 |
|
||||||
|
|
||||||
**需管理员**(Cookie 会话或 `X-API-Key`):
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| POST | `/api/v1/ingest/bzzoiro` | 采集赛果/赛程/统计/积分榜 |
|
|
||||||
| GET | `/api/v1/predictions` | 预测历史(列表) |
|
|
||||||
| GET | `/api/v1/predictions/{id}` | 单条预测详情 |
|
|
||||||
| POST | `/api/v1/eval/settle` | 回填实际结果 |
|
| POST | `/api/v1/eval/settle` | 回填实际结果 |
|
||||||
| GET | `/api/v1/eval/summary` | 准确率汇总 |
|
| GET | `/api/v1/eval/summary` | 准确率汇总 |
|
||||||
| POST | `/api/v1/backtest` | 历史回测 |
|
| POST | `/api/v1/backtest` | 历史回测 |
|
||||||
| `/api/v1/admin/**` | 配置/采集状态/日志/定时任务/死信等 | 管理后台(router 级鉴权) |
|
| GET | `/health` | 存活检查 |
|
||||||
|
| GET | `/health/ready` | 就绪检查(含 DB) |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -188,46 +136,36 @@ Profeto/
|
|||||||
├── src/
|
├── src/
|
||||||
│ ├── api/ # FastAPI 路由层
|
│ ├── api/ # FastAPI 路由层
|
||||||
│ │ ├── app.py # 应用工厂 + lifespan
|
│ │ ├── app.py # 应用工厂 + lifespan
|
||||||
│ │ ├── deps.py # 依赖注入:鉴权 / 限流
|
|
||||||
│ │ ├── schemas.py # Pydantic 请求/响应模型
|
│ │ ├── schemas.py # Pydantic 请求/响应模型
|
||||||
│ │ └── routes/
|
│ │ └── routes/
|
||||||
│ │ ├── matches.py # 比赛查询(公开只读)
|
│ │ ├── matches.py # 比赛查询
|
||||||
│ │ ├── predict.py # 预测入口 + 预测历史
|
│ │ ├── predict.py # 预测入口
|
||||||
│ │ ├── ingest.py # 数据采集(需管理员)
|
│ │ ├── ingest.py # 数据采集
|
||||||
│ │ ├── eval.py # 评估回填(需管理员)
|
│ │ ├── eval.py # 评估回填
|
||||||
│ │ ├── backtest.py # 回测(需管理员)
|
│ │ └── backtest.py # 回测
|
||||||
│ │ ├── auth.py # 登录/登出/改密
|
|
||||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
|
||||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
|
||||||
│ ├── core/ # 基础设施
|
│ ├── core/ # 基础设施
|
||||||
│ │ ├── config.py # pydantic-settings 配置
|
│ │ ├── config.py # pydantic-settings 配置
|
||||||
│ │ ├── crypto.py # 加密/哈希
|
|
||||||
│ │ ├── http_client.py # 共享 httpx 客户端
|
│ │ ├── http_client.py # 共享 httpx 客户端
|
||||||
│ │ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
|
│ │ └── retry.py # 重试工具(指数退避)
|
||||||
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
|
|
||||||
│ │ ├── scheduler.py # 进程内 cron 调度器
|
|
||||||
│ │ └── security_check.py # 启动安全校验
|
|
||||||
│ ├── data/ # 数据层
|
│ ├── data/ # 数据层
|
||||||
│ │ ├── sources.py # DataSource 协议 + 注册表
|
│ │ ├── sources.py # DataSource 协议 + 注册表
|
||||||
│ │ ├── bzzoiro.py # bzzoiro 数据源(events/standings/stats)
|
|
||||||
│ │ ├── normalize.py # 数据规范化契约
|
│ │ ├── normalize.py # 数据规范化契约
|
||||||
|
│ │ ├── bzzoiro.py # bzzoiro 数据源
|
||||||
|
│ │ ├── understat.py # understat xG 数据源
|
||||||
|
│ │ ├── injuries.py # 伤停数据
|
||||||
│ │ ├── config.py # 联赛映射常量
|
│ │ ├── config.py # 联赛映射常量
|
||||||
│ │ ├── key_ring.py # API Key 轮换环(429 冷却)
|
│ │ └── team_names.py # 队名归一化
|
||||||
│ │ ├── team_names.py # 队名归一化
|
|
||||||
│ │ └── team_names_zh.py # 队名中文映射
|
|
||||||
│ ├── db/ # 数据库
|
│ ├── db/ # 数据库
|
||||||
│ │ ├── base.py # SQLAlchemy async engine
|
│ │ ├── base.py # SQLAlchemy async engine
|
||||||
│ │ ├── models.py # ORM 模型 (12 表)
|
│ │ ├── models.py # ORM 模型 (6 表)
|
||||||
│ │ ├── unit_of_work.py # UnitOfWork 事务封装
|
│ │ ├── unit_of_work.py # UnitOfWork 事务封装
|
||||||
│ │ └── repositories.py # Repository 数据访问
|
│ │ └── repositories.py # Repository 数据访问
|
||||||
│ └── llm/ # LLM 预测核心
|
│ └── llm/ # LLM 预测核心
|
||||||
│ ├── predict.py # 预测服务 (缓存 + 单/多/基线模式)
|
│ ├── predict.py # 预测服务 (缓存 + 单/多模式)
|
||||||
│ ├── context_builder.py # 数据切片 + 上下文拼接
|
│ ├── context_builder.py # 数据切片 + 上下文拼接
|
||||||
│ ├── baseline.py # 基线预测(均值模型)
|
|
||||||
│ ├── eval.py # 评估统计
|
│ ├── eval.py # 评估统计
|
||||||
│ ├── backtest.py # 回测框架
|
│ ├── backtest.py # 回测框架
|
||||||
│ ├── provider.py # 多提供商 LLM 抽象
|
│ ├── provider.py # 多提供商 LLM 抽象
|
||||||
│ ├── utils.py # LLM 工具函数
|
|
||||||
│ ├── validation.py # LLM 输出校验
|
│ ├── validation.py # LLM 输出校验
|
||||||
│ ├── agents/
|
│ ├── agents/
|
||||||
│ │ ├── base.py # Agent 基础设施 + 解析
|
│ │ ├── base.py # Agent 基础设施 + 解析
|
||||||
@@ -235,11 +173,6 @@ Profeto/
|
|||||||
│ └── prompts/ # Prompt 模板
|
│ └── prompts/ # Prompt 模板
|
||||||
├── alembic/ # 数据库迁移
|
├── alembic/ # 数据库迁移
|
||||||
├── frontend/ # React 前端
|
├── frontend/ # React 前端
|
||||||
│ └── src/
|
|
||||||
│ ├── pages/ # 公开站(赛程 Matches + 积分榜 Standings)
|
|
||||||
│ ├── admin/ # 管理后台(布局/页面/数据访问层 dal.ts)
|
|
||||||
│ ├── components/ # 共享组件
|
|
||||||
│ └── lib/http.ts # 唯一 HTTP 实现(带凭据/超时/错误处理)
|
|
||||||
├── docs/ # 详细文档
|
├── docs/ # 详细文档
|
||||||
├── tests/ # 单元测试
|
├── tests/ # 单元测试
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
@@ -274,6 +207,7 @@ Profeto/
|
|||||||
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
|
| `LLM_SPECIALIST_MODEL` | 专家模型 (空=回落 LLM_MODEL) | |
|
||||||
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
|
| `LLM_AGGREGATOR_MODEL` | 终裁模型 (空=回落 LLM_MODEL) | |
|
||||||
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
|
| `BZZOIRO_KEY` | bzzoiro API Key | *(必填)* |
|
||||||
|
| `API_FOOTBALL_KEY` | api-football Key (伤停) | |
|
||||||
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
|
| `CORS_ORIGINS` | 允许的跨域来源 | `http://localhost:5173` |
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|||||||
@@ -70,11 +70,6 @@ def run_migrations_online() -> None:
|
|||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
_ensure_version_table(connection)
|
_ensure_version_table(connection)
|
||||||
# SQLAlchemy 2.0 autobegin:上面的探测 SELECT 会留下隐式事务。
|
|
||||||
# 若不结束,alembic(>=1.16)会判定处于「外部事务」而全程不提交,
|
|
||||||
# 迁移在连接关闭时被静默回滚(upgrade 退出码仍为 0)。
|
|
||||||
if connection.in_transaction():
|
|
||||||
connection.commit()
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
"""新增 app_settings 表(后台运行时配置)
|
|
||||||
|
|
||||||
Revision ID: 0010_app_settings
|
|
||||||
Revises: 0009_match_stats_xg_fields
|
|
||||||
Create Date: 2026-09-18
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = '0010_app_settings'
|
|
||||||
down_revision: Union[str, None] = '0009_match_stats_xg_fields'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'app_settings',
|
|
||||||
sa.Column('key', sa.String(100), primary_key=True),
|
|
||||||
sa.Column('value', sa.Text(), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('app_settings')
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""predictions 增加备选比分字段
|
|
||||||
|
|
||||||
Revision ID: 0011_prediction_alt_scores
|
|
||||||
Revises: 0010_app_settings
|
|
||||||
Create Date: 2026-09-19
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision: str = '0011_prediction_alt_scores'
|
|
||||||
down_revision: Union[str, None] = '0010_app_settings'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column('predictions', sa.Column('alt_pred_home_goals', sa.Integer(), nullable=True))
|
|
||||||
op.add_column('predictions', sa.Column('alt_pred_away_goals', sa.Integer(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('predictions', 'alt_pred_away_goals')
|
|
||||||
op.drop_column('predictions', 'alt_pred_home_goals')
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""修复 injuries 唯一索引允许 NULL 重复 + 添加 return_date 字段
|
|
||||||
|
|
||||||
Revision ID: 0012_injuries_partial_unique_and_return_date
|
|
||||||
Revises: 0011_prediction_alt_scores
|
|
||||||
Create Date: 2026-09-20
|
|
||||||
|
|
||||||
Fix 4: 唯一索引 (player_id, fixture_id, injury_type) 三列均可 NULL,
|
|
||||||
PostgreSQL 允许多条 NULL 重复。改为 partial unique index:
|
|
||||||
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
|
|
||||||
|
|
||||||
Fix 2: return_date 字段已在 ORM 声明,确保数据库列存在。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used Alembic.
|
|
||||||
revision: str = '0012_injuries_partial_unique_and_return_date'
|
|
||||||
down_revision: Union[str, None] = '0011_prediction_alt_scores'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
bind = op.get_bind()
|
|
||||||
inspector = sa.inspect(bind)
|
|
||||||
|
|
||||||
# 检查现有索引
|
|
||||||
indexes = {i["name"]: i for i in inspector.get_indexes("injuries")}
|
|
||||||
|
|
||||||
# Fix 4: 删除旧的全局唯一索引(允许 NULL 重复)
|
|
||||||
if "ix_injuries_player_fixture" in indexes:
|
|
||||||
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
|
||||||
|
|
||||||
# 创建 partial unique index: 只在 player_id 和 fixture_id 都非空时强制唯一
|
|
||||||
op.execute(
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX ix_injuries_player_fixture
|
|
||||||
ON injuries (player_id, fixture_id, injury_type)
|
|
||||||
WHERE player_id IS NOT NULL AND fixture_id IS NOT NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fix 2: 确保 return_date 列存在(ORM 已声明,但早期迁移可能缺失)
|
|
||||||
columns = [c["name"] for c in inspector.get_columns("injuries")]
|
|
||||||
if "return_date" not in columns:
|
|
||||||
op.add_column(
|
|
||||||
"injuries",
|
|
||||||
sa.Column("return_date", sa.Date, nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 删除 partial unique index
|
|
||||||
op.drop_index("ix_injuries_player_fixture", table_name="injuries")
|
|
||||||
|
|
||||||
# 恢复旧的全局唯一索引
|
|
||||||
op.create_index(
|
|
||||||
"ix_injuries_player_fixture",
|
|
||||||
"injuries",
|
|
||||||
["player_id", "fixture_id", "injury_type"],
|
|
||||||
unique=True,
|
|
||||||
)
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
"""修复预测唯一约束过粗,增加 mode + run_type 维度
|
|
||||||
|
|
||||||
Revision ID: 0013_predictions_unique_constraint_mode_run_type
|
|
||||||
Revises: 0012_injuries_partial_unique_and_return_date
|
|
||||||
Create Date: 2026-09-20
|
|
||||||
|
|
||||||
背景:
|
|
||||||
原唯一约束 (match_id, provider, model) 过粗,回测写入会覆盖未结算的实盘预测,
|
|
||||||
后续 settle 会污染评估数据。
|
|
||||||
|
|
||||||
修复:
|
|
||||||
1. 新增 run_type 列(默认 'live'),区分实盘与回测
|
|
||||||
2. 唯一约束改为 (match_id, provider, model, mode, run_type)
|
|
||||||
3. 已有数据 run_type 回填为 'live'
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '0013_predictions_unique_constraint_mode_run_type'
|
|
||||||
down_revision: Union[str, None] = '0012_injuries_partial_unique_and_return_date'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 1. 新增 run_type 列(先 nullable,回填后再改 NOT NULL)
|
|
||||||
op.add_column(
|
|
||||||
"predictions",
|
|
||||||
sa.Column("run_type", sa.String(10), nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 回填已有数据:全部标记为 'live'
|
|
||||||
op.execute("UPDATE predictions SET run_type = 'live' WHERE run_type IS NULL")
|
|
||||||
|
|
||||||
# 3. 改为 NOT NULL
|
|
||||||
op.alter_column("predictions", "run_type", nullable=False)
|
|
||||||
|
|
||||||
# 4. 删除旧唯一约束
|
|
||||||
op.drop_constraint("uq_predictions_match_provider_model", "predictions", type_="unique")
|
|
||||||
|
|
||||||
# 5. 创建新唯一约束(包含 mode + run_type)
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"uq_predictions_match_provider_model_mode_run_type",
|
|
||||||
"predictions",
|
|
||||||
["match_id", "provider", "model", "mode", "run_type"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 添加 check constraint
|
|
||||||
op.create_check_constraint(
|
|
||||||
"ck_run_type_enum",
|
|
||||||
"predictions",
|
|
||||||
"run_type IN ('live', 'backtest')",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 1. 删除 check constraint
|
|
||||||
op.drop_constraint("ck_run_type_enum", "predictions", type_="check")
|
|
||||||
|
|
||||||
# 2. 删除新唯一约束
|
|
||||||
op.drop_constraint("uq_predictions_match_provider_model_mode_run_type", "predictions", type_="unique")
|
|
||||||
|
|
||||||
# 3. 恢复旧唯一约束
|
|
||||||
op.create_unique_constraint(
|
|
||||||
"uq_predictions_match_provider_model",
|
|
||||||
"predictions",
|
|
||||||
["match_id", "provider", "model"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 删除 run_type 列
|
|
||||||
op.drop_column("predictions", "run_type")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""为 predictions 表增加 agent_weights 列
|
|
||||||
|
|
||||||
Revision ID: 0014_predictions_agent_weights
|
|
||||||
Revises: 0013_predictions_unique_constraint_mode_run_type
|
|
||||||
Create Date: 2026-09-20
|
|
||||||
|
|
||||||
背景:
|
|
||||||
multi-agent 终裁的 agent_weights 原本只在 raw_response JSON 中,
|
|
||||||
无独立列,评估不便。本迁移增加 agent_weights JSONB 列,可空,旧行保持 NULL。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '0014_predictions_agent_weights'
|
|
||||||
down_revision: Union[str, None] = '0013_predictions_unique_constraint_mode_run_type'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 新增 agent_weights 列(JSONB, 可空, 旧行保持 NULL)
|
|
||||||
op.add_column(
|
|
||||||
"predictions",
|
|
||||||
sa.Column(
|
|
||||||
"agent_weights",
|
|
||||||
sa.dialects.postgresql.JSONB(),
|
|
||||||
nullable=True,
|
|
||||||
comment="multi-agent 终裁各专家权重, 格式: {expert_name: weight}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 删除 agent_weights 列
|
|
||||||
op.drop_column("predictions", "agent_weights")
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""新增 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')
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""扩展 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')",
|
|
||||||
)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""采集任务状态表 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')
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""球队别名表 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')
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""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')
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"""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'],
|
|
||||||
)
|
|
||||||
+3
-23
@@ -17,26 +17,10 @@ services:
|
|||||||
|
|
||||||
api:
|
api:
|
||||||
build: .
|
build: .
|
||||||
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
command: uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload
|
||||||
# Fix 3: 启动时先跑 alembic upgrade,再启 uvicorn
|
|
||||||
command: >
|
|
||||||
sh -c "alembic upgrade head &&
|
|
||||||
uvicorn src.api.app:app --host 0.0.0.0 --port 8000"
|
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
environment:
|
|
||||||
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
|
|
||||||
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
|
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
|
|
||||||
# 日志持久化:写入挂载卷,容器重建不丢(应用内滚动 10MB×6 份封顶)
|
|
||||||
LOG_FILE: /app/logs/app.log
|
|
||||||
env_file: .env
|
env_file: .env
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=5).status==200 else 1)\" || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -44,20 +28,16 @@ services:
|
|||||||
- ./src:/app/src
|
- ./src:/app/src
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
- ./alembic.ini:/app/alembic.ini
|
- ./alembic.ini:/app/alembic.ini
|
||||||
- applogs:/app/logs
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
|
image: nginx:alpine
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.frontend
|
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./frontend/dist:/usr/share/nginx/html
|
||||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
applogs:
|
|
||||||
|
|||||||
+27
-35
@@ -13,15 +13,16 @@
|
|||||||
│ │
|
│ │
|
||||||
│ 数据查询 预测编排 采集(手动/cron 触发) │
|
│ 数据查询 预测编排 采集(手动/cron 触发) │
|
||||||
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
|
│ ┌──────┐ ┌────────────┐ ┌───────────────────┐ │
|
||||||
│ │matches│ │ orchestrator│ │ bzzoiro (唯一源) │ │
|
│ │matches│ │ orchestrator│ │ bzzoiro (赛果) │ │
|
||||||
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
|
│ │leagues│ │ ┌─ 5 专家并行(便宜模型) │ │
|
||||||
│ └──┬───┘ │ │ h2h / form / stats / │ │
|
│ └──┬───┘ │ │ h2h / form / stats / │ │
|
||||||
│ │ │ │ home_away / standings │ │
|
│ │ │ │ home_away / injuries │ │
|
||||||
│ │ │ └─ aggregator 终裁(强模型) │ │
|
│ │ │ └─ aggregator 终裁(强模型) │ │
|
||||||
│ │ └────────────┘ │ events / standings │ │
|
│ │ └────────────┘ └───────────────────┘ │
|
||||||
│ ┌──┴──────────────┴──┐ │ /stats 三条管线 │ │
|
│ │ │ └ understat (xG) │
|
||||||
│ │ PostgreSQL (12 张表)│ └───────────────────┘ │
|
│ ┌──┴──────────────┴──┐ └ injuries (伤停) │
|
||||||
│ └────────────────────┘ httpx → 外部 API │
|
│ │ PostgreSQL (6 张表) │ httpx → 外部 API │
|
||||||
|
│ └────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
1. `POST /predict {match_id}` → orchestrator
|
1. `POST /predict {match_id}` → orchestrator
|
||||||
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
|
2. `load_match_header`: 查比赛 + 双方 + 联赛(一次 eager load)
|
||||||
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
|
3. **5 个专家 agent 并行**(`asyncio.gather`),每个:
|
||||||
- 各自的数据切片函数查库(近况/交锋/积分榜聚合/射门控球/xG)
|
- 各自的数据切片函数查库(近况/交锋/积分榜 SQL 聚合/伤停/xG)
|
||||||
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
|
- 切片无数据 → **跳过 LLM**,直接 `no_data` stub(省 token、防幻觉)
|
||||||
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
|
- 有数据 → 专属 prompt(专家模型,便宜快)→ 结构化 JSON 报告(`home_edge` 方向性评分 + 证据)
|
||||||
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
|
4. **终裁 agent**:5 份报告 + 比赛信息 → 权衡采信度(`agent_weights`)→ 最终预测 JSON
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
|
| **多专家并行而非单次大 prompt** | 每维度独立迭代 prompt;报告可归因(哪个维度分析错了);总延迟 ≈ 2 次串行调用 |
|
||||||
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
|
| **专家/终裁模型分档** | 专家用便宜模型快速分析,终裁用强模型汇总决策,成本与质量平衡(`LLM_SPECIALIST_MODEL` / `LLM_AGGREGATOR_MODEL`) |
|
||||||
| **no_data 门控** | 无数据维度(如积分榜未采集)不调 LLM,终裁知道维度缺失,不编造 |
|
| **no_data 门控** | 无数据维度(如伤停未接入)不调 LLM,终裁知道维度缺失,不编造 |
|
||||||
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
|
| **fail-open** | 单个专家失败只标记 `status=error`,其余照常;研究场景可用性优先 |
|
||||||
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
|
| **`match_date_date` 天级去重** | 不同源时间精度不同,秒级匹配会产生重复行;天级 + 数据库唯一约束 |
|
||||||
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
|
| **积分榜 SQL 聚合 + season 过滤** | `UNION ALL` 主客双视角 + `GROUP BY` 在库内算,只算当前赛季(修复过跨赛季 bug) |
|
||||||
@@ -56,38 +57,33 @@
|
|||||||
Profeto/
|
Profeto/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── app.py # FastAPI 工厂(lifespan:迁移校验/定时任务/生产限流提醒)
|
│ │ ├── app.py # FastAPI 工厂(lifespan 仅验证 DB 连接,不建表)
|
||||||
│ │ ├── deps.py # 依赖:管理接口鉴权(Cookie/X-API-Key)+ 限流
|
│ │ ├── deps.py # 依赖:管理接口鉴权(X-API-Key)
|
||||||
│ │ ├── schemas.py # Pydantic v2 请求/响应
|
│ │ ├── schemas.py # Pydantic v2 请求/响应
|
||||||
│ │ └── routes/
|
│ │ └── routes/
|
||||||
│ │ ├── matches.py # 联赛/比赛/上下文/积分榜(公开只读)
|
│ │ ├── matches.py # 联赛/比赛查询(游标分页)
|
||||||
│ │ ├── predict.py # 预测(限流)+ 预测历史(需鉴权)
|
│ │ ├── predict.py # 预测 + 预测历史
|
||||||
│ │ ├── ingest.py # 采集触发(需鉴权)
|
│ │ ├── ingest.py # 采集触发(自管 session,需鉴权)
|
||||||
│ │ ├── eval.py # 赛后回填 + 准确率汇总(需鉴权)
|
│ │ ├── eval.py # 赛后回填 + 准确率汇总
|
||||||
│ │ ├── backtest.py # 历史回测(需鉴权)
|
│ │ └── backtest.py # 历史回测(需鉴权)
|
||||||
│ │ ├── auth.py # 登录/登出/改密
|
|
||||||
│ │ ├── admin_settings.py # /admin/** 配置/日志/数据质量(router 级鉴权)
|
|
||||||
│ │ └── schedules.py # 定时任务 + 死信重试(router 级鉴权)
|
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ ├── base.py # async engine + get_db/get_db_read
|
│ │ ├── base.py # async engine + get_db/get_db_read
|
||||||
│ │ ├── models.py # 12 张表 ORM
|
│ │ ├── models.py # 6 张表 ORM
|
||||||
│ │ ├── repositories.py # 仓储层
|
│ │ ├── repositories.py # 仓储层
|
||||||
│ │ └── unit_of_work.py # 事务边界
|
│ │ └── unit_of_work.py # 事务边界
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ │ ├── bzzoiro.py # 唯一数据源:events/standings/stats 三管线 + Bronze 层
|
│ │ ├── bzzoiro.py # 赛果采集 + 幂等入库
|
||||||
|
│ │ ├── understat.py # xG 回填
|
||||||
|
│ │ ├── injuries.py # 伤停采集(带文件缓存)
|
||||||
│ │ ├── normalize.py # NormalizedMatch 清洗契约
|
│ │ ├── normalize.py # NormalizedMatch 清洗契约
|
||||||
│ │ ├── team_names.py # 队名归一映射
|
│ │ ├── team_names.py # 队名归一映射
|
||||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
|
||||||
│ │ ├── key_ring.py # 多 key 轮换(429 冷却,进程内)
|
|
||||||
│ │ ├── sources.py # 数据源注册表
|
│ │ ├── sources.py # 数据源注册表
|
||||||
│ │ └── config.py # 联赛代码映射
|
│ │ └── config.py # 联赛代码映射
|
||||||
│ ├── llm/
|
│ ├── llm/
|
||||||
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
|
│ │ ├── provider.py # OpenAI-compatible 抽象(共享连接池/JSON 兜底解析)
|
||||||
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/standings)+ 单 agent 拼接
|
│ │ ├── context_builder.py # 数据切片(h2h/form/stats/home_away/injuries)+ 单 agent 拼接
|
||||||
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
|
│ │ ├── predict.py # 预测入口(mode 分派 + 缓存)
|
||||||
│ │ ├── baseline.py # 基线预测(均值模型,mode=baseline)
|
|
||||||
│ │ ├── eval.py # 准确率统计
|
│ │ ├── eval.py # 准确率统计
|
||||||
│ │ ├── utils.py # LLM 工具函数
|
|
||||||
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
|
│ │ ├── validation.py # LLM 输出严格校验(Pydantic)
|
||||||
│ │ ├── backtest.py # 回测执行
|
│ │ ├── backtest.py # 回测执行
|
||||||
│ │ ├── agents/
|
│ │ ├── agents/
|
||||||
@@ -95,18 +91,14 @@ Profeto/
|
|||||||
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
|
│ │ │ └── orchestrator.py # 并行专家 → 终裁 → 存库
|
||||||
│ │ └── prompts/
|
│ │ └── prompts/
|
||||||
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
|
│ │ ├── match_prediction_v1/v2.md # 单 agent 模板
|
||||||
│ │ └── agents/{form,stats,home_away,standings,h2h,aggregator}_v1.md
|
│ │ └── agents/{h2h,form,stats,home_away,injuries,aggregator}_v1.md
|
||||||
│ └── core/
|
│ └── core/
|
||||||
│ ├── config.py # pydantic-settings
|
│ ├── config.py # pydantic-settings
|
||||||
│ ├── crypto.py # 加密/哈希
|
|
||||||
│ ├── http_client.py # 共享 httpx 客户端
|
│ ├── http_client.py # 共享 httpx 客户端
|
||||||
│ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
|
│ └── retry.py # 重试工具
|
||||||
│ ├── runtime_config.py # DB 配置覆盖(app_settings)
|
├── alembic/versions/ # 0001~0006(0001 建表 → 0006 漂移清理)
|
||||||
│ ├── scheduler.py # 进程内 cron 调度器
|
├── frontend/src/pages/Matches.tsx # 单页(预测面板 + 专家报告折叠区 + 游标分页)
|
||||||
│ └── security_check.py # 启动安全校验
|
├── tests/ # 核心 + agent 测试
|
||||||
├── alembic/versions/ # 0001~0018(建表 → Bronze 层 → 单一数据源 → 基线模式等)
|
|
||||||
├── frontend/src/ # pages/(公开站) + admin/(管理后台) + lib/http.ts(唯一 HTTP 实现)
|
|
||||||
├── tests/ # 核心 + agent 测试(250+ 项,自包含)
|
|
||||||
├── docker-compose.yml # api + postgres 两容器
|
├── docker-compose.yml # api + postgres 两容器
|
||||||
└── docs/ # 本文档
|
└── docs/ # 本文档
|
||||||
```
|
```
|
||||||
@@ -121,5 +113,5 @@ Profeto/
|
|||||||
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
|
| HTTP | httpx(共享连接池)/ urllib(bzzoiro 同步限速) |
|
||||||
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
|
| LLM | OpenAI-compatible 接口(openai/deepseek/ollama 等任一) |
|
||||||
| 前端 | Vite + React 18 + TypeScript + Tailwind |
|
| 前端 | Vite + React 18 + TypeScript + Tailwind |
|
||||||
| 测试 | pytest + pytest-asyncio(250+ 项,自包含) |
|
| 测试 | pytest + pytest-asyncio(33 项,自包含) |
|
||||||
| 部署 | Docker Compose(api + postgres) |
|
| 部署 | Docker Compose(api + postgres) |
|
||||||
|
|||||||
+4
-13
@@ -66,9 +66,6 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
|||||||
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
|
-d '{"leagues":["E0"],"date_from":"2026-08-01","date_to":"2026-09-08"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
> 注:采集/评估端点需管理员凭据。本地开发环境(未配置鉴权、非 production)默认放行;
|
|
||||||
> 生产环境需先 `POST /auth/login` 取 Cookie,或带 `X-API-Key` 头。
|
|
||||||
|
|
||||||
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
|
数据量大时**直接拉整赛季**(约 380 场,含近几个赛季更好,近况/交锋/积分榜都需要历史):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -77,20 +74,14 @@ curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
|||||||
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
|
-d '{"leagues":["E0"],"date_from":"2025-08-01","date_to":"2026-09-08"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 回填积分榜与统计(让攻防/排名专家有数据)
|
### 回填 xG(可选,让攻防数据 agent 有数据)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
curl -X POST http://localhost:8000/api/v1/ingest/understat \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"task":"standings"}'
|
-d '{"league":"E0","season":2025}'
|
||||||
|
|
||||||
curl -X POST http://localhost:8000/api/v1/ingest/bzzoiro \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"task":"stats","leagues":["E0"],"limit":300}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`task=stats` 只补空字段(xG/射门/控球等),不创建比赛。
|
|
||||||
|
|
||||||
### 查比赛
|
### 查比赛
|
||||||
|
|
||||||
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
|
浏览器打开 http://localhost:5173 ,选"英超 / 未开赛";
|
||||||
@@ -135,4 +126,4 @@ curl http://localhost:8000/api/v1/eval/summary
|
|||||||
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
|
| predict 返回 502 | 看 uvicorn 日志的 LLM error;确认 `LLM_BASE_URL`/`LLM_API_KEY`;`response_format` 不兼容的网关会报错(改用支持 json mode 的模型) |
|
||||||
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
|
| 采集 0 场 | bzzoiro Key 失效或联赛代码写错;先 `GET /api/v1/leagues` 看库里有没有联赛 |
|
||||||
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
|
| 专家报告全是 no_data | 历史数据不够 —— 近况需要每队近 5 场、积分榜需要本赛季已完赛比赛,多拉几周数据 |
|
||||||
| stats 专家报无 xG/统计 | 先跑 `task=stats` 回填(bzzoiro 统计管线,只补空字段) |
|
| xg agent 报无 xG 数据 | 先跑 understat 回填;注意 understat 只有五大联赛 |
|
||||||
|
|||||||
+34
-80
@@ -4,30 +4,18 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
|
|
||||||
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
|
所有数据端点返回 JSON。错误统一为 `{"detail": "<message>"}` + 对应 HTTP 状态码。
|
||||||
|
|
||||||
## 鉴权模型
|
|
||||||
|
|
||||||
| 级别 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| **公开只读** | `GET /leagues`、`GET /matches`、`GET /matches/{id}`、`GET /matches/{id}/context`、`GET /standings`、`GET /health*` | 无需任何凭据;公开站直接调用 |
|
|
||||||
| **公开 + 限流** | `POST /predict` | 内存滑动窗口限流(10 次/分钟/IP) |
|
|
||||||
| **需管理员** | `GET /predictions*`、`POST /ingest/bzzoiro`、`/eval/*`、`POST /backtest`、`/admin/**` | Cookie 会话(`POST /auth/login` 颁发)或 `X-API-Key` 头 |
|
|
||||||
|
|
||||||
管理端点在生产环境未配置鉴权时 fail-closed(503),不会静默放行。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据查询(公开只读)
|
## 数据查询
|
||||||
|
|
||||||
### `GET /api/v1/leagues`
|
### `GET /api/v1/leagues`
|
||||||
|
|
||||||
列出已入库联赛(P1-3: 公开站联赛筛选动态加载来源)。
|
列出已入库联赛。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
|
[{"id": 1, "code": "E0", "name": "Premier League", "country": "England"}]
|
||||||
```
|
```
|
||||||
|
|
||||||
仅返回 `id/code/name/country` 四个展示字段,不含任何配置或密钥信息。
|
|
||||||
|
|
||||||
### `GET /api/v1/matches`
|
### `GET /api/v1/matches`
|
||||||
|
|
||||||
比赛列表,游标分页。
|
比赛列表,游标分页。
|
||||||
@@ -59,44 +47,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
|
|
||||||
### `GET /api/v1/matches/{id}`
|
### `GET /api/v1/matches/{id}`
|
||||||
|
|
||||||
单场比赛详情,字段同上,另含 `stats`(统计)与 `recent_predictions`(最近 5 条预测摘要)。
|
单场比赛详情,字段同上。
|
||||||
|
|
||||||
### `GET /api/v1/matches/{id}/context`
|
|
||||||
|
|
||||||
比赛上下文(公开只读,P1-2: 公开站详情页「近况/交锋」数据来源;不触发 LLM):
|
|
||||||
|
|
||||||
- `home_recent`: 主队最近 5 场已完赛
|
|
||||||
- `away_recent`: 客队最近 5 场已完赛
|
|
||||||
- `h2h`: 双方最近 5 次交手
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"home_recent": [
|
|
||||||
{"match_date": "2026-09-12T14:00:00+00:00", "home_team": "阿森纳",
|
|
||||||
"away_team": "切尔西", "home_goals": 2, "away_goals": 1}
|
|
||||||
],
|
|
||||||
"away_recent": [],
|
|
||||||
"h2h": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
数据不足时对应列表为空(前端展示空态)。比赛不存在返回 404。
|
|
||||||
|
|
||||||
### `GET /api/v1/standings`
|
|
||||||
|
|
||||||
联赛积分榜(公开只读)。参数:`league`(联赛代码,空 = 全部)、`season`(空 = 各联赛最新赛季)。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"leagues": [
|
|
||||||
{"league_code": "E0", "league_name": "Premier League", "season": "2026-2027",
|
|
||||||
"retrieved_at": "2026-09-20T08:00:00+00:00",
|
|
||||||
"rows": [{"position": 1, "team": "阿森纳", "team_en": "Arsenal",
|
|
||||||
"played": 5, "won": 4, "drawn": 1, "lost": 0,
|
|
||||||
"goals_for": 11, "goals_against": 3, "goal_diff": 8,
|
|
||||||
"points": 13, "xg_for": 9.8, "xg_against": 3.9,
|
|
||||||
"form": "WWWDW", "zone": "UEFA Champions League"}]}
|
|
||||||
]}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,13 +96,13 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
|
"exp_home_goals": null, "exp_away_goals": null, "probable_score": null,
|
||||||
"model": "gpt-4o-mini", "latency_ms": 2100,
|
"model": "gpt-4o-mini", "latency_ms": 2100,
|
||||||
"prompt_tokens": 380, "completion_tokens": 120},
|
"prompt_tokens": 380, "completion_tokens": 120},
|
||||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||||
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
|
"analysis": "该维度无数据,跳过分析。", "home_edge": null, "subjective_confidence": null,
|
||||||
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
|
"key_evidence": [], "exp_home_goals": null, "exp_away_goals": null,
|
||||||
"probable_score": null, "model": "", "latency_ms": null,
|
"probable_score": null, "model": "", "latency_ms": null,
|
||||||
"prompt_tokens": null, "completion_tokens": null}
|
"prompt_tokens": null, "completion_tokens": null}
|
||||||
],
|
],
|
||||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8},
|
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8},
|
||||||
"context": "[5 份报告的 JSON 串]",
|
"context": "[5 份报告的 JSON 串]",
|
||||||
"latency_ms": 9800
|
"latency_ms": 9800
|
||||||
}
|
}
|
||||||
@@ -161,11 +112,11 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
|
|
||||||
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
|
错误:404 比赛不存在;502 LLM 调用失败(终裁失败时整体失败,专家失败不会)。
|
||||||
|
|
||||||
### `GET /api/v1/predictions?match_id=&limit=`(需管理员)
|
### `GET /api/v1/predictions?match_id=&limit=`
|
||||||
|
|
||||||
预测历史(倒序),含 `settled` 与实际比分回填状态。
|
预测历史(倒序),含 `settled` 与实际比分回填状态。
|
||||||
|
|
||||||
### `GET /api/v1/predictions/{id}`(需管理员)
|
### `GET /api/v1/predictions/{id}`
|
||||||
|
|
||||||
单条预测详情(含完整 `agent_outputs`)。
|
单条预测详情(含完整 `agent_outputs`)。
|
||||||
|
|
||||||
@@ -173,34 +124,40 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
|
|
||||||
## 数据采集
|
## 数据采集
|
||||||
|
|
||||||
### `POST /api/v1/ingest/bzzoiro`(需管理员)
|
### `POST /api/v1/ingest/bzzoiro`
|
||||||
|
|
||||||
从 bzzoiro(唯一数据源)采集数据并入库(幂等,重复跑安全)。任务在后台异步执行,请求立即返回。
|
从 bzzoiro 采集赛果/赛程并入库(幂等,重复跑安全)。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"task": "all", "leagues": ["E0", "SP1"], "date_from": "2025-08-01", "date_to": "2026-09-08", "status": "finished"}
|
{"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` 统计
|
- 响应含每联赛 `inserted`/`updated`/`errors` 统计
|
||||||
- `task=stats` 只补空字段、不创建比赛(xG/射门/控球等统计回填)
|
|
||||||
|
|
||||||
> 历史版本曾有独立的 understat(xG)与 injuries(伤停)采集端点,
|
### `POST /api/v1/ingest/understat`
|
||||||
> 已随数据源收敛为 bzzoiro 唯一来源而移除。
|
|
||||||
|
回填 xG(只补空字段,不创建比赛):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"league": "E0", "season": 2025}
|
||||||
|
```
|
||||||
|
|
||||||
|
`season=2025` 表示 2025-2026 赛季。仅支持五大联赛。
|
||||||
|
|
||||||
|
### `POST /api/v1/ingest/injuries`
|
||||||
|
|
||||||
|
采集伤停(需 `API_FOOTBALL_KEY`,当前只返回计数,尚未接入 context):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"date": "2026-09-10"}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 评估
|
## 评估
|
||||||
|
|
||||||
### `POST /api/v1/eval/settle`(需管理员)
|
### `POST /api/v1/eval/settle`
|
||||||
|
|
||||||
赛后回填实际比分:
|
赛后回填实际比分:
|
||||||
|
|
||||||
@@ -208,7 +165,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
|
{"prediction_id": 7, "home_goals": 2, "away_goals": 1}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/v1/eval/summary`(需管理员)
|
### `GET /api/v1/eval/summary`
|
||||||
|
|
||||||
按 `provider × model` 聚合已结算预测:
|
按 `provider × model` 聚合已结算预测:
|
||||||
|
|
||||||
@@ -226,10 +183,7 @@ Base URL: `http://localhost:8000` · 交互式文档: `/docs`(Swagger)与 `/redo
|
|||||||
|
|
||||||
## 基础
|
## 基础
|
||||||
|
|
||||||
| 端点 | 权限 | 说明 |
|
| 端点 | 说明 |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `GET /health` | 公开 | 存活检查 |
|
| `GET /health` | 存活检查 |
|
||||||
| `GET /docs`、`GET /redoc` | 公开 | Swagger UI / ReDoc |
|
| `GET /docs` | Swagger UI |
|
||||||
| `POST /auth/login`、`POST /auth/logout`、`GET /auth/me` | 公开 | 管理员 Cookie 会话登录/登出/当前用户 |
|
|
||||||
| `POST /auth/change-password` | 需管理员 | 修改管理员密码 |
|
|
||||||
| `POST /backtest` | 需管理员 | 历史回测(对已完赛比赛批量预测并评估) |
|
|
||||||
|
|||||||
+7
-20
@@ -10,7 +10,7 @@ Profeto 的核心预测路径是 **5 个领域专家 Agent 并行分析 + 1 个
|
|||||||
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
|
| `form` 近期状态 | 分析比分与关键事件,判断近期走势 | 两队近 N 场赛果(含 xG) | `home_edge` + 走势判断 |
|
||||||
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
|
| `stats` 攻防数据 | 评估进球、射门与控球,量化攻防强度 | 近 N 场进球/射门/控球/xG 统计 | `home_edge` + 攻防强度 |
|
||||||
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
|
| `home_away` 主客因素 | 对比主场与客场表现,评估地理优势影响 | 主队主场战绩 + 客队客场战绩 | `home_edge` + 地理优势 |
|
||||||
| `standings` 联赛排名 | 结合积分榜排名、积分与分区(欧冠/欧联/降级),评估双方竞争位置 | 两队当前赛季积分榜行(排名/积分/分区/近期战绩) | `home_edge` 或 `no_data` |
|
| `injuries` 阵容完整性 | 汇总伤停与停赛名单,评估战力缺失程度 | 伤停数据(当前无源 → no_data 门控) | `home_edge` 或 `no_data` |
|
||||||
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
|
| `h2h` 历史交锋 | 分析过去数年以及近期的交手数据,提取交手规律 | 近 N 次交锋(含主客方向 + 总计统计) | `home_edge` + 交手规律 |
|
||||||
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
|
| `aggregator` 终裁 | 权衡 5 份报告 → 最终结论 | 5 份结构化报告 + 比赛头信息 | 最终预测 + 各报告采信度 |
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ POST /predict {match_id, mode: "multi"}
|
|||||||
│ ├─ form agent ─┐
|
│ ├─ form agent ─┐
|
||||||
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
|
│ ├─ stats agent │ 每个 agent 拿到专属数据切片
|
||||||
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
|
│ ├─ home_away agent │ → no_data 门控 → 调 LLM → 输出 JSON 报告
|
||||||
│ ├─ standings agent │ (无数据 → 跳过 LLM,返回 stub)
|
│ ├─ injuries agent │ (无数据 → 跳过 LLM,返回 stub)
|
||||||
│ └─ h2h agent ─┘
|
│ └─ h2h agent ─┘
|
||||||
│
|
│
|
||||||
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
|
├─ aggregator agent(5 份报告 + 比赛头 → 最终 JSON)
|
||||||
@@ -39,12 +39,12 @@ POST /predict {match_id, mode: "multi"}
|
|||||||
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
|
5 个专家通过 `asyncio.gather` 并发,总延迟 ≈ `max(专家延迟) + 终裁延迟` ≈ 2 次串行 LLM 调用。
|
||||||
|
|
||||||
### 2. no_data 门控(省 token、防幻觉)
|
### 2. no_data 门控(省 token、防幻觉)
|
||||||
数据切片为空时(如该场比赛的积分榜尚未采集),**跳过 LLM 调用**,直接返回:
|
数据切片为空时(如伤停数据源未接入),**跳过 LLM 调用**,直接返回:
|
||||||
```json
|
```json
|
||||||
{"agent": "standings", "status": "no_data", "data_sufficiency": "none",
|
{"agent": "injuries", "status": "no_data", "data_sufficiency": "none",
|
||||||
"analysis": "该维度无数据,跳过分析。"}
|
"analysis": "该维度无数据,跳过分析。"}
|
||||||
```
|
```
|
||||||
终裁 Agent 会看到这个 `no_data` 状态,不会编造积分榜分析。
|
终裁 Agent 会看到这个 `no_data` 状态,不会编造伤停分析。
|
||||||
|
|
||||||
### 3. fail-open(单专家失败不阻断)
|
### 3. fail-open(单专家失败不阻断)
|
||||||
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
|
单个专家 LLM 调用失败 → 其报告标记 `status: error`,其余 4 份 + 终裁照常执行。
|
||||||
@@ -89,7 +89,7 @@ POST /predict {match_id, mode: "multi"}
|
|||||||
"1x2": "1",
|
"1x2": "1",
|
||||||
"subjective_confidence": 0.68,
|
"subjective_confidence": 0.68,
|
||||||
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
|
"reasoning": "综合 stats 报告的攻防强度与 form 报告的三连胜势头……",
|
||||||
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "standings": 0.8, "h2h": 0.8}
|
"agent_weights": {"form": 0.9, "stats": 0.8, "home_away": 0.7, "injuries": 0.0, "h2h": 0.8}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ agents/
|
|||||||
├── form_v1.md # 近期状态专家
|
├── form_v1.md # 近期状态专家
|
||||||
├── stats_v1.md # 攻防数据专家
|
├── stats_v1.md # 攻防数据专家
|
||||||
├── home_away_v1.md # 主客因素专家
|
├── home_away_v1.md # 主客因素专家
|
||||||
├── standings_v1.md # 联赛排名专家
|
├── injuries_v1.md # 阵容完整性专家
|
||||||
├── h2h_v1.md # 历史交锋专家
|
├── h2h_v1.md # 历史交锋专家
|
||||||
└── aggregator_v1.md # 终裁
|
└── aggregator_v1.md # 终裁
|
||||||
```
|
```
|
||||||
@@ -129,19 +129,6 @@ agents/
|
|||||||
迭代 prompt 时:复制 `h2h_v1.md` → `h2h_v2.md`,改内容,传 `prompt_version: "v2"`。
|
迭代 prompt 时:复制 `h2h_v1.md` → `h2h_v2.md`,改内容,传 `prompt_version: "v2"`。
|
||||||
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
|
`predictions.prompt_version` 存的是 `multi_v2`,与 single 模式的 `v1`/`v2` 天然分组,可在 eval summary 中 A/B 对比。
|
||||||
|
|
||||||
### 版本纪律(强制)
|
|
||||||
|
|
||||||
> **改 prompt 内容必须 bump 版本号(v2→v3),禁止默默修改 `*_v1.md` 内容却不改版本。**
|
|
||||||
|
|
||||||
原因:
|
|
||||||
1. **可复现性**:`predictions.prompt_version` 决定哪份 prompt 产生了历史预测;篡改 v1 会让历史预测的 prompt 来源失真,eval 对比失效。
|
|
||||||
2. **A/B 可信度**:`get_eval_summary` 按 `(provider, model, prompt_version)` 分组。若 v1 内容在不同时间指向不同 prompt,则 v1 桶内数据不可比。
|
|
||||||
3. **缓存一致性**:模板内容 hash 写入缓存键(`_prompt_template_hash`),版本不变则 hash 不变,命中旧缓存。bump 版本自动让旧缓存失效。
|
|
||||||
|
|
||||||
**流程**:改 prompt → 新建 `*_v{N+1}.md` → 新请求传 `prompt_version=v{N+1}` → 旧版本文件保持不变(供历史复现)。
|
|
||||||
|
|
||||||
代码保证:写入 DB 的 `prompt_version` 与 `_load_prompt_template(version)` / `load_agent_prompt(name, version)` 加载的文件**严格一致**,不会漂移。
|
|
||||||
|
|
||||||
## 如何新增一个专家 Agent
|
## 如何新增一个专家 Agent
|
||||||
|
|
||||||
三步:
|
三步:
|
||||||
|
|||||||
+17
-110
@@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
| 数据源 | 用途 | 必需 Key | 说明 |
|
| 数据源 | 用途 | 必需 Key | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| bzzoiro(唯一) | 赛果/赛程/积分榜/统计(xG、射门、控球等) | `BZZOIRO_KEY` | 五大联赛 + 欧战,历史 + 实时 |
|
| bzzoiro | 赛果/赛程(主源) | `BZZOIRO_KEY` | 五大联赛历史 + 实时 |
|
||||||
|
| understat | xG 回填 | 无(公开) | 仅五大联赛,补 `match_stats.xg` |
|
||||||
> 历史版本曾有 understat(xG 回填)与 api-football(伤停)两个辅助源,
|
| api-football | 伤停 | `API_FOOTBALL_KEY` | 当前只采集计数,未接入 context |
|
||||||
> 现已移除:数据源收敛为 bzzoiro 唯一来源,统计与积分榜均由 bzzoiro 管线采集。
|
|
||||||
|
|
||||||
### bzzoiro
|
### bzzoiro
|
||||||
|
|
||||||
@@ -18,16 +17,11 @@
|
|||||||
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
|
BZZOIRO_LEAGUE_IDS = {"E0": 1, "SP1": 3, "D1": 5, "I1": 4, "F1": 6, "CL": 7, "EL": 8}
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ **统计字段映射待验证**:当前字段名基于常见足球 API 模式推测(如 `home_shots`/`away_shots`),
|
### understat
|
||||||
> 未经真实 bzzoiro 响应校验。若真实字段不同,映射结果将为 None。
|
|
||||||
> 请提供一份 event 样例核对以下字段:
|
- 端点:`/getLeagueData/{league}/{season}`,返回 JS 包裹的 JSON(需正则提取)
|
||||||
> - 射门: `home_shots` / `away_shots`
|
- 只回填 xG(`match_stats.home_xg`/`away_xg`),**不创建新比赛**
|
||||||
> - 射正: `home_shots_on_target` / `away_shots_on_target`
|
- 通过"天级日期 + 队名归一"匹配已有比赛
|
||||||
> - 角球: `home_corners` / `away_corners`
|
|
||||||
> - 控球: `home_possession`
|
|
||||||
> - xG: `home_xg` / `away_xg`
|
|
||||||
> - 黄牌: `home_yellow_cards` / `away_yellow_cards`
|
|
||||||
> - 红牌: `home_red_cards` / `away_red_cards`
|
|
||||||
|
|
||||||
## 数据清洗契约
|
## 数据清洗契约
|
||||||
|
|
||||||
@@ -60,68 +54,12 @@
|
|||||||
|
|
||||||
### 队名归一化
|
### 队名归一化
|
||||||
|
|
||||||
`src/data.team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
|
`src/data/team_names.py` 维护 `NORMALIZE_MAP`(如 `Man City` → `Manchester City`),未命中映射的队名原样返回。
|
||||||
归一前先做 Unicode NFKD 去重音。
|
归一前先做 Unicode NFKD 去重音。
|
||||||
|
|
||||||
**唯一键是归一后英文名**:`teams.name` 带 `UNIQUE` 约束,所有入库路径均经 `TeamRepository.get_or_create` 收敛归一化
|
|
||||||
(events / standings 管线在调用前归一,仓库层再做一次幂等归一作为兜底)。创建新 Team 时打 `info` 日志记录「原始名 → 归一后名」。
|
|
||||||
|
|
||||||
> ⚠️ **`normalize` 当前大小写敏感**:仅当入参大小写与 `NORMALIZE_MAP` 键完全匹配时才触发映射
|
|
||||||
>(如 `"Man City"` → `"Manchester City"`,但 `"man city"` 原样保留)。上游 bzzoiro 返回的队名首字母大写,
|
|
||||||
>实际命中无问题;若新增数据源返回全小写/全大写队名,需先 `title()` 再归一,否则会绕过映射产生重复 Team。
|
|
||||||
|
|
||||||
#### 别名机制(`team_aliases`)
|
|
||||||
|
|
||||||
归一仍可能遗漏历史重复队(如 `"Bayern Munich"` 与 `"Bayern München"` 经 NFKD 后相同则命中,
|
|
||||||
但 `"Man United"` vs `"Manchester United"` 若漏映射)。`team_aliases` 表提供**显式别名→teams.id** 映射:
|
|
||||||
|
|
||||||
| 列 | 说明 |
|
|
||||||
|----|------|
|
|
||||||
| `alias_normalized` | PK,`normalize(别名)` 后的稳定幂等键 |
|
|
||||||
| `team_id` | FK → `teams.id`(ON DELETE CASCADE) |
|
|
||||||
| `original_alias` | 原始写法(保留供参考) |
|
|
||||||
|
|
||||||
**定位三步链**(`get_or_create`):`normalize(name)` → 查 `teams.name` → 查 `team_aliases`(以 `normalize(name)` 为 PK)→ 都没有才 insert 新 Team。别名命中即复用已有 Team,避免产生重复。
|
|
||||||
|
|
||||||
**添加别名**(不自动合并历史重复队):
|
|
||||||
|
|
||||||
- **Admin 接口**(推荐):`POST /api/v1/admin/teams/aliases {"alias": "Man United", "team_id": 42}`(require_admin,幂等)
|
|
||||||
- **直接 SQL**:
|
|
||||||
```sql
|
|
||||||
INSERT INTO team_aliases(alias_normalized, team_id, original_alias)
|
|
||||||
VALUES ('man united', 42, 'Man United')
|
|
||||||
ON CONFLICT (alias_normalized) DO UPDATE SET team_id = EXCLUDED.team_id, original_alias = EXCLUDED.original_alias;
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ **别名不自动合并**:发现历史重复队 A/B 后,需人工确认归一目标(如保留 B),再为 A 的归一名添加别名指向 B。
|
|
||||||
> 合并前请确认 A 的 `matches`/`standings` 引用是否需要迁移(可先 `SELECT COUNT(*) FROM matches WHERE home_team_id = A.id OR away_team_id = A.id` 评估)。
|
|
||||||
|
|
||||||
**改名 / 合并流程**(人工):
|
|
||||||
|
|
||||||
当发现两个 `teams` 行实际是同一球队(如 `Manchester City` 与 `Man City` 因历史数据大小写差异各占一行):
|
|
||||||
|
|
||||||
1. 确定**保留行**(通常选归一后规范名、且被更多 Match 引用的那行)。
|
|
||||||
2. 将被删行的所有引用指向保留行(`UPDATE matches SET home_team_id = 保留id WHERE home_team_id = 删行id`,客场同理;
|
|
||||||
`standings` / `match_stats` 按 `team_id` 同理)。
|
|
||||||
3. 删掉多余行:`DELETE FROM teams WHERE id = 删行id`。
|
|
||||||
|
|
||||||
> 此过程引入外键约束风险,务必在事务中执行并先 `BEGIN; ... ` 验证行数后再 `COMMIT`。
|
|
||||||
> 暂不做自动合并(避免误合相似名),仅通过下方 Admin 接口列出「近似重名」候选,由人工判定。
|
|
||||||
|
|
||||||
## Admin:近似重名候选
|
|
||||||
|
|
||||||
`GET /api/v1/admin/team-name-duplicates` 只读列出启发式相似候选(大小写差异、子串包含、前缀碰撞),不做自动合并。
|
|
||||||
典型用途:定期巡检,发现候选后走上方人工 SQL 合并。启发式规则:
|
|
||||||
|
|
||||||
- **大小写变体**:`lower(name)` 相同但 `name` 不同(如 `Arsenal FC` / `arsenal fc`)。
|
|
||||||
- **子串包含**:A 是 B 的子串且长度 ≥ 5(如 `Manchester` / `Manchester City`)。
|
|
||||||
- **前缀碰撞**:前 8 个字符相同的两队。
|
|
||||||
|
|
||||||
命中任一规则即列为候选,按相似度分组返回。
|
|
||||||
|
|
||||||
## 数据库 Schema
|
## 数据库 Schema
|
||||||
|
|
||||||
12 张表:核心业务表 5 张见下方 DDL,其余 7 张(积分榜/配置/调度/治理)见后文表格。
|
6 张表:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 联赛
|
-- 联赛
|
||||||
@@ -195,48 +133,21 @@ CREATE TABLE predictions (
|
|||||||
reasoning TEXT,
|
reasoning TEXT,
|
||||||
raw_response JSONB, -- LLM 完整原始响应
|
raw_response JSONB, -- LLM 完整原始响应
|
||||||
agent_outputs JSONB, -- multi 模式: 5 份专家报告
|
agent_outputs JSONB, -- multi 模式: 5 份专家报告
|
||||||
agent_weights JSONB, -- multi 模式: 终裁给出的各专家权重
|
|
||||||
created_at TIMESTAMPTZ,
|
created_at TIMESTAMPTZ,
|
||||||
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
|
actual_home_goals INT, actual_away_goals INT, -- 赛后回填
|
||||||
settled BOOLEAN DEFAULT FALSE
|
settled BOOLEAN DEFAULT FALSE
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
其余 7 张表(DDL 略,详见 `src/db/models.py` 与 alembic 迁移):
|
|
||||||
|
|
||||||
| 表 | 状态 | 用途 |
|
|
||||||
|---|---|---|
|
|
||||||
| `standings` | 已启用 | 联赛积分榜追加快照(P0-02):每次采集 INSERT 新行(available_at=now),唯一键 `(league_id, season, team_id, available_at)`;查询取每队 available_at 最新快照,支持回测还原历史榜单。含排名/战绩/进失球/积分/分区(zone) |
|
|
||||||
| `app_settings` | 已启用 | 后台运行时设置(如数据源 API Key),读取时优先于 `.env` 默认值 |
|
|
||||||
| `schedules` | 已启用 | 定时采集任务配置(task/cron/leagues/enabled),供内置调度器执行 |
|
|
||||||
| `raw_events` | 预留未启用 | Bronze 层原始事件存档;规划中用于重放与审计 |
|
|
||||||
| `ingest_failures` | 已启用 | 采集失败死信:bzzoiro 三条管线(events/standings/stats)抓取失败时写入,admin 后台可查看与重试 |
|
|
||||||
| `data_quality_checks` | 预留未启用 | 数据质量检查结果;规划中定时检查比赛/统计/积分榜完整性 |
|
|
||||||
| `data_lineage` | 预留未启用 | ETL 血缘追踪;规划中记录源记录到目标表的映射 |
|
|
||||||
|
|
||||||
### 关键设计点
|
### 关键设计点
|
||||||
|
|
||||||
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
|
1. **`match_date_date`(天级日期)**: 用于天级去重。bzzoiro 返回的时间带时分秒,精确匹配不可靠,故拆出 `DATE` 列做唯一键。
|
||||||
|
|
||||||
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。**业务唯一:同联赛同主客同自然天一条。**
|
2. **`ix_matches_unique`**: `(league_id, home_team_id, away_team_id, match_date_date)` 唯一,保证同一场比赛重复采集时 upsert 而非插入重复行。
|
||||||
|
|
||||||
3. **`source_event_id` 部分唯一**: `ix_matches_source_event_id_unique`(WHERE source_event_id IS NOT NULL)——上游 bzzoiro 的比赛 id,当非空时全局唯一。作用:
|
3. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
|
||||||
- 统计回填(`/events/{id}/stats/`)与 Bronze 血缘(/events/ 采集)通过它定位比赛,不依赖自然键天级舍入;
|
|
||||||
- 新采集行均带此 id,避免同一 upstream 比赛因时间戳差异绕开自然键产生重复。
|
|
||||||
- 存量空 source_event_id 历史行不受影响(不强制回填)。
|
|
||||||
|
|
||||||
4. **`predictions` 级联删除**: `ON DELETE CASCADE`,删比赛时自动清其预测。
|
4. **`mode` + `prompt_version`**: `single` 模式存 `v1`/`v2`,`multi` 模式存 `multi_v1`/`multi_v2`,eval summary 按这两列天然分组对比。
|
||||||
|
|
||||||
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 新比赛。
|
|
||||||
|
|
||||||
## 入库语义(幂等)
|
## 入库语义(幂等)
|
||||||
|
|
||||||
@@ -248,9 +159,7 @@ events 管线按以下优先级定位已有比赛,命中即复用(更新):
|
|||||||
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
|
- 状态:只允许单向升级(`scheduled` → `finished`),防止完赛行被覆盖成赛程
|
||||||
- stats:只补空(`home_xg` 已有值时不覆盖)
|
- stats:只补空(`home_xg` 已有值时不覆盖)
|
||||||
|
|
||||||
`task=stats` 只回填统计(xG/射门/控球等,也只补空),不创建比赛。
|
`ingest_understat` 只回填 xG(也只补空),不创建比赛。
|
||||||
|
|
||||||
`task=standings` 追加快照(available_at=now,ON CONFLICT DO NOTHING);公开接口与切片均取每队 available_at 最新快照,支持回测还原历史榜单。
|
|
||||||
|
|
||||||
## 采集建议
|
## 采集建议
|
||||||
|
|
||||||
@@ -263,11 +172,9 @@ curl -X POST /api/v1/ingest/bzzoiro \
|
|||||||
curl -X POST /api/v1/ingest/bzzoiro \
|
curl -X POST /api/v1/ingest/bzzoiro \
|
||||||
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
|
-d '{"leagues":["E0"],"date_from":"2026-09-01","date_to":"2026-09-08"}'
|
||||||
|
|
||||||
# 3. 积分榜 + 统计回填(xG/射门/控球,提升 stats/standings 专家质量)
|
# 3. xG 回填(可选,提升 xg agent 质量)
|
||||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"standings"}'
|
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2025}'
|
||||||
curl -X POST /api/v1/ingest/bzzoiro -d '{"task":"stats","leagues":["E0"],"limit":300}'
|
curl -X POST /api/v1/ingest/understat -d '{"league":"E0","season":2026}'
|
||||||
|
|
||||||
# 注:采集端点需管理员凭据(Cookie 会话或 X-API-Key 头),下同
|
|
||||||
```
|
```
|
||||||
|
|
||||||
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
|
建议用外部 cron(如系统 crontab)定时触发,不引入 worker/redis。
|
||||||
|
|||||||
+12
-176
@@ -13,45 +13,22 @@
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
||||||
|
|
||||||
# 2. 启动(自动执行数据库迁移)
|
# 2. 启动(自动建表)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
# 3. 验证
|
# 3. 验证
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.yml` 包含 3 个服务:
|
`docker-compose.yml` 仅 2 个服务:
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
| 服务 | 端口 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `postgres` | 5433 | PostgreSQL 16 |
|
| `postgres` | 5432 | PostgreSQL 16 |
|
||||||
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
|
| `api` | 8000 | FastAPI 应用 |
|
||||||
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
|
|
||||||
|
|
||||||
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
||||||
|
|
||||||
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
|
|
||||||
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
|
|
||||||
|
|
||||||
## 生产上线检查清单
|
|
||||||
|
|
||||||
公网上线前逐项勾选。第 1–4 项由 `src/core/security_check.py` 在 `APP_ENV=production`
|
|
||||||
启动时**强制校验**,不满足直接拒绝启动(开发环境仅告警);管理鉴权另有请求期 fail-closed(503)。
|
|
||||||
|
|
||||||
- [ ] **1. `APP_ENV=production`** — 安全校验、Cookie `Secure`、管理端点 fail-closed 均以它为总开关
|
|
||||||
- [ ] **2. `SECRET_KEY` 强随机** — 用 `openssl rand -base64 32` 生成;禁止弱值/短值(弱值黑名单会拒绝启动,含把生成指令原样粘进去的情况)
|
|
||||||
- [ ] **3. 管理鉴权至少其一** — `ADMIN_PASSWORD` 或 `ADMIN_API_KEY`(后台改过密码后以数据库哈希优先);两者皆空时管理接口 503
|
|
||||||
- [ ] **4. 数据库强密码** — 禁止 `football:football` 等示例弱密码(启动校验会拒绝);compose 的 `POSTGRES_PASSWORD` 必填,缺失时容器拒绝启动
|
|
||||||
- [ ] **5. HTTPS** — 由反代(Nginx/Caddy)终结 TLS;`APP_ENV=production` 下会话 Cookie 自动 `Secure`(且 HttpOnly + SameSite=Lax)
|
|
||||||
- [ ] **6. 反代信任头** — `TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
|
|
||||||
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
|
|
||||||
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
|
|
||||||
|
|
||||||
> ⚠️ **多 worker 陷阱**:应用内限流(`_RateLimiter`)与 KeyRing 均为**进程内纯内存状态**,多 worker 部署(如 `uvicorn --workers 4`)时各进程**各自独立计数、互不共享**——实际限流配额会被放大 N 倍、KeyRing 限流状态也不同步。
|
|
||||||
> 若确需多 worker,必须前置 Nginx/网关做**全局限流**(见[安全与限流](#安全与限流)),并设环境变量 `STRICT_SINGLE_WORKER=True`(见下)在启动期强制拒绝多 worker,避免静默配额漂移。
|
|
||||||
- [ ] **9. 启动后健康检查** — `curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
|
|
||||||
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
|
|
||||||
|
|
||||||
## 本地开发部署
|
## 本地开发部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -86,14 +63,7 @@ cd frontend && npm install && npm run dev
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||||||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||||||
| `LOG_FILE` | ❌ | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
|
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL |
|
||||||
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
|
||||||
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
|
||||||
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
|
||||||
| `POSTGRES_PASSWORD` | ✅ | — | PostgreSQL 密码 |
|
|
||||||
| `POSTGRES_DB` | ❌ | `football` | PostgreSQL 数据库名 |
|
|
||||||
| `POSTGRES_PORT` | ❌ | `5433` | PostgreSQL 端口映射 |
|
|
||||||
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL(Docker 内会被覆盖) |
|
|
||||||
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
||||||
| `LLM_API_KEY` | ✅ | — | API Key |
|
| `LLM_API_KEY` | ✅ | — | API Key |
|
||||||
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
||||||
@@ -101,30 +71,10 @@ cd frontend && npm install && npm run dev
|
|||||||
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
||||||
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
||||||
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
||||||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
|
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key |
|
||||||
|
| `BZZOIRO_BASE` | ❌ | `https://sports.bzzoiro.com/api/v2` | bzzoiro 接口地址 |
|
||||||
|
| `API_FOOTBALL_KEY` | ❌ | — | 伤停数据源 Key |
|
||||||
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
||||||
| `STRICT_SINGLE_WORKER` | ❌ | `False` | `True` 时若以多 worker 启动则拒绝(防限流配额漂移) |
|
|
||||||
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
|
|
||||||
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
|
|
||||||
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
|
|
||||||
|
|
||||||
## 同站部署 vs 跨站 CSRF
|
|
||||||
|
|
||||||
Profeto 管理鉴权使用 **HttpOnly Cookie 会话**(登录后服务端写入),`allow_credentials=True` 的 CORS 配置允许浏览器跨域携带 Cookie——这也引入了 CSRF 面。部署拓扑决定风险等级:
|
|
||||||
|
|
||||||
**同站部署(推荐)**: 前端与 API 同域(反代把 `/` 与 `/api` 都转发到同一后端,或同源端口)。
|
|
||||||
- 浏览器视为 **same-origin**,CORS 不触发;`SameSite=Lax` 会话 Cookie 天然阻断跨站请求携带。
|
|
||||||
- 风险最低。`CORS_ORIGINS` 可设为空或同域来源,仅作兜底。
|
|
||||||
|
|
||||||
**跨站部署**: 前端与 API 不同域(如前端 `app.example.com`、API `api.example.com`,或开发时 `localhost:3000` → `localhost:8000`)。
|
|
||||||
- 必须把 API 域名列入 `CORS_ORIGINS`,且 `allow_credentials=True` 才能携带 Cookie。
|
|
||||||
- 此时任何被允许域下的页面都能构造带 Cookie 的请求 → **CSRF 面**:
|
|
||||||
- 状态变更接口(采集/回测/改密等写操作)要求**管理员 Cookie + 同域**,攻击者无法从第三方站点读取 Cookie,但可构造跨域表单/请求——`SameSite=Lax` 会阻断跨站 POST 表单提交(顶级导航 GET 仍放行),这是当前主要防线。
|
|
||||||
- `GET /api/v1/admin/*` 只读接口受 `SameSite=Lax` 下顶级导航可能被利用,但攻击者无法读取响应(CORS 不匹配时浏览器拦截)。
|
|
||||||
- **加固建议**:
|
|
||||||
1. 反代层加 `Origin`/`Referer` 校验,仅放行 `CORS_ORIGINS` 列表中的来源(即便 FastAPI CORS 已通过,反代校验是多一层纵深)。
|
|
||||||
2. 写操作要求自定义请求头(如 `X-Requested-With: XMLHttpRequest`),第三方站点无法在无预检下添加自定义头,天然阻断简单跨站 POST。
|
|
||||||
3. 生产强制 HTTPS(`APP_ENV=production` 下 Cookie 自动 `Secure`),防中间人窃 Cookie。
|
|
||||||
|
|
||||||
## LLM 提供商配置示例
|
## LLM 提供商配置示例
|
||||||
|
|
||||||
@@ -177,16 +127,9 @@ alembic revision --autogenerate -m "描述"
|
|||||||
alembic revision -m "描述"
|
alembic revision -m "描述"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
|
|
||||||
无需手动运行。本地开发时需手动执行迁移。
|
|
||||||
|
|
||||||
已有迁移:
|
已有迁移:
|
||||||
- `0001_initial`: 初始 5 张表
|
- `0001_initial`: 初始 5 张表,0003 增加 injuries,0004 增加约束,0005 增加时间语义
|
||||||
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
|
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
|
||||||
- `0003_injuries`: 增加 injuries 表
|
|
||||||
- `0004_snapshot_and_constraints`: 增加约束
|
|
||||||
- `0005_prediction_status_and_stats_provenance`: 增加时间语义
|
|
||||||
- `0006-0012`: 后续 schema 调整、约束命名对齐、partial unique index 等
|
|
||||||
|
|
||||||
## 备份与恢复
|
## 备份与恢复
|
||||||
|
|
||||||
@@ -200,113 +143,6 @@ cat backup.sql | docker exec -i profeto-postgres psql -U football football
|
|||||||
|
|
||||||
## 监控
|
## 监控
|
||||||
|
|
||||||
### 健康检查
|
- `/health`: 存活检查
|
||||||
|
- 日志:容器 stdout(`docker compose logs -f api`)
|
||||||
| 端点 | 含义 | HTTP 状态码 |
|
- 评估汇总:`GET /api/v1/eval/summary`(准确率/RMSAE/校准度)
|
||||||
|---|---|---|
|
|
||||||
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
|
|
||||||
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
|
|
||||||
|
|
||||||
### 探针配置
|
|
||||||
|
|
||||||
#### Docker Compose
|
|
||||||
|
|
||||||
`docker-compose.yml` 已为 `api` 服务配置 readiness:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
```
|
|
||||||
|
|
||||||
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
|
|
||||||
|
|
||||||
#### Kubernetes
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8000
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 15
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health/ready
|
|
||||||
port: 8000
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 10
|
|
||||||
failureThreshold: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
**两探针必须区分**:
|
|
||||||
- `livenessProbe` 用 `/health`:仅在进程死锁/崩溃时重启,避免误杀。
|
|
||||||
- `readinessProbe` 用 `/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
|
|
||||||
|
|
||||||
#### 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 宿主机直接运行(经本地 8000 端口)
|
|
||||||
python3 tests/test_health_ready.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### 评估
|
|
||||||
|
|
||||||
## 安全与限流
|
|
||||||
|
|
||||||
### 内存限流(按进程)
|
|
||||||
|
|
||||||
`/api/v1/predict` 与登录防爆破均使用**进程内内存**计数:
|
|
||||||
|
|
||||||
| 机制 | 位置 | 局限 |
|
|
||||||
|------|------|------|
|
|
||||||
| `/predict` 限流 | `_RateLimiter`(内存) | 每 worker 独立计数,不共享 |
|
|
||||||
| 登录防爆破 | `_fail_times`(内存) | 同上 |
|
|
||||||
|
|
||||||
**多 worker 部署时**(如 `uvicorn --workers 4`),每进程各自计数,实际限额为 `N × 单进程限制`。
|
|
||||||
|
|
||||||
### 公网部署建议
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────┐ ┌──────────┐ ┌──────────┐
|
|
||||||
│ Client │────▶│ Nginx │────▶│ API │
|
|
||||||
│ │ │ 限流层 │ │ 内存限流 │
|
|
||||||
└─────────┘ └──────────┘ └──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**推荐配置**:
|
|
||||||
|
|
||||||
1. **Nginx 层限流**(第一道防线):
|
|
||||||
```nginx
|
|
||||||
limit_req_zone $binary_remote_addr zone=predict:10m rate=10r/m;
|
|
||||||
location /api/v1/predict {
|
|
||||||
limit_req zone=predict burst=20 nodelay;
|
|
||||||
proxy_pass http://api:8000;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **TRUST_PROXY_HEADERS=True** 时必须由可信反代设置 `X-Forwarded-For`:
|
|
||||||
```nginx
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
```
|
|
||||||
- 若为 `False`: 仅用 `request.client.host`,忽略 `X-Forwarded-For`,防伪造
|
|
||||||
- 若为 `True`: 解析 `X-Forwarded-For` 第一个 IP,反代后方可信
|
|
||||||
|
|
||||||
3. **生产环境必须配置管理鉴权**:
|
|
||||||
```bash
|
|
||||||
APP_ENV=production
|
|
||||||
REQUIRE_ADMIN_AUTH=True
|
|
||||||
ADMIN_PASSWORD=your_secure_password
|
|
||||||
```
|
|
||||||
未配置时 `/admin` 等管理接口返回 503。
|
|
||||||
|
|
||||||
### 参数调优
|
|
||||||
|
|
||||||
| 参数 | 默认 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `_predict_limiter.max_requests` | 10 | 每分钟每 IP 最大请求数 |
|
|
||||||
| `_predict_limiter.window_seconds` | 60 | 滑动窗口时长 |
|
|
||||||
| `ADMIN_SESSION_TTL_HOURS` | 168 | 管理会话有效期(天) |
|
|
||||||
|
|||||||
+14
-56
@@ -2,70 +2,32 @@
|
|||||||
|
|
||||||
## 本地开发环境搭建
|
## 本地开发环境搭建
|
||||||
|
|
||||||
### 锁定依赖策略
|
|
||||||
|
|
||||||
项目使用 [pip-tools](https://github.com/jazzband/pip-tools) 锁定依赖版本,确保本地、CI、Docker 三端一致:
|
|
||||||
|
|
||||||
| 文件 | 用途 | 生成命令 |
|
|
||||||
|---|---|---|
|
|
||||||
| `requirements.txt` | 生产依赖锁定(含 SHA256 哈希) | `pip-compile pyproject.toml --generate-hashes` |
|
|
||||||
| `requirements-dev.txt` | 开发+CI 依赖锁定(含哈希) | `pip-compile pyproject.toml --extra dev --generate-hashes` |
|
|
||||||
|
|
||||||
### 安装步骤
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆并进入项目
|
# 1. 克隆并进入项目
|
||||||
cd Profeto
|
cd Profeto
|
||||||
|
|
||||||
# 2. 创建虚拟环境
|
# 2. 安装依赖(含 dev)
|
||||||
python3.11 -m venv .venv
|
pip install -e ".[dev]"
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# 3. 安装 pip-tools(用于同步锁定依赖)
|
# 3. 启动 PostgreSQL
|
||||||
pip install pip-tools
|
|
||||||
|
|
||||||
# 4. 同步生产+开发依赖到当前环境(严格按 lock 文件版本,含哈希校验)
|
|
||||||
pip-sync requirements-dev.txt
|
|
||||||
|
|
||||||
# 5. 启动 PostgreSQL(或在 .env 配置外部库)
|
|
||||||
docker run -d --name profeto-pg \
|
docker run -d --name profeto-pg \
|
||||||
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
|
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
|
||||||
-p 5432:5432 postgres:16-alpine
|
-p 5432:5432 postgres:16-alpine
|
||||||
|
|
||||||
# 6. 配置环境变量
|
# 4. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
|
# 编辑 .env 填 LLM_API_KEY / BZZOIRO_KEY
|
||||||
|
|
||||||
# 7. 建表
|
# 5. 建表
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
# 8. 启动 API(热重载)
|
# 6. 启动 API(热重载)
|
||||||
uvicorn src.api.app:app --reload
|
uvicorn src.api.app:app --reload
|
||||||
|
|
||||||
# 9. 启动前端(另一个终端)
|
# 7. 启动前端(另一个终端)
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:不要用 `pip install -e ".[dev]"` 直接安装——它按 pyproject 下界约束解析,版本可能与 lock 文件不一致。统一用 `pip-sync requirements-dev.txt` 保证三端一致。
|
|
||||||
|
|
||||||
### 变更依赖时
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 编辑 pyproject.toml(调整依赖或版本约束)
|
|
||||||
|
|
||||||
# 2. 重新生成 lock 文件(含哈希)
|
|
||||||
pip-compile pyproject.toml --generate-hashes --output-file=requirements.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
pip-compile pyproject.toml --extra dev --generate-hashes --output-file=requirements-dev.txt --index-url=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
|
|
||||||
# 3. 同步到本地环境
|
|
||||||
pip-sync requirements-dev.txt
|
|
||||||
|
|
||||||
# 4. 提交 lock 文件
|
|
||||||
git add requirements.txt requirements-dev.txt pyproject.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
> **禁止无故大升级主版本依赖**:仅升级真正需要的包,并重新跑全量测试。
|
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -83,16 +45,16 @@ Profeto/
|
|||||||
│ │ └── app.py # FastAPI 工厂
|
│ │ └── app.py # FastAPI 工厂
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ ├── base.py # SQLAlchemy async engine + session
|
│ │ ├── base.py # SQLAlchemy async engine + session
|
||||||
│ │ ├── models.py # 12 张表 ORM
|
│ │ ├── models.py # 6 张表 ORM
|
||||||
│ │ ├── repositories.py # 仓储层(查询封装)
|
│ │ ├── repositories.py # 仓储层(查询封装)
|
||||||
│ │ └── unit_of_work.py # 事务边界
|
│ │ └── unit_of_work.py # 事务边界
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库(唯一数据源)
|
│ │ ├── bzzoiro.py # bzzoiro 采集 + 入库
|
||||||
|
│ │ ├── understat.py # understat xG 回填
|
||||||
|
│ │ ├── injuries.py # 伤停采集
|
||||||
│ │ ├── normalize.py # 数据清洗契约
|
│ │ ├── normalize.py # 数据清洗契约
|
||||||
│ │ ├── team_names.py # 队名归一化映射
|
│ │ ├── team_names.py # 队名归一化映射
|
||||||
│ │ ├── team_names_zh.py # 队名中文名映射
|
|
||||||
│ │ ├── sources.py # 数据源注册表
|
│ │ ├── sources.py # 数据源注册表
|
||||||
│ │ ├── key_ring.py # 数据源 Key 读取(DB 设置优先于 env)
|
|
||||||
│ │ └── config.py # 联赛映射常量
|
│ │ └── config.py # 联赛映射常量
|
||||||
│ ├── llm/
|
│ ├── llm/
|
||||||
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
|
│ │ ├── provider.py # LLM 提供商抽象(OpenAI-compatible)
|
||||||
@@ -110,17 +72,13 @@ Profeto/
|
|||||||
│ │ ├── form_v1.md
|
│ │ ├── form_v1.md
|
||||||
│ │ ├── stats_v1.md
|
│ │ ├── stats_v1.md
|
||||||
│ │ ├── home_away_v1.md
|
│ │ ├── home_away_v1.md
|
||||||
│ │ ├── standings_v1.md
|
│ │ ├── injuries_v1.md
|
||||||
│ │ ├── h2h_v1.md
|
│ │ ├── h2h_v1.md
|
||||||
│ │ └── aggregator_v1.md
|
│ │ └── aggregator_v1.md
|
||||||
│ └── core/
|
│ └── core/
|
||||||
│ ├── config.py # pydantic-settings 配置
|
│ ├── config.py # pydantic-settings 配置
|
||||||
│ ├── http_client.py # 共享 httpx 客户端
|
│ ├── http_client.py # 共享 httpx 客户端
|
||||||
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
|
│ └── retry.py # 重试工具
|
||||||
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
|
|
||||||
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
|
|
||||||
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
|
|
||||||
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
|
|
||||||
├── frontend/ # React 单页前端
|
├── frontend/ # React 单页前端
|
||||||
├── alembic/ # 数据库迁移
|
├── alembic/ # 数据库迁移
|
||||||
│ └── versions/
|
│ └── versions/
|
||||||
@@ -198,7 +156,7 @@ cp src/llm/prompts/agents/h2h_v1.md src/llm/prompts/agents/h2h_v2.md
|
|||||||
|
|
||||||
### 3. 新增数据源
|
### 3. 新增数据源
|
||||||
|
|
||||||
1. 在 `src/data/` 写采集模块(参考 `bzzoiro.py`)
|
1. 在 `src/data/` 写采集模块(参考 `understat.py`)
|
||||||
2. 在 `normalize.py` 加清洗函数
|
2. 在 `normalize.py` 加清洗函数
|
||||||
3. 在 `context_builder.py` 加切片函数
|
3. 在 `context_builder.py` 加切片函数
|
||||||
4. 在 `api/routes/ingest.py` 加端点
|
4. 在 `api/routes/ingest.py` 加端点
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
> ⚠️ **历史文档(已过时)**:伤停(injuries)数据源与 api-football 集成已移除,
|
|
||||||
> 数据源收敛为 bzzoiro 唯一来源。本文仅作历史决策记录保留,
|
|
||||||
> 现状请见 [05-data.md](05-data.md) 与 [01-architecture.md](01-architecture.md)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
"""检查 injuries 数据源 api-football 的响应结构。"""
|
"""检查 injuries 数据源 api-football 的响应结构。"""
|
||||||
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
|
API_FOOTBALL_INJURY_RESPONSE_EXAMPLE = """
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# 前端构建时不需要的文件
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
*.log
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Fix 4: 前端多阶段构建 —— 构建静态文件 + nginx 服务
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# 构建
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# 生产阶段: nginx 服务静态文件
|
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# 从 builder 阶段复制构建产物
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# nginx 配置(在 compose 中通过 volume 挂载,此处仅作备用)
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }
|
|
||||||
+1
-40
@@ -3,46 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Profeto - 足球 LLM 预测</title>
|
||||||
<!--
|
|
||||||
SEO / 分享 / 移动端元信息。
|
|
||||||
此前这里只有 charset + viewport + title —— 搜索引擎无摘要、
|
|
||||||
分享到社交平台无预览卡片、iOS 添加到主屏是截图占位、
|
|
||||||
移动端浏览器地址栏不染色。品牌在浏览器层面完全裸奔。
|
|
||||||
-->
|
|
||||||
<title>Profeto · 足球赛程与 LLM 预测</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Profeto 提供主流联赛赛程、实时积分榜,以及由多专家大语言模型生成的比分与胜平负预测。仅供研究参考,不构成投注建议。"
|
|
||||||
/>
|
|
||||||
<meta name="theme-color" content="#FDFCF8" />
|
|
||||||
|
|
||||||
<!-- 索引控制:公开站点允许收录,但不索引管理后台 -->
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
|
|
||||||
<!-- 图标:SVG 优先(任意缩放清晰),PNG 作为老浏览器兜底 -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
|
|
||||||
<!-- Open Graph:微信 / 飞书 / 社交平台分享卡片 -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:site_name" content="Profeto" />
|
|
||||||
<meta property="og:title" content="Profeto · 足球赛程与 LLM 预测" />
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
|
||||||
/>
|
|
||||||
<meta property="og:locale" content="zh_CN" />
|
|
||||||
|
|
||||||
<!-- Twitter / X 卡片 -->
|
|
||||||
<meta name="twitter:card" content="summary" />
|
|
||||||
<meta name="twitter:title" content="Profeto · 足球赛程与 LLM 预测" />
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8000/api/;
|
proxy_pass http://api:8000/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
# LLM 多专家预测/回测耗时长(可达数分钟),默认 60s 会掐断请求返回 504
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_send_timeout 60s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "profeto-frontend",
|
"name": "profeto-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/noto-serif-sc": "^5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.6"
|
"react-router-dom": "^6.30.6"
|
||||||
@@ -709,6 +710,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/noto-serif-sc": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
+3
-12
@@ -5,32 +5,23 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && sh scripts/verify-tokens.sh",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
||||||
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
|
|
||||||
"verify:tokens": "sh scripts/verify-tokens.sh"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/noto-serif-sc": "^5.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.30.6"
|
"react-router-dom": "^6.30.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^9.39.5",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.7",
|
|
||||||
"globals": "^17.12.0",
|
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.6",
|
"tailwindcss": "^3.4.6",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.70.1",
|
|
||||||
"vite": "^5.3.4"
|
"vite": "^5.3.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-2601
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 650 B |
Binary file not shown.
|
Before Width: | Height: | Size: 128 B |
Binary file not shown.
|
Before Width: | Height: | Size: 182 B |
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 334 B |
@@ -1,46 +0,0 @@
|
|||||||
#!/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
-66
@@ -1,84 +1,81 @@
|
|||||||
/**
|
/**
|
||||||
* 主应用入口
|
* 主应用入口
|
||||||
*
|
*
|
||||||
* 页面结构:
|
* 整合前台(报纸风格)和后台(暗色管理)的路由。
|
||||||
* - / → 先知主站(报纸风赛程 + 预测),报头含「评估」「管理」入口
|
* - / → 先知(Profeto)主站
|
||||||
* - /admin/* → 管理后台(鉴权门禁,未登录引导登录)
|
* - /admin/* → 管理后台
|
||||||
*
|
|
||||||
* 导航策略:
|
|
||||||
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
|
||||||
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
|
||||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
|
||||||
*
|
|
||||||
* 代码分割:
|
|
||||||
* - 公开页(Matches/Standings)各自 lazy,后台整体懒加载。
|
|
||||||
* 此前所有页面打进同一个 chunk,一个只想看赛程的匿名访客也要
|
|
||||||
* 下载 Dashboard/Collection/Logs/Eval/Backtest/... 十个后台页面。
|
|
||||||
* - ErrorBoundary 下沉到每个路由级:某个页面渲染崩溃时降级为
|
|
||||||
* 「该页面报错」,而不是整个应用白屏。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react'
|
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import { SiteLayout } from './components/SiteLayout'
|
import Matches from './pages/Matches'
|
||||||
import { adminRoutes } from './admin/routes'
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
// ── 路由级懒加载 ────────────────────────────────────────────────
|
/** 报眉日期行 */
|
||||||
// 每个入口拆成独立 chunk,首屏只加载当前路由所需代码。
|
function dateLine(): string {
|
||||||
const Matches = lazy(() => import('./pages/Matches'))
|
return new Date().toLocaleDateString('zh-CN', {
|
||||||
const Standings = lazy(() => import('./pages/Standings'))
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
/**
|
day: 'numeric',
|
||||||
* 路由级加载占位。
|
weekday: 'long',
|
||||||
* 保持与应用一致的纸色背景与居中位置,避免切换时出现白闪。
|
})
|
||||||
*/
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 期号:当年第几天,报纸连载的仪式感 */
|
||||||
* 单个路由的错误与加载边界。
|
function issueNo(): number {
|
||||||
*
|
const now = new Date()
|
||||||
* 注意 ErrorBoundary 必须包在 Suspense 外层:lazy 组件加载失败
|
const start = new Date(now.getFullYear(), 0, 0)
|
||||||
* (网络中断、部署后 chunk 哈希变化)会以错误形式抛出,而不是
|
return Math.floor((now.getTime() - start.getTime()) / 86400000)
|
||||||
* 永远停在加载态。
|
|
||||||
*/
|
|
||||||
function RouteShell({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ErrorBoundary fullScreen={false}>
|
|
||||||
<Suspense fallback={<RouteFallback />}>{children}</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<SiteLayout
|
// grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
|
||||||
active="home"
|
<div className="grain min-h-screen bg-paper-50">
|
||||||
footerNote="预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议"
|
{/* ── 报头:Oxford 双线 + 大字刊名 + 期号报眉 ── */}
|
||||||
|
<header className="masthead-rule rise-in">
|
||||||
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
|
<div className="border-b border-ink-900 py-6 text-center sm:py-8">
|
||||||
|
<h1 className="font-serif text-5xl font-bold leading-none tracking-[0.16em] text-ink-900 sm:text-6xl">
|
||||||
|
先知
|
||||||
|
<span className="wordmark ml-3 align-baseline text-base font-normal italic tracking-normal text-ink-500 sm:text-lg">
|
||||||
|
Profeto
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 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 className="tabular-nums">
|
||||||
|
<span className="text-ink-700">第 {issueNo()} 期</span>
|
||||||
|
<span className="mx-2 text-ink-300" aria-hidden="true">|</span>
|
||||||
|
{dateLine()}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="tracking-[0.3em] text-ink-400">体育版</span>
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex min-h-[32px] items-center gap-1.5 text-press transition-colors hover:text-press-dark"
|
||||||
>
|
>
|
||||||
<RouteShell>
|
<span aria-hidden="true">⚙</span>
|
||||||
<Matches />
|
管理后台
|
||||||
</RouteShell>
|
</Link>
|
||||||
</SiteLayout>
|
</span>
|
||||||
)
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
function StandingsPage() {
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
return (
|
<Matches />
|
||||||
<SiteLayout active="standings" footerNote="数据由 bzzoiro 提供 · 仅供研究参考">
|
</main>
|
||||||
<RouteShell>
|
|
||||||
<Standings />
|
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||||
</RouteShell>
|
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||||
</SiteLayout>
|
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +85,6 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/standings" element={<StandingsPage />} />
|
|
||||||
{adminRoutes.map(route => (
|
{adminRoutes.map(route => (
|
||||||
<Route key={route.path} path={route.path} element={route.element}>
|
<Route key={route.path} path={route.path} element={route.element}>
|
||||||
{route.children.map(child => (
|
{route.children.map(child => (
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* 后台导航图标集。
|
|
||||||
*
|
|
||||||
* 此前这 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
|
|
||||||
@@ -6,35 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'
|
||||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api'
|
import { fetchHealth } from './dal'
|
||||||
import { fetchHealth } from '../api/dal'
|
|
||||||
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
|
||||||
import Login from './Login'
|
|
||||||
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
|
||||||
import { AdminIcon } from './AdminIcon'
|
|
||||||
|
|
||||||
// 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch,
|
const NAV_ITEMS = [
|
||||||
// 让布局文件同时承担页面骨架与图标图形库两种职责。
|
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||||
|
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||||
/**
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
* 侧栏导航项的 className 生成器。
|
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
||||||
*
|
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
||||||
* 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项),
|
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||||
* 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里
|
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||||
* 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。
|
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||||
*/
|
]
|
||||||
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 {
|
function dateLine(): string {
|
||||||
@@ -49,32 +33,7 @@ function dateLine(): string {
|
|||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const palette = useCommandPalette(COMMAND_PALETTE_PAGES)
|
|
||||||
|
|
||||||
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true
|
|
||||||
fetchAuthState()
|
|
||||||
.then(s => alive && setAuthed(s.authenticated))
|
|
||||||
.catch(() => alive && setAuthed(false))
|
|
||||||
const onUnauthorized = () => setAuthed(false)
|
|
||||||
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
|
|
||||||
return () => {
|
|
||||||
alive = false
|
|
||||||
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await logout()
|
|
||||||
} catch {
|
|
||||||
/* 会话可能已失效,直接切回登录页 */
|
|
||||||
}
|
|
||||||
setAuthed(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
const checkHealth = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -106,68 +65,8 @@ export default function AdminLayout() {
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
|
|
||||||
if (authed === null) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
|
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
|
||||||
正在验证登录状态…
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!authed) {
|
|
||||||
return <Login onSuccess={() => setAuthed(true)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-paper-50 text-ink-800">
|
|
||||||
{/* ── 通栏顶部报眉 ── */}
|
|
||||||
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 bg-paper-50 px-4 lg:px-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* 移动端汉堡按钮 */}
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
|
|
||||||
aria-label="打开菜单"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/* 面包屑 */}
|
|
||||||
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
|
|
||||||
<a href="/admin" className="hover:text-ink-700">仪表盘</a>
|
|
||||||
{location.pathname !== '/admin' && (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true">/</span>
|
|
||||||
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-2xs">
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-ink-500">
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 ${
|
|
||||||
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
|
|
||||||
}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-ink-500 transition-colors hover:text-press"
|
|
||||||
title="退出登录"
|
|
||||||
>
|
|
||||||
登出
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── 主体:侧边栏 + 内容 ── */}
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* ── 移动端遮罩层 ── */}
|
{/* ── 移动端遮罩层 ── */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -187,67 +86,107 @@ export default function AdminLayout() {
|
|||||||
`}
|
`}
|
||||||
aria-label="主导航"
|
aria-label="主导航"
|
||||||
>
|
>
|
||||||
{/* 侧栏顶部 */}
|
{/* 报头 */}
|
||||||
<div className="flex items-center border-b border-ink-900 px-5 py-4">
|
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
|
||||||
<h1 className="font-brush text-xl text-ink-900">先知</h1>
|
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
|
||||||
|
先知
|
||||||
|
<span className="wordmark ml-2 align-baseline 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>
|
</div>
|
||||||
|
|
||||||
{/* 导航:分组 + 小节标题 */}
|
{/* 导航:选中项以墨色方块标记(红色只留给异常状态,与前台纪律一致) */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
<nav className="flex-1 overflow-y-auto px-3 py-4" 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">
|
<ul className="space-y-0.5">
|
||||||
{section.items.map(item => (
|
{NAV_ITEMS.map(item => (
|
||||||
<li key={item.to}>
|
<li key={item.to}>
|
||||||
<NavLink to={item.to} className={sidebarLinkClass}>
|
<NavLink
|
||||||
<AdminIcon name={item.icon} />
|
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-paper-200/70 font-medium text-ink-900'
|
||||||
|
: '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-ink-900' : 'bg-transparent'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-base leading-none opacity-50" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 底部 */}
|
{/* 底部 */}
|
||||||
<div className="border-t border-ink-200 px-4 py-3 space-y-2">
|
<div className="border-t border-ink-200 px-4 py-3">
|
||||||
<a
|
<Link
|
||||||
href="/"
|
to="/"
|
||||||
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
返回前台版面
|
返回前台版面
|
||||||
</a>
|
</Link>
|
||||||
<p className="text-2xs text-ink-300">v1.0</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── 主内容区 ── */}
|
{/* ── 主内容区 ── */}
|
||||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8" key={location.pathname}>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="mx-auto max-w-6xl page-content-enter">
|
{/* 报眉:日期 + 系统状态 */}
|
||||||
|
<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>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-press transition-colors hover:text-press-dark sm:hidden"
|
||||||
|
aria-label="返回前台"
|
||||||
|
>
|
||||||
|
前台
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 页面内容 */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 命令面板(⌘K) */}
|
|
||||||
<CommandPalette
|
|
||||||
open={palette.open}
|
|
||||||
query={palette.query}
|
|
||||||
setQuery={palette.setQuery}
|
|
||||||
items={palette.items}
|
|
||||||
onClose={() => palette.setOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
|
|
||||||
*
|
|
||||||
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
|
|
||||||
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { fetchLLMAgents, updateSetting, clearSetting } from '../api/dal'
|
|
||||||
import type { LLMAgentConfig } from '../api/types'
|
|
||||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
|
||||||
import { Button, Input } from '../components/ui'
|
|
||||||
|
|
||||||
type FieldKey = 'model' | 'base_url' | 'api_key'
|
|
||||||
|
|
||||||
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
|
|
||||||
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
|
|
||||||
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
|
|
||||||
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
|
|
||||||
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
|
|
||||||
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
|
|
||||||
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AgentLLMCard() {
|
|
||||||
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
||||||
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
setAgents(await fetchLLMAgents())
|
|
||||||
} catch {
|
|
||||||
setAgents([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
function toggleExpand(agent: LLMAgentConfig) {
|
|
||||||
if (expandedId === agent.id) {
|
|
||||||
setExpandedId(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExpandedId(agent.id)
|
|
||||||
setNotice(null)
|
|
||||||
// 预填非敏感覆盖值;API Key 不回填
|
|
||||||
setForm({
|
|
||||||
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
|
|
||||||
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
|
|
||||||
api_key: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave(agent: LLMAgentConfig) {
|
|
||||||
setBusy(true)
|
|
||||||
setNotice(null)
|
|
||||||
try {
|
|
||||||
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
|
|
||||||
if (nonEmpty.length === 0) {
|
|
||||||
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const f of nonEmpty) {
|
|
||||||
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
|
|
||||||
}
|
|
||||||
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
|
|
||||||
setExpandedId(null)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReset(agent: LLMAgentConfig) {
|
|
||||||
setBusy(true)
|
|
||||||
setNotice(null)
|
|
||||||
try {
|
|
||||||
for (const f of FIELD_META) {
|
|
||||||
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
|
|
||||||
}
|
|
||||||
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
|
|
||||||
setExpandedId(null)
|
|
||||||
await load()
|
|
||||||
} catch (err) {
|
|
||||||
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasOverride = (agent: LLMAgentConfig) =>
|
|
||||||
Object.values(agent.fields).some(f => f.origin === 'db')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="专家与终裁 LLM 配置"
|
|
||||||
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
|
||||||
action={
|
|
||||||
<Button size="sm" onClick={load} disabled={loading}>
|
|
||||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2 px-4 sm:px-5">
|
|
||||||
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
|
|
||||||
</div>
|
|
||||||
) : agents.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-xs text-ink-400">无法加载角色配置</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{notice && (
|
|
||||||
<div className="px-4 pb-2 sm:px-5">
|
|
||||||
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{agents.map(agent => {
|
|
||||||
const expanded = expandedId === agent.id
|
|
||||||
return (
|
|
||||||
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
|
|
||||||
{/* 行:角色名 + 生效模型 + 配置按钮 */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
|
|
||||||
{hasOverride(agent) && <Badge status="success">独立配置</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
|
|
||||||
<Button size="sm" className="flex-shrink-0" onClick={() => toggleExpand(agent)} disabled={busy}>
|
|
||||||
{expanded ? '收起' : '配置'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开的编辑表单 */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
|
|
||||||
{FIELD_META.map(f => {
|
|
||||||
const state = agent.fields[f.key]
|
|
||||||
return (
|
|
||||||
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
|
|
||||||
<label htmlFor={`llm-${f.key}`} className="text-xs text-ink-500">{f.label}</label>
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
id={`llm-${f.key}`}
|
|
||||||
type={f.sensitive ? 'password' : 'text'}
|
|
||||||
value={form[f.key]}
|
|
||||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
|
||||||
placeholder={
|
|
||||||
f.key === 'api_key' && state.origin === 'db'
|
|
||||||
? `已覆盖(${state.masked}),留空保持不变`
|
|
||||||
: f.hint
|
|
||||||
}
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-1">
|
|
||||||
<p className="text-2xs text-ink-400">
|
|
||||||
生效模型:{agent.effective_model}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{hasOverride(agent) && (
|
|
||||||
<Button size="sm" onClick={() => handleReset(agent)} disabled={busy}>
|
|
||||||
恢复继承
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="solid" size="sm" onClick={() => handleSave(agent)} disabled={busy}>
|
|
||||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 登录页(报刊风)
|
|
||||||
*
|
|
||||||
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { ApiError, login } from '../api/api'
|
|
||||||
import { Button, Input } from '../components/ui'
|
|
||||||
|
|
||||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!password || submitting) return
|
|
||||||
setSubmitting(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
await login(password)
|
|
||||||
onSuccess()
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof ApiError
|
|
||||||
? err.message.split('\n')[0]
|
|
||||||
: '登录失败,请检查网络连接',
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
|
|
||||||
<header className="masthead-rule">
|
|
||||||
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
|
|
||||||
<div className="border-b border-ink-900 py-5 text-center">
|
|
||||||
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
|
|
||||||
先知
|
|
||||||
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
|
|
||||||
Profeto
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500">管理后台 · 管理员登录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="flex flex-1 items-start justify-center px-5 py-10">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="w-full max-w-sm border border-ink-300 bg-paper-50 p-6 shadow-print"
|
|
||||||
>
|
|
||||||
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
|
|
||||||
管理密码
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="admin-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="current-password"
|
|
||||||
/* 错误时把输入框标记为无效并关联错误文本,
|
|
||||||
屏幕阅读器才能知道「密码错了」以及错在哪 */
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
aria-describedby={error ? 'admin-password-error' : undefined}
|
|
||||||
className="mt-2 w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p
|
|
||||||
id="admin-password-error"
|
|
||||||
/* role=alert 让错误出现时立即被朗读(aria-live 的 assertive 语义) */
|
|
||||||
role="alert"
|
|
||||||
className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button variant="solid" className="mt-5 w-full justify-center" type="submit" disabled={!password || submitting}>
|
|
||||||
{submitting ? '验证中…' : '登 录'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
|
||||||
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="pb-8 text-center text-2xs text-ink-400">
|
|
||||||
<a href="/" className="transition-colors hover:text-press">
|
|
||||||
← 返回前台版面
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ src/admin/
|
|||||||
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
|
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
|
||||||
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
||||||
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
||||||
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
|
└── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 页面说明
|
## 页面说明
|
||||||
@@ -69,8 +69,8 @@ src/admin/
|
|||||||
- 可用模型列表
|
- 可用模型列表
|
||||||
|
|
||||||
### 8. 系统配置 (`/admin/config`)
|
### 8. 系统配置 (`/admin/config`)
|
||||||
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
|
- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带;
|
||||||
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
|
后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥
|
||||||
- 配置列表: 脱敏显示 .env 配置项
|
- 配置列表: 脱敏显示 .env 配置项
|
||||||
- 配置修改指南: SSH 修改 .env + 重启服务
|
- 配置修改指南: SSH 修改 .env + 重启服务
|
||||||
|
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 配置项行组件(报刊风)
|
|
||||||
*
|
|
||||||
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
|
|
||||||
* 编辑态:输入框 + 保存/取消
|
|
||||||
* 由数据源页与 LLM 配置页共用。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import type { DataSourceSetting } from '../api/types'
|
|
||||||
import { Badge, Spinner } from './components'
|
|
||||||
import { Button, Input } from '../components/ui'
|
|
||||||
|
|
||||||
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
|
|
||||||
db: { text: '数据库覆盖', status: 'success' },
|
|
||||||
env: { text: '.env 默认', status: 'info' },
|
|
||||||
none: { text: '未配置', status: 'error' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingRow({
|
|
||||||
setting,
|
|
||||||
editing,
|
|
||||||
busy,
|
|
||||||
onEdit,
|
|
||||||
onCancel,
|
|
||||||
onSave,
|
|
||||||
onClear,
|
|
||||||
detectModels,
|
|
||||||
}: {
|
|
||||||
setting: DataSourceSetting
|
|
||||||
editing: boolean
|
|
||||||
busy: boolean
|
|
||||||
onEdit: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
onSave: (value: string) => void
|
|
||||||
onClear: () => void
|
|
||||||
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
|
|
||||||
detectModels?: () => Promise<string[]>
|
|
||||||
}) {
|
|
||||||
const [value, setValue] = useState('')
|
|
||||||
const origin = ORIGIN_BADGE[setting.origin]
|
|
||||||
|
|
||||||
// 行内模型检测
|
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [detected, setDetected] = useState<string[] | null>(null)
|
|
||||||
const [detectError, setDetectError] = useState('')
|
|
||||||
|
|
||||||
// 进入编辑态时清空上次的检测结果
|
|
||||||
useEffect(() => {
|
|
||||||
if (editing) {
|
|
||||||
setDetected(null)
|
|
||||||
setDetectError('')
|
|
||||||
}
|
|
||||||
}, [editing])
|
|
||||||
|
|
||||||
async function handleDetect() {
|
|
||||||
if (!detectModels || detecting) return
|
|
||||||
setDetecting(true)
|
|
||||||
setDetectError('')
|
|
||||||
try {
|
|
||||||
setDetected(await detectModels())
|
|
||||||
} catch (err) {
|
|
||||||
setDetected(null)
|
|
||||||
setDetectError(err instanceof Error ? err.message : '检测失败')
|
|
||||||
} finally {
|
|
||||||
setDetecting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editing) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
|
|
||||||
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
|
|
||||||
<span className="break-all font-mono text-ink-800">{setting.key}</span>
|
|
||||||
{setting.sensitive && <Badge status="warning">敏感</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<Input
|
|
||||||
type={setting.sensitive ? 'password' : 'text'}
|
|
||||||
value={value}
|
|
||||||
onChange={e => setValue(e.target.value)}
|
|
||||||
placeholder={`输入新的 ${setting.label}`}
|
|
||||||
/* 无可见 label(标题即配置项名),用 aria-label 提供可访问名称 */
|
|
||||||
aria-label={`设置项 ${setting.label} 的值`}
|
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="solid" size="sm" onClick={() => onSave(value)} disabled={!value.trim() || busy}>
|
|
||||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={onCancel} disabled={busy}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detectModels && (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<Button size="sm" onClick={handleDetect} disabled={detecting}>
|
|
||||||
{detecting ? (<><Spinner /> 检测中</>) : detected ? '重新检测' : '检测可用模型'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{detectError && (
|
|
||||||
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
|
|
||||||
{detectError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detected && detected.length > 0 && (
|
|
||||||
<div className="max-h-48 overflow-y-auto border border-ink-200">
|
|
||||||
{detected.map(id => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setValue(id)}
|
|
||||||
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
|
|
||||||
value === id ? 'bg-press-wash/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
|
|
||||||
{value === id && <Badge status="success">已选</Badge>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detected && detected.length === 0 && !detectError && (
|
|
||||||
<p className="text-2xs text-ink-400">服务未返回可用模型</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
|
|
||||||
<Badge status={origin.status}>{origin.text}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
|
|
||||||
{setting.configured ? setting.masked : '—'}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
|
||||||
{setting.configured ? '更换' : '配置'}
|
|
||||||
</Button>
|
|
||||||
{setting.origin === 'db' && (
|
|
||||||
<Button size="sm" onClick={onClear} disabled={busy} title="删除数据库覆盖值,回落 .env">
|
|
||||||
回落 .env
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+110
-3
@@ -1,5 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/api.ts`。
|
* Admin 后台管理系统 - 统一 API 客户端
|
||||||
* 新代码请直接从 `../api/api` 导入。
|
*
|
||||||
|
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
|
||||||
|
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带。
|
||||||
*/
|
*/
|
||||||
export * from '../api/api'
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
const TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
const ADMIN_KEY_STORAGE = 'profeto_admin_key'
|
||||||
|
|
||||||
|
/** 读取本机保存的管理员密钥 */
|
||||||
|
export function getAdminKey(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ADMIN_KEY_STORAGE) ?? ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存/清除管理员密钥(传空字符串即清除) */
|
||||||
|
export function setAdminKey(key: string): void {
|
||||||
|
try {
|
||||||
|
if (key) localStorage.setItem(ADMIN_KEY_STORAGE, key)
|
||||||
|
else localStorage.removeItem(ADMIN_KEY_STORAGE)
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式等场景下不可用,静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {}): Promise<T> {
|
||||||
|
// 修复: 正确拼接 API_BASE
|
||||||
|
const url = path.startsWith('http')
|
||||||
|
? path
|
||||||
|
: path.startsWith('/')
|
||||||
|
? path // 已经是绝对路径(如 /health)
|
||||||
|
: `${API_BASE}${path}`
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminKey = getAdminKey()
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(adminKey ? { 'X-API-Key': adminKey } : {}),
|
||||||
|
...options.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}`
|
||||||
|
if (res.status === 401) {
|
||||||
|
message += '\n请在「系统配置」页填写管理员密钥后重试。'
|
||||||
|
}
|
||||||
|
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) =>
|
||||||
|
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export { API_BASE }
|
||||||
|
|||||||
@@ -1,27 +1,338 @@
|
|||||||
/**
|
/**
|
||||||
* 【兼容壳】组件已合并到 `src/components/ui/`。
|
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||||
*
|
*
|
||||||
* 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。
|
* 与前台共用同一套设计语言:
|
||||||
* 二者分裂导致 Spinner 出现三份逐字节相同的副本,且前台页面要跨目录
|
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||||
* `import '../admin/components'` 才能拿到通用组件。
|
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||||
*
|
|
||||||
* 现已全部归入 `src/components/ui/`,本文件保留为再导出壳,
|
|
||||||
* 使既有 import 路径继续可用。**新代码请直接从 `components/ui` 导入。**
|
|
||||||
*
|
|
||||||
* 本文件不含任何组件实现。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Card, CardHeader, CardBody } from '../components/ui/Card'
|
import { ReactNode } from 'react'
|
||||||
export { Badge } from '../components/ui/Badge'
|
|
||||||
export { StatCard, ProgressBar, AgentWeightsBar } from '../components/ui/Stat'
|
|
||||||
export { SectionHeader } from '../components/ui/SectionHeader'
|
|
||||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from '../components/ui/Feedback'
|
|
||||||
export { DataTable, MobileCardList, ResponsiveTable } from '../components/ui/DataTable'
|
|
||||||
export { Spinner } from '../components/ui'
|
|
||||||
|
|
||||||
/**
|
// ── 卡片 ────────────────────────────────────────────────────────
|
||||||
* 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。
|
|
||||||
* 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`),
|
export function Card({
|
||||||
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
|
children,
|
||||||
*/
|
className = '',
|
||||||
export { Skeleton as SkeletonBlock } from '../components/ui'
|
}: {
|
||||||
|
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'
|
||||||
|
title: string
|
||||||
|
message?: string
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
const style =
|
||||||
|
kind === 'error'
|
||||||
|
? 'border-press bg-press-wash'
|
||||||
|
: kind === 'ok'
|
||||||
|
? 'border-ink-900 bg-paper-100'
|
||||||
|
: 'border-ink-300 bg-paper-50'
|
||||||
|
const titleCls = kind === 'error' ? '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' ? '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 加载指示:同前台 Spinner ────────────────────────────────────
|
||||||
|
|
||||||
|
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}`} />
|
||||||
|
}
|
||||||
|
|||||||
+235
-5
@@ -1,8 +1,238 @@
|
|||||||
/**
|
/**
|
||||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/dal.ts`。
|
* Admin 后台 - 数据访问层
|
||||||
*
|
*
|
||||||
* 迁移原因见 `src/api/public.ts` 头注释:admin 目录不应承载被前台
|
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||||
* 反向依赖的数据层。本文件仅为不破坏旧 import 路径而保留,
|
* 所有端点对齐 FastAPI 后端实际实现。
|
||||||
* **新代码请直接从 `../api/dal`(admin 内)或 `../api`(其余位置)导入。*
|
|
||||||
*/
|
*/
|
||||||
export * from '../api/dal'
|
|
||||||
|
import { api, API_BASE } from './api'
|
||||||
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
CollectionRequest,
|
||||||
|
BacktestRequest,
|
||||||
|
BacktestSummary,
|
||||||
|
League,
|
||||||
|
Match,
|
||||||
|
Prediction,
|
||||||
|
EvalSummary,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从多个端点聚合仪表盘数据。
|
||||||
|
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||||
|
*/
|
||||||
|
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||||
|
// 并行获取各端点数据
|
||||||
|
const [leagues, matches, predictions, health] = await Promise.allSettled([
|
||||||
|
api.get<League[]>(`${API_BASE}/leagues`),
|
||||||
|
api.get<Match[]>(`${API_BASE}/matches?limit=1`),
|
||||||
|
api.get<Prediction[]>(`${API_BASE}/predictions?limit=1`),
|
||||||
|
api.get<{ status: string }>('/health'),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
||||||
|
total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.total ?? 0 : 0,
|
||||||
|
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.total ?? 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: 'finished',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(): Promise<EvalSummary | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<EvalSummary>(`${API_BASE}/eval/summary`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||||
|
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试数据源连接 — 调用采集 API 验证连通性
|
||||||
|
*/
|
||||||
|
export async function testDataSource(source: 'bzzoiro' | 'understat' | 'injuries'): Promise<any> {
|
||||||
|
const sourceMap: Record<string, { path: string; body: any }> = {
|
||||||
|
bzzoiro: { path: `${API_BASE}/ingest/bzzoiro`, body: { leagues: [], date_from: '', date_to: '', status: 'finished' } },
|
||||||
|
understat: { path: `${API_BASE}/ingest/understat`, body: { league: 'EPL', season: new Date().getFullYear() } },
|
||||||
|
injuries: { path: `${API_BASE}/ingest/injuries`, body: { date: new Date().toISOString().slice(0, 10) } },
|
||||||
|
}
|
||||||
|
const cfg = sourceMap[source]
|
||||||
|
if (!cfg) throw new Error(`未知数据源: ${source}`)
|
||||||
|
return api.post(cfg.path, cfg.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据源状态 — 后端暂无专用端点,返回模拟状态
|
||||||
|
*/
|
||||||
|
export async function fetchDataSourceStatuses(): Promise<any[]> {
|
||||||
|
// 后端暂无专用配置端点,返回静态信息
|
||||||
|
return [
|
||||||
|
{ name: 'bzzoiro', label: 'Bzzoiro', keyConfigured: true, maskedKey: 'bz***xxx', lastIngestion: null, status: 'configured' },
|
||||||
|
{ name: 'understat', label: 'Understat', keyConfigured: true, maskedKey: '无需 Key', lastIngestion: null, status: 'configured' },
|
||||||
|
{ name: 'injuries', label: 'Injuries', keyConfigured: true, maskedKey: 'inj***xxx', lastIngestion: null, status: 'configured' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 LLM 连接 — 调用预测端点验证
|
||||||
|
*/
|
||||||
|
export async function testLLMConnection(matchId?: number): Promise<any> {
|
||||||
|
return api.post(`${API_BASE}/predict`, {
|
||||||
|
match_id: matchId || 1,
|
||||||
|
mode: 'single',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 系统配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统配置列表 — 后端暂无配置端点,返回静态信息
|
||||||
|
*/
|
||||||
|
export async function fetchSystemConfig(): Promise<any[]> {
|
||||||
|
return [
|
||||||
|
{ key: 'LLM_PROVIDER', value_masked: 'openai', description: 'LLM 提供商', is_sensitive: false },
|
||||||
|
{ key: 'LLM_MODEL', value_masked: 'gpt-4o', description: 'LLM 模型', is_sensitive: false },
|
||||||
|
{ key: 'LLM_BASE_URL', value_masked: 'https://api.openai.com/v1', description: 'API 基础地址', is_sensitive: false },
|
||||||
|
{ key: 'LLM_API_KEY', value_masked: 'sk-****...****', description: 'LLM API 密钥', is_sensitive: true },
|
||||||
|
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥', is_sensitive: true },
|
||||||
|
{ key: 'DATABASE_URL', value_masked: 'postgresql://****@localhost/profeto', description: '数据库连接', is_sensitive: true },
|
||||||
|
{ key: 'LOG_LEVEL', value_masked: 'INFO', description: '日志级别', is_sensitive: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,19 +11,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../../api/types'
|
import type { BacktestRequest, EvalSummary, League } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
import TeamSideTag from '../../components/TeamSideTag'
|
|
||||||
import { Button, Input, Select } from '../../components/ui'
|
|
||||||
|
|
||||||
interface BacktestResultRow {
|
interface BacktestResultRow {
|
||||||
match_id: number
|
match_id: number
|
||||||
league_code?: string | null
|
league_code?: string | null
|
||||||
home_team: string
|
home_team: string
|
||||||
away_team: string
|
away_team: string
|
||||||
home_team_zh?: string | null
|
|
||||||
away_team_zh?: string | null
|
|
||||||
match_date?: string | null
|
match_date?: string | null
|
||||||
actual_score: string
|
actual_score: string
|
||||||
actual_1x2?: string
|
actual_1x2?: string
|
||||||
@@ -35,44 +31,21 @@ interface BacktestResultRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BacktestResponse {
|
interface BacktestResponse {
|
||||||
summary: BacktestSummary
|
summary: {
|
||||||
|
total: number
|
||||||
|
scored: number
|
||||||
|
accuracy_1x2?: number
|
||||||
|
avg_score_rmse?: number
|
||||||
|
avg_subjective_confidence?: number
|
||||||
|
}
|
||||||
results: BacktestResultRow[]
|
results: BacktestResultRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
|
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||||
function exportCsv(rows: BacktestResultRow[]) {
|
|
||||||
const header = [
|
|
||||||
"比赛日期", "联赛", "主队", "客队", "实际比分", "实际1X2",
|
|
||||||
"预测主球", "预测客球", "预测1X2", "主观置信度", "1X2命中",
|
|
||||||
]
|
|
||||||
const lines = [header.join(",")]
|
|
||||||
for (const r of rows) {
|
|
||||||
lines.push([
|
|
||||||
fmtDate(r.match_date), r.league_code ?? "",
|
|
||||||
csvCell(r.home_team_zh || r.home_team), csvCell(r.away_team_zh || r.away_team),
|
|
||||||
r.actual_score, r.actual_1x2 ?? "",
|
|
||||||
r.pred_home ?? "", r.pred_away ?? "", r.pred_1x2 ?? "",
|
|
||||||
r.subjective_confidence != null ? String(Math.round(r.subjective_confidence * 100)) : "",
|
|
||||||
r.correct_1x2 ? "是" : "否",
|
|
||||||
].join(","))
|
|
||||||
}
|
|
||||||
const blob = new Blob(["" + lines.join("\n")], { type: "text/csv;charset=utf-8" })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = url
|
|
||||||
a.download = `backtest_${new Date().toLocaleDateString('sv-SE')}.csv`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CSV 字段转义:含逗号/引号/换行时加引号 */
|
|
||||||
function csvCell(v: string): string {
|
|
||||||
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(s?: string | null): string {
|
function fmtDate(s?: string | null): string {
|
||||||
if (!s) return '—'
|
if (!s) return '—'
|
||||||
return new Date(s).toLocaleDateString('sv-SE')
|
return s.slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BacktestPage() {
|
export default function BacktestPage() {
|
||||||
@@ -81,8 +54,7 @@ export default function BacktestPage() {
|
|||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [limit, setLimit] = useState(20)
|
const [limit, setLimit] = useState(20)
|
||||||
const [mode] = useState<'multi'>('multi')
|
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
||||||
const [model, setModel] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<BacktestResponse | null>(null)
|
const [result, setResult] = useState<BacktestResponse | null>(null)
|
||||||
@@ -115,7 +87,6 @@ export default function BacktestPage() {
|
|||||||
date_to: dateTo || undefined,
|
date_to: dateTo || undefined,
|
||||||
limit,
|
limit,
|
||||||
mode,
|
mode,
|
||||||
model: model.trim() || undefined,
|
|
||||||
}
|
}
|
||||||
const res = await triggerBacktest(req as BacktestRequest)
|
const res = await triggerBacktest(req as BacktestRequest)
|
||||||
setResult(res as unknown as BacktestResponse)
|
setResult(res as unknown as BacktestResponse)
|
||||||
@@ -143,10 +114,10 @@ export default function BacktestPage() {
|
|||||||
<form onSubmit={handleBacktest} className="space-y-4">
|
<form onSubmit={handleBacktest} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||||
<Select
|
<select
|
||||||
value={leagueId}
|
value={leagueId}
|
||||||
onChange={e => setLeagueId(e.target.value)}
|
onChange={e => setLeagueId(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
>
|
>
|
||||||
<option value="">全部联赛</option>
|
<option value="">全部联赛</option>
|
||||||
{leagues.map(l => (
|
{leagues.map(l => (
|
||||||
@@ -154,71 +125,60 @@ export default function BacktestPage() {
|
|||||||
{l.name_zh || l.name}
|
{l.name_zh || l.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-from">起始日期</label>
|
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||||
<Input id="bt-date-from"
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={e => setDateFrom(e.target.value)}
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-to">结束日期</label>
|
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||||
<Input id="bt-date-to"
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dateTo}
|
value={dateTo}
|
||||||
onChange={e => setDateTo(e.target.value)}
|
onChange={e => setDateTo(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-limit">场数限制</label>
|
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||||
<Input id="bt-limit"
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={200}
|
max={200}
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-mode">模式</label>
|
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||||
<Input id="bt-mode"
|
<select
|
||||||
type="text"
|
value={mode}
|
||||||
value="多专家 (5 路 + 终裁)"
|
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||||
readOnly
|
className="field w-full"
|
||||||
className="w-full bg-paper-100 text-ink-500"
|
>
|
||||||
/>
|
<option value="single">单次调用 (快)</option>
|
||||||
|
<option value="multi">多专家 (慢,贵)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">
|
|
||||||
指定模型(可选,空=默认 <code className="font-mono">gpt-4o</code>)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={model}
|
|
||||||
onChange={e => setModel(e.target.value)}
|
|
||||||
placeholder="如 deepseek-chat / 留空使用默认"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||||
|
|
||||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -227,31 +187,15 @@ export default function BacktestPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader title="回测结果" />
|
||||||
title="回测结果"
|
|
||||||
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`}
|
|
||||||
action={
|
|
||||||
result?.results?.length
|
|
||||||
? (<Button size="sm" onClick={() => exportCsv(result.results)}>导出 CSV</Button>)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||||
{summary.scored}/{summary.total}
|
{summary.scored}/{summary.total}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
|
||||||
{summary.success}/{summary.degraded}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">
|
|
||||||
成功 / 降级<span className="text-ink-300"> (degraded)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||||
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
||||||
@@ -276,11 +220,6 @@ export default function BacktestPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-2xs text-ink-400">平均置信度</div>
|
<div className="mt-1 text-2xs text-ink-400">平均置信度</div>
|
||||||
</div>
|
</div>
|
||||||
{summary.degraded > 0 && (
|
|
||||||
<div className="col-span-full border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
|
||||||
有 {summary.degraded} 场预测降级(专家无有效结论),未计入准确率分子。建议检查该时段数据完整性。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -292,9 +231,9 @@ export default function BacktestPage() {
|
|||||||
title="模型评估"
|
title="模型评估"
|
||||||
description="已结算预测的准确率统计"
|
description="已结算预测的准确率统计"
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={loadEval} disabled={evalLoading}>
|
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||||
</Button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -340,12 +279,8 @@ export default function BacktestPage() {
|
|||||||
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
|
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
|
||||||
{fmtDate(r.match_date)}
|
{fmtDate(r.match_date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
|
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
|
||||||
<TeamSideTag side="home" />
|
{r.home_team} vs {r.away_team}
|
||||||
<span className="truncate">{r.home_team_zh || r.home_team}</span>
|
|
||||||
<span className="flex-shrink-0 text-ink-300">vs</span>
|
|
||||||
<TeamSideTag side="away" />
|
|
||||||
<span className="truncate">{r.away_team_zh || r.away_team}</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
|
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
|
||||||
实际 <span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
|
实际 <span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
|
||||||
|
|||||||
@@ -1,65 +1,55 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
* Admin 后台 - 数据采集页面(报刊风)
|
||||||
*
|
|
||||||
* 三个采集任务:
|
|
||||||
* events — 比赛日程与比分
|
|
||||||
* standings — 联赛积分榜
|
|
||||||
* stats — 已完赛比赛详细统计回填
|
|
||||||
* all — 依次执行以上三项
|
|
||||||
*
|
*
|
||||||
* 响应式布局: 移动端单列,桌面端双列
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
|
import { triggerCollection, fetchLeagues } from '../dal'
|
||||||
import type { IngestJob, League } from '../../api/types'
|
import type { CollectionRequest, League } from '../types'
|
||||||
import type { CollectionRequest } from '../../api/types'
|
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
import { Button, Input, Select } from '../../components/ui'
|
|
||||||
|
|
||||||
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
const SOURCES = [
|
||||||
const TASKS = [
|
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
||||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
|
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
||||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
|
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
||||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
|
|
||||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
/** 把采集接口返回摘要成一两行可读文字 */
|
||||||
|
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
||||||
const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['success', 'failed'])
|
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) ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
export default function CollectionPage() {
|
export default function CollectionPage() {
|
||||||
const [leagues, setLeagues] = useState<League[]>([])
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
const [task, setTask] = useState<string>('events')
|
const [source, setSource] = useState<string>('bzzoiro')
|
||||||
const [leagueCode, setLeagueCode] = useState('')
|
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 [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
const [season, setSeason] = useState('')
|
const [season, setSeason] = useState('')
|
||||||
const [ingestStatus, setIngestStatus] = useState('')
|
|
||||||
const [limit, setLimit] = useState(100)
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 loadLeagues = useCallback(async () => {
|
||||||
const lg = await fetchLeagues()
|
const lg = await fetchLeagues()
|
||||||
@@ -68,137 +58,35 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setJobInfo(null)
|
setResult(null)
|
||||||
setJobId(null)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setTaskStatus('running')
|
|
||||||
setTaskStartedAt(Date.now())
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: CollectionRequest = {
|
const body: CollectionRequest = {
|
||||||
source: 'bzzoiro',
|
source: source as CollectionRequest['source'],
|
||||||
leagues: leagueCode ? [leagueCode] : undefined,
|
leagues: leagueCode ? [leagueCode] : undefined,
|
||||||
task: task as CollectionRequest['task'],
|
league: leagueCode || undefined,
|
||||||
limit,
|
|
||||||
season: season || undefined,
|
season: season || undefined,
|
||||||
status: ingestStatus || undefined,
|
date_from: dateFrom || undefined,
|
||||||
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
date_to: dateTo || undefined,
|
||||||
date_to: isEventsTask ? dateTo || undefined : undefined,
|
|
||||||
}
|
}
|
||||||
const res = await triggerCollection(body)
|
const res = await triggerCollection(body)
|
||||||
const id: string | undefined = res?.job_id
|
setResult(summarizeResult(res, source))
|
||||||
if (id) {
|
|
||||||
setJobId(id)
|
|
||||||
startJobPolling(id)
|
|
||||||
} else {
|
|
||||||
// 后端未返回 job_id(旧版兼容):退化为原逻辑
|
|
||||||
setTimeout(() => { setTaskStatus('done'); }, 30_000)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setTaskStatus('error')
|
|
||||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||||
stopPolling()
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = elapsedSec
|
|
||||||
const summary = jobInfo ? jobSummary(jobInfo) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="数据采集"
|
title="数据采集"
|
||||||
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
@@ -207,239 +95,116 @@ export default function CollectionPage() {
|
|||||||
<CardHeader title="新建采集任务" />
|
<CardHeader title="新建采集任务" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* 任务类型 */}
|
{/* 数据源选择 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">采集任务</label>
|
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<select
|
||||||
{TASKS.map(t => (
|
value={source}
|
||||||
<button
|
onChange={e => setSource(e.target.value)}
|
||||||
key={t.value}
|
className="field w-full"
|
||||||
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>
|
{SOURCES.map(s => (
|
||||||
<span className="font-medium">{t.label}</span>
|
<option key={s.value} value={s.value}>
|
||||||
</button>
|
{s.label} — {s.desc}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 联赛选择 */}
|
{/* 联赛选择 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-league">联赛</label>
|
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||||
<Select
|
<select
|
||||||
id="col-league"
|
|
||||||
value={leagueCode}
|
value={leagueCode}
|
||||||
onChange={e => setLeagueCode(e.target.value)}
|
onChange={e => setLeagueCode(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
>
|
>
|
||||||
<option value="">全部联赛</option>
|
<option value="">全部联赛</option>
|
||||||
{leagues.map(l => (
|
{leagues.map(l => (
|
||||||
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
{/* Understat 专用: 赛季 */}
|
||||||
{isEventsTask && (
|
{source === 'understat' && (
|
||||||
<>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-status">比赛状态</label>
|
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
||||||
<Select
|
<input
|
||||||
id="col-status"
|
type="number"
|
||||||
value={ingestStatus}
|
value={season}
|
||||||
onChange={e => setIngestStatus(e.target.value)}
|
onChange={e => setSeason(e.target.value)}
|
||||||
className="w-full"
|
placeholder="2025"
|
||||||
>
|
className="field w-full"
|
||||||
<option value="">全部(已完赛 + 未开赛)</option>
|
/>
|
||||||
<option value="finished">仅已完赛</option>
|
|
||||||
<option value="scheduled">仅未开赛</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 日期范围 */}
|
||||||
|
{source !== 'injuries' && (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-from">起始日期</label>
|
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||||
<Input id="col-date-from"
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={e => setDateFrom(e.target.value)}
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-to">结束日期</label>
|
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||||
<Input id="col-date-to"
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dateTo}
|
value={dateTo}
|
||||||
onChange={e => setDateTo(e.target.value)}
|
onChange={e => setDateTo(e.target.value)}
|
||||||
className="w-full"
|
className="field w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* standings 任务专用: 赛季 */}
|
|
||||||
{(task === 'standings') && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-season">赛季(留空取当前赛季)</label>
|
|
||||||
<Input id="col-season"
|
|
||||||
type="text"
|
|
||||||
value={season}
|
|
||||||
onChange={e => setSeason(e.target.value)}
|
|
||||||
placeholder="如 2026-2027"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* stats 任务专用: 回填数量 */}
|
|
||||||
{(task === 'stats') && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={500}
|
|
||||||
value={limit}
|
|
||||||
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-2xs text-ink-400">
|
|
||||||
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 消息提示 */}
|
{/* 消息提示 */}
|
||||||
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
||||||
{summary && (
|
{result && (
|
||||||
<Alert
|
<Alert
|
||||||
kind={jobInfo?.status === 'success' ? 'ok' : 'error'}
|
kind="ok"
|
||||||
title={summary.title}
|
title={result.title}
|
||||||
message={summary.detail}
|
message={result.detail || undefined}
|
||||||
|
onClose={() => setResult(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
{/* 提交按钮 */}
|
||||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||||
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 任务状态 + 数据源说明 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 任务进度 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="任务状态" />
|
|
||||||
<CardBody>
|
|
||||||
{taskStatus === 'idle' && (
|
|
||||||
<p className="text-xs text-ink-400">尚未触发任务。</p>
|
|
||||||
)}
|
|
||||||
{taskStatus === 'running' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-ink-700">
|
|
||||||
<Spinner />
|
|
||||||
<span>任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-2xs text-ink-400">
|
|
||||||
后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{taskStatus === 'done' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-ok-700">
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
|
|
||||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
|
||||||
</div>
|
|
||||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{taskStatus === 'error' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-press">采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p>
|
|
||||||
{jobInfo?.error && (
|
|
||||||
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{jobInfo?.created_at && (
|
|
||||||
<p className="mt-2 text-2xs text-ink-400">
|
|
||||||
创建于 {new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })}
|
|
||||||
{jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 最近任务历史 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="最近任务"
|
|
||||||
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 => {
|
|
||||||
const st = j.status
|
|
||||||
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
|
|
||||||
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
|
|
||||||
return (
|
|
||||||
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
|
||||||
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
|
|
||||||
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
|
||||||
<span className="text-2xs text-ink-600">{label}</span>
|
|
||||||
<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>
|
<Card>
|
||||||
<CardHeader title="采集任务说明" />
|
<CardHeader title="数据源说明" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{TASKS.map(t => (
|
{SOURCES.map(s => (
|
||||||
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<span>{t.icon}</span>
|
<Badge status="info">{s.label}</Badge>
|
||||||
<Badge status="info">{t.label}</Badge>
|
<p className="text-xs text-ink-600">{s.desc}</p>
|
||||||
<p className="text-xs text-ink-600">{t.desc}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||||
采集接口需要管理员登录(401 表示登录已过期)。
|
若后端配置了 ADMIN_API_KEY,采集接口需要管理员密钥。
|
||||||
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
遇到 401 请到「系统配置」页填写密钥。
|
||||||
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
|
||||||
</p>
|
</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 系统配置管理页面(报刊风)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口
|
||||||
|
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
||||||
|
* - 配置修改指南
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchSystemConfig } from '../dal'
|
||||||
|
import { getAdminKey, setAdminKey } 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 [adminKey, setAdminKeyInput] = useState('')
|
||||||
|
const [keySaved, setKeySaved] = useState(false)
|
||||||
|
const [keyExists, setKeyExists] = useState(false)
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchSystemConfig()
|
||||||
|
setConfig(data)
|
||||||
|
} catch {
|
||||||
|
setConfig([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
const stored = getAdminKey()
|
||||||
|
setKeyExists(stored !== '')
|
||||||
|
}, [loadConfig])
|
||||||
|
|
||||||
|
function handleSaveKey(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setAdminKey(adminKey.trim())
|
||||||
|
setKeyExists(adminKey.trim() !== '')
|
||||||
|
setKeySaved(true)
|
||||||
|
setAdminKeyInput('')
|
||||||
|
setTimeout(() => setKeySaved(false), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearKey() {
|
||||||
|
setAdminKey('')
|
||||||
|
setAdminKeyInput('')
|
||||||
|
setKeyExists(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="系统配置"
|
||||||
|
description="管理员密钥管理与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 管理员密钥 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="管理员密钥 (X-API-Key)"
|
||||||
|
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
<form onSubmit={handleSaveKey} className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={adminKey}
|
||||||
|
onChange={e => setAdminKeyInput(e.target.value)}
|
||||||
|
placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'}
|
||||||
|
autoComplete="off"
|
||||||
|
className="field flex-1"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={!adminKey.trim()} className="btn btn-solid">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearKey}
|
||||||
|
disabled={!keyExists}
|
||||||
|
className="btn btn-sm"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keySaved && <Alert kind="ok" title="密钥已保存,后续请求将自动附带" />}
|
||||||
|
{keyExists && !keySaved && (
|
||||||
|
<p className="text-2xs text-ink-500">
|
||||||
|
当前状态:<Badge status="success">已保存密钥</Badge>
|
||||||
|
<span className="ml-2">密钥仅保存在本机浏览器,不会上传到任何第三方。</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||||
|
密钥与服务器 .env 中 ADMIN_API_KEY 一致即可。留空时后端默认不鉴权(本地开发模式)。
|
||||||
|
遇到 401 错误通常就是缺这个密钥。
|
||||||
|
</p>
|
||||||
|
</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_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,240 +1,141 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
* Admin 后台 - 仪表盘(报刊风)
|
||||||
*
|
*
|
||||||
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
||||||
* 而不是扫描一堆常驻数字。
|
|
||||||
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
|
||||||
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
|
||||||
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
|
||||||
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { fetchDashboard, fetchHealth } from '../dal'
|
||||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
|
import type { DashboardStats } from '../types'
|
||||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
||||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
|
|
||||||
import { Card, CardBody, CardHeader, SkeletonBlock } 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() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [data, setData] = useState<DashboardStats | null>(null)
|
||||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
const [health, setHealth] = useState<any>(null)
|
||||||
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 [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
Promise.all([fetchDashboard(), fetchHealth()])
|
||||||
const [s, i, d, f, c, p] = await Promise.allSettled([
|
.then(([stats, h]) => {
|
||||||
fetchAdminStats(),
|
if (active) {
|
||||||
fetchIngestStatus(),
|
setData(stats)
|
||||||
fetchDashboard(),
|
setHealth(h)
|
||||||
fetchIngestFailures(),
|
}
|
||||||
fetchDataCompleteness(),
|
})
|
||||||
fetchPredictions(100),
|
.catch((err: unknown) => {
|
||||||
])
|
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
||||||
if (s.status === 'fulfilled') setStats(s.value)
|
})
|
||||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
.finally(() => {
|
||||||
if (d.status === 'fulfilled') setDash(d.value)
|
if (active) setLoading(false)
|
||||||
if (f.status === 'fulfilled') setFailures(f.value)
|
})
|
||||||
if (c.status === 'fulfilled') setCompleteness(c.value)
|
return () => { active = false }
|
||||||
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
|
|
||||||
setLoading(false)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 待办计算 ──
|
const healthText =
|
||||||
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── 待办行:只有真有待办才出现 ── */}
|
{/* 统计:报纸数字版式 */}
|
||||||
{loading ? (
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<SkeletonBlock className="h-14 w-full" />
|
<StatCard
|
||||||
) : todos.length > 0 ? (
|
label="健康状态"
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
value={loading ? '—' : healthText}
|
||||||
{todos.map(t => (
|
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="数据源"
|
|
||||||
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
|
||||||
/>
|
/>
|
||||||
<CardBody className="px-0">
|
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
||||||
{loading ? (
|
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
||||||
<div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const st = bzzoiro
|
|
||||||
const hasData = st && st.recent_count > 0
|
|
||||||
const keyOk = st?.key_configured !== false
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
|
||||||
<span className="text-xs font-medium 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>
|
||||||
)
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
{/* 联赛列表 */}
|
||||||
<Card>
|
|
||||||
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
|
||||||
<CardBody>
|
|
||||||
{stats ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">预测 · 近 24 小时</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">预测 · 近 7 天</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">预测 · 累计</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── 已入库联赛 ── */}
|
|
||||||
{dash && dash.leagues.length > 0 && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="已入库联赛" />
|
<CardHeader title="已入库联赛" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{dash.leagues.map(l => (
|
{[1, 2, 3, 4].map(i => (
|
||||||
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
<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"
|
||||||
|
>
|
||||||
{l.name_zh || l.name}
|
{l.name_zh || l.name}
|
||||||
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据源管理页面(报刊风)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
|
||||||
|
* - 显示 API Key 配置状态(脱敏显示)
|
||||||
|
* - 测试连接按钮(调用采集 API 验证)
|
||||||
|
* - 数据源说明
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchDataSourceStatuses, testDataSource } from '../dal'
|
||||||
|
import type { DataSourceStatus } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
|
export default function DataSourcesPage() {
|
||||||
|
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
const loadSources = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchDataSourceStatuses()
|
||||||
|
setSources(data)
|
||||||
|
} catch {
|
||||||
|
setSources([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadSources() }, [loadSources])
|
||||||
|
|
||||||
|
async function handleTest(sourceName: string) {
|
||||||
|
setTestingSource(sourceName)
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
|
||||||
|
try {
|
||||||
|
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '连接失败'
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
|
||||||
|
} finally {
|
||||||
|
setTestingSource(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据源管理"
|
||||||
|
description="数据采集源的配置状态与连通性测试。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据源卡片 */}
|
||||||
|
{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]
|
||||||
|
return (
|
||||||
|
<Card key={source.name}>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
|
||||||
|
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
|
||||||
|
<Badge status={source.keyConfigured ? 'success' : 'error'}>
|
||||||
|
{source.keyConfigured ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key 状态 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-ink-400">API Key</span>
|
||||||
|
<span className="font-mono text-ink-600">{source.maskedKey}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-ink-400">最近采集</span>
|
||||||
|
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试结果 */}
|
||||||
|
{result && (
|
||||||
|
<Alert
|
||||||
|
kind={result.success ? 'ok' : 'error'}
|
||||||
|
title={result.success ? '连接成功' : '连接失败'}
|
||||||
|
message={result.success ? undefined : result.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(source.name)}
|
||||||
|
disabled={testingSource === source.name}
|
||||||
|
className="btn btn-sm w-full"
|
||||||
|
>
|
||||||
|
{testingSource === source.name ? (<><Spinner /> 测试中</>) : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 数据源说明 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="数据源说明" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
|
||||||
|
<div className="border-t border-ink-200 pt-3">
|
||||||
|
<Badge status="info">Bzzoiro</Badge>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||||
|
历史赛程与比分数据,覆盖全球主要联赛。需要 API Key 配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-ink-200 pt-3">
|
||||||
|
<Badge status="info">Understat</Badge>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||||
|
xG(预期进球)进阶数据,无需 API Key,通过网页抓取获取。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-ink-200 pt-3">
|
||||||
|
<Badge status="info">Injuries</Badge>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-ink-600">
|
||||||
|
球员伤停信息,用于预测时考虑阵容完整性。需要 API Key 配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 评估汇总页(报刊风)
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 按 provider / model / prompt_version / mode / league_code 筛选
|
|
||||||
* - 展示汇总统计卡片(已结算/已评估/跳过数)
|
|
||||||
* - 准确率对比表格
|
|
||||||
* - 空态与加载态
|
|
||||||
*/
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
|
||||||
import type { EvalSummary, EvalSummaryRow, EvalCalibrationBucket } from '../../api/types'
|
|
||||||
import { Card, CardBody, CardHeader, StatCard, DataTable, Alert, Spinner, EmptyText } from '../components'
|
|
||||||
import { Button, Input, Select } from '../../components/ui'
|
|
||||||
|
|
||||||
interface Filters {
|
|
||||||
provider: string
|
|
||||||
model: string
|
|
||||||
prompt_version: string
|
|
||||||
mode: string
|
|
||||||
league_code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { provider: '', model: '', prompt_version: '', mode: '', league_code: '' }
|
|
||||||
|
|
||||||
export default function EvalPage() {
|
|
||||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
|
|
||||||
const [leagues, setLeagues] = useState<Array<{ code: string; name: string }>>([])
|
|
||||||
const [data, setData] = useState<EvalSummary | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLeagues()
|
|
||||||
.then(l => setLeagues(l.map(x => ({ code: x.code, name: x.name }))))
|
|
||||||
.catch(() => setLeagues([]))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const params: Record<string, string> = {}
|
|
||||||
if (filters.provider) params.provider = filters.provider
|
|
||||||
if (filters.model) params.model = filters.model
|
|
||||||
if (filters.prompt_version) params.prompt_version = filters.prompt_version
|
|
||||||
if (filters.mode) params.mode = filters.mode
|
|
||||||
if (filters.league_code) params.league_code = filters.league_code
|
|
||||||
const result = await fetchEvalSummary(params)
|
|
||||||
setData(result)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : '加载失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [filters])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleChange = (key: keyof Filters) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
|
|
||||||
setFilters(f => ({ ...f, [key]: e.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = () => setFilters(EMPTY_FILTERS)
|
|
||||||
|
|
||||||
const summary = data?.summary ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 筛选控件 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="筛选条件" description="按提供商 / 模型 / 版本 / 模式 / 联赛过滤评估数据" />
|
|
||||||
<CardBody>
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-2xs text-ink-500">提供商</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={filters.provider}
|
|
||||||
onChange={handleChange('provider')}
|
|
||||||
placeholder="如 openai"
|
|
||||||
className="mt-1 w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-2xs text-ink-500">模型</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={filters.model}
|
|
||||||
onChange={handleChange('model')}
|
|
||||||
placeholder="如 gpt-4o"
|
|
||||||
className="mt-1 w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-2xs text-ink-500">Prompt 版本</span>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={filters.prompt_version}
|
|
||||||
onChange={handleChange('prompt_version')}
|
|
||||||
placeholder="如 v1"
|
|
||||||
className="mt-1 w-full"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-2xs text-ink-500">模式</span>
|
|
||||||
<Select
|
|
||||||
value={filters.mode}
|
|
||||||
onChange={handleChange('mode')}
|
|
||||||
className="mt-1 w-full"
|
|
||||||
>
|
|
||||||
<option value="">全部</option>
|
|
||||||
<option value="single">single</option>
|
|
||||||
<option value="multi">multi</option>
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="text-2xs text-ink-500">联赛</span>
|
|
||||||
<Select
|
|
||||||
value={filters.league_code}
|
|
||||||
onChange={handleChange('league_code')}
|
|
||||||
className="mt-1 w-full"
|
|
||||||
>
|
|
||||||
<option value="">全部</option>
|
|
||||||
{leagues.map(l => (
|
|
||||||
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<Button size="sm" disabled={loading} onClick={load}>
|
|
||||||
{loading ? '加载中…' : '应用筛选'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" disabled={loading} onClick={handleReset}>
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 错误态 */}
|
|
||||||
{error && <Alert kind="error" title="加载失败" message={error} />}
|
|
||||||
|
|
||||||
{/* 汇总统计 */}
|
|
||||||
{data && (
|
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
||||||
<StatCard label="已结算总数" value={data.total_settled} hint="含 degraded" />
|
|
||||||
<StatCard label="筛选后已结算" value={data.filtered_settled} hint="应用筛选条件后" />
|
|
||||||
<StatCard label="实际评估" value={data.evaluated} hint="status=success 且比分齐全" />
|
|
||||||
<StatCard label="跳过 degraded" value={data.skipped_degraded} hint="不计入准确率" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 准确率表格 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="准确率对比"
|
|
||||||
description={!loading && summary.length > 0 ? `共 ${summary.length} 组` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : summary.length === 0 ? (
|
|
||||||
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<DataTable<EvalSummaryRow>
|
|
||||||
columns={[
|
|
||||||
{ key: 'provider', label: '提供商' },
|
|
||||||
{ key: 'model', label: '模型' },
|
|
||||||
{ key: 'prompt_version', label: '版本', render: (row: EvalSummaryRow) => (
|
|
||||||
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
|
|
||||||
) },
|
|
||||||
{ key: 'total', label: '评估条数' },
|
|
||||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: EvalSummaryRow) => (
|
|
||||||
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
|
|
||||||
) },
|
|
||||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: EvalSummaryRow) => (
|
|
||||||
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
|
|
||||||
) },
|
|
||||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: EvalSummaryRow) => (
|
|
||||||
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
|
|
||||||
) },
|
|
||||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: EvalSummaryRow) => (
|
|
||||||
row.calibration ? (
|
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
|
|
||||||
{Object.entries(row.calibration).map(([name, b]: [string, EvalCalibrationBucket]) => (
|
|
||||||
<span key={name} className="inline-flex items-center gap-1">
|
|
||||||
<span className="text-ink-400">{name}:</span>
|
|
||||||
<span className="tabular-nums font-medium">
|
|
||||||
{b.hit_rate != null ? `${b.hit_rate}%` : '—'}
|
|
||||||
</span>
|
|
||||||
<span className="text-ink-300">({b.total})</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : '—'
|
|
||||||
) },
|
|
||||||
]}
|
|
||||||
data={summary}
|
|
||||||
rowKey={(row: EvalSummaryRow) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
|
||||||
emptyText="暂无评估数据"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - LLM 配置管理页面(报刊风)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
|
||||||
|
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
|
||||||
|
* - 显示 LLM 使用统计(从预测记录聚合)
|
||||||
|
* - 可用模型列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
|
||||||
|
import type { LLMUsageStats } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
|
// 可用模型列表
|
||||||
|
const AVAILABLE_MODELS = [
|
||||||
|
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '综合能力最强,适合复杂分析' },
|
||||||
|
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' },
|
||||||
|
{ id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' },
|
||||||
|
{ id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比,中文优化' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 当前配置(后端暂无配置端点,取 .env 约定值展示)
|
||||||
|
const currentConfig = {
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key_configured: true,
|
||||||
|
api_key_masked: 'sk-****...****abcd',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchLLMUsageStats()
|
||||||
|
setStats(data)
|
||||||
|
} catch {
|
||||||
|
setStats(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadStats() }, [loadStats])
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 当前配置 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="当前配置" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{[
|
||||||
|
{ label: '提供商', value: currentConfig.provider, mono: false },
|
||||||
|
{ label: '模型', value: currentConfig.model, mono: true },
|
||||||
|
{ label: 'API 地址', value: currentConfig.base_url, mono: true },
|
||||||
|
{ label: 'API Key', value: currentConfig.api_key_masked, mono: true },
|
||||||
|
{ label: '模式', value: '多专家 (5 路 + 终裁)', mono: false },
|
||||||
|
].map(row => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className="flex items-center justify-between gap-4 border-b border-ink-200 py-2.5 last:border-b-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-ink-400">{row.label}</span>
|
||||||
|
<span className={`text-xs text-ink-800 ${row.mono ? 'break-all font-mono' : ''}`}>
|
||||||
|
{row.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试连接 */}
|
||||||
|
{testResult && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Alert
|
||||||
|
kind={testResult.success ? 'ok' : 'error'}
|
||||||
|
title={testResult.success ? '连接正常' : '连接失败'}
|
||||||
|
message={testResult.success ? undefined : testResult.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
|
||||||
|
{testing ? (<><Spinner /> 测试中</>) : '测试 LLM 连接'}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-center text-2xs text-ink-400">
|
||||||
|
测试会真实调用一次单次模式预测,产生 LLM 费用。
|
||||||
|
</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 使用统计 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="使用统计"
|
||||||
|
description="从最近预测记录聚合"
|
||||||
|
action={
|
||||||
|
<button onClick={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>
|
||||||
|
|
||||||
|
{/* 可用模型 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="可用模型" description="在 .env 中修改 LLM_MODEL 后重启服务生效" />
|
||||||
|
<CardBody className="px-0 sm:px-0">
|
||||||
|
{AVAILABLE_MODELS.map(model => {
|
||||||
|
const isCurrent = model.id === currentConfig.model
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className={`flex flex-col gap-2 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5 ${
|
||||||
|
isCurrent ? 'bg-press-wash/50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-ink-900">{model.label}</span>
|
||||||
|
{isCurrent && <Badge status="success">当前</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-2xs text-ink-500">{model.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-2xs text-ink-400">{model.provider}</span>
|
||||||
|
{!isCurrent && <span className="text-2xs text-ink-400">编辑 .env 切换</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近预测 */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 系统日志页面(报刊风)
|
|
||||||
*
|
|
||||||
* 查看应用运行日志(内存缓冲,最新在前):
|
|
||||||
* - 级别筛选 + 关键字搜索
|
|
||||||
* - 自动刷新(10s)可开关
|
|
||||||
* - 缓冲上限 2000 条,进程重启后清零
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
||||||
import { fetchLogs } from '../../api/dal'
|
|
||||||
import type { LogEntry } from '../../api/types'
|
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
|
||||||
import { Button, Input } from '../../components/ui'
|
|
||||||
|
|
||||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
|
||||||
|
|
||||||
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
|
|
||||||
DEBUG: { status: 'info', text: 'DEBUG' },
|
|
||||||
INFO: { status: 'info', text: 'INFO' },
|
|
||||||
WARNING: { status: 'warning', text: 'WARN' },
|
|
||||||
ERROR: { status: 'error', text: 'ERROR' },
|
|
||||||
CRITICAL: { status: 'error', text: 'FATAL' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTs(ts: number): string {
|
|
||||||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogsPage() {
|
|
||||||
const [entries, setEntries] = useState<LogEntry[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [level, setLevel] = useState<string>('')
|
|
||||||
const [keyword, setKeyword] = useState('')
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
|
|
||||||
setEntries(d.entries)
|
|
||||||
setError('')
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [level, keyword])
|
|
||||||
|
|
||||||
// 筛选条件变化 → 立即拉取
|
|
||||||
useEffect(() => {
|
|
||||||
load()
|
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const userScrolledDown = useRef(false)
|
|
||||||
|
|
||||||
// 检测用户是否向下滚动回看历史(旧日志在下方)
|
|
||||||
const handleScroll = () => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
const atTop = el.scrollTop < 50
|
|
||||||
userScrolledDown.current = !atTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
|
|
||||||
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = 0
|
|
||||||
}
|
|
||||||
}, [entries, autoRefresh])
|
|
||||||
|
|
||||||
// 自动刷新
|
|
||||||
useEffect(() => {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
|
||||||
if (autoRefresh) {
|
|
||||||
timerRef.current = setInterval(load, 10_000)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [autoRefresh, load])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<SectionHeader
|
|
||||||
title="系统日志"
|
|
||||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="日志查看"
|
|
||||||
description={entries.length > 0 ? `显示最新 ${entries.length} 条` : undefined}
|
|
||||||
action={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={e => setAutoRefresh(e.target.checked)}
|
|
||||||
className="accent-current"
|
|
||||||
/>
|
|
||||||
10s 自动刷新
|
|
||||||
</label>
|
|
||||||
<Button size="sm" onClick={load} disabled={loading}>
|
|
||||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{/* 筛选栏 */}
|
|
||||||
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{LEVELS.map(lv => (
|
|
||||||
<Button
|
|
||||||
key={lv || 'all'}
|
|
||||||
onClick={() => setLevel(lv)}
|
|
||||||
variant={level === lv ? 'solid' : 'default'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{lv || '全部'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={keyword}
|
|
||||||
onChange={e => setKeyword(e.target.value)}
|
|
||||||
placeholder="搜索关键字(消息 / logger)…"
|
|
||||||
aria-label="搜索日志关键字"
|
|
||||||
className="w-full sm:w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 日志列表 */}
|
|
||||||
{loading && entries.length === 0 ? (
|
|
||||||
<div className="space-y-2 px-4 py-3 sm:px-5">
|
|
||||||
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
||||||
</div>
|
|
||||||
) : entries.length === 0 ? (
|
|
||||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
|
||||||
) : (
|
|
||||||
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
|
|
||||||
{entries.map((e, i) => {
|
|
||||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${e.ts}-${i}`}
|
|
||||||
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
|
|
||||||
>
|
|
||||||
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
|
|
||||||
<span className="w-14 flex-shrink-0">
|
|
||||||
<Badge status={badge.status}>{badge.text}</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
|
|
||||||
{e.logger}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
|
|
||||||
{e.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 监控页(报刊风·三分区)
|
* Admin 后台 - 监控面板(报刊风)
|
||||||
*
|
*
|
||||||
* 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
|
* 功能:
|
||||||
* 现在聚合全站已有信号,回答三个问题:
|
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||||
* 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
|
* - /health/ready 数据库就绪检查
|
||||||
* 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
|
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||||
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
|
|
||||||
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
|
|
||||||
* 30s 自动巡检 + 手动巡检。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { fetchHealth } from '../dal'
|
||||||
import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../../api/dal'
|
import { api } from '../api'
|
||||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||||
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() {
|
export default function MonitoringPage() {
|
||||||
const [health, setHealth] = useState<HealthProbe | null>(null)
|
const [health, setHealth] = useState<any>(null)
|
||||||
const [ready, setReady] = useState<'ready' | 'not_ready' | 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 [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [lastCheck, setLastCheck] = useState<string>('')
|
const [lastCheck, setLastCheck] = useState<string>('')
|
||||||
@@ -55,28 +22,20 @@ export default function MonitoringPage() {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
// 各信号独立容错:单项失败只降级对应卡片
|
try {
|
||||||
const [h, r, u, j, f, c, l] = await Promise.allSettled([
|
const [h, r] = await Promise.allSettled([
|
||||||
fetchHealth(),
|
fetchHealth(),
|
||||||
api.get<{ status: string }>('/health/ready'),
|
api.get<{ status: string }>('/health/ready'),
|
||||||
fetchUpstreamProbe(),
|
|
||||||
fetchIngestJobs({ limit: 10 }),
|
|
||||||
fetchIngestFailures(),
|
|
||||||
fetchDataCompleteness(),
|
|
||||||
fetchLLMUsageStats() as Promise<LLMStats>,
|
|
||||||
])
|
])
|
||||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : 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') {
|
if (h.status === 'rejected') {
|
||||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||||
}
|
}
|
||||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||||
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,197 +45,108 @@ export default function MonitoringPage() {
|
|||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统监控"
|
title="系统监控"
|
||||||
description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
|
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||||
action={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
|
|
||||||
<Button size="sm" onClick={refresh} disabled={loading}>
|
|
||||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
<div className="flex items-center justify-between">
|
||||||
<Alert kind="error" title="无法连接到后端" message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`} />
|
<span className="text-2xs text-ink-400">
|
||||||
)}
|
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||||
|
</span>
|
||||||
{/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
|
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||||
{!loading && todos.length > 0 ? (
|
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
</button>
|
||||||
{todos.map(t => (
|
|
||||||
<Link
|
|
||||||
key={t.key}
|
|
||||||
to={t.to}
|
|
||||||
className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
|
||||||
>
|
|
||||||
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-press" aria-hidden="true" />
|
|
||||||
<span className="text-xs text-ink-800">{t.label}</span>
|
|
||||||
<span className="ml-auto text-press" aria-hidden="true">→</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
!loading && (
|
{error && (
|
||||||
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
<Alert
|
||||||
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
kind="error"
|
||||||
各项巡检正常:服务、数据库、上游、采集与数据完整性均无异常。
|
title="无法连接到后端"
|
||||||
</p>
|
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置。`}
|
||||||
)
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 基础设施 ── */}
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<section>
|
{/* 存活状态 */}
|
||||||
<h2 className="section-head mb-3">基础设施</h2>
|
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||||
<MetricCard label="存活状态">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span
|
||||||
<span className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} aria-hidden="true" />
|
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'}`}>
|
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||||
{health ? (alive ? '正常' : health.status) : '—'}
|
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</MetricCard>
|
</div>
|
||||||
|
|
||||||
<MetricCard label="数据库就绪">
|
{/* 数据库就绪 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||||
<span className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`} aria-hidden="true" />
|
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||||
<span className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}>
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
|
||||||
|
>
|
||||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</MetricCard>
|
</div>
|
||||||
|
|
||||||
<MetricCard label="版本 / 运行时间">
|
{/* 服务名 */}
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||||
{health?.version ?? '—'}
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</MetricCard>
|
|
||||||
|
|
||||||
<MetricCard label="上游 bzzoiro">
|
{/* 版本 */}
|
||||||
{upstream ? (
|
{health?.version && (
|
||||||
<div>
|
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||||
<span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
|
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||||
<span className={`font-serif text-xl font-bold ${upstream.ok ? 'text-ink-900' : 'text-press'}`}>
|
{health.version}
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<span className="font-serif text-xl text-ink-300">—</span>
|
|
||||||
)}
|
)}
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── 数据管线 ── */}
|
{/* 运行时间 */}
|
||||||
<section>
|
{health?.uptime_seconds != null && (
|
||||||
<h2 className="section-head mb-3">数据管线</h2>
|
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||||
<MetricCard label="最近采集">
|
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||||
{recentJobs && runJobs > 0 ? (
|
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||||
{okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500">成功</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="font-serif text-xl text-ink-300">—</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">
|
|
||||||
{lastJobTime ? `最近 ${new Date(lastJobTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}` : '暂无记录'}
|
|
||||||
</div>
|
|
||||||
</MetricCard>
|
|
||||||
|
|
||||||
<MetricCard label="死信待处理">
|
{/* 检查项 */}
|
||||||
<div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
|
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||||
{deadLetterCount}
|
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||||
|
<div className="text-2xs tracking-[0.2em] text-ink-400">健康检查</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{Object.entries(health.checks).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-2xs text-ink-500">{key}</span>
|
||||||
|
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
|
||||||
|
{String(val)}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div 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>
|
||||||
<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>
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">有统计 / 已完赛</div>
|
)}
|
||||||
</MetricCard>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
|
||||||
|
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, `${home} vs ${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}>
|
||||||
|
{(m.home_team_zh || m.home_team)} vs {(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,533 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,71 +3,32 @@
|
|||||||
*
|
*
|
||||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||||
* 挂载路径: /admin/*
|
* 挂载路径: /admin/*
|
||||||
*
|
|
||||||
* 全部页面采用 `lazy` 动态导入。
|
|
||||||
*
|
|
||||||
* 此前这里是 10 个静态 import:App.tsx 引入 adminRoutes 就把
|
|
||||||
* Dashboard/Collection/Logs/Eval/Backtest/Settings/Monitoring/
|
|
||||||
* DataCompleteness/DataPipeline/PredictionHistory 连同它们的
|
|
||||||
* 数据访问层一次性拉进首屏 —— 一个只想看赛程的匿名访客,
|
|
||||||
* 要为完整的后台界面付出流量。改为 lazy 后,后台代码只在实际
|
|
||||||
* 访问 /admin/* 时才下载。
|
|
||||||
*
|
|
||||||
* AdminLayout 保持静态引入:它是所有后台页面的公共外壳,
|
|
||||||
* 若也 lazy 则会与页面 chunk 形成串行请求(先下载 layout,
|
|
||||||
* 再下载页面),反而更慢。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense } from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
import CollectionPage from './pages/Collection'
|
||||||
const CollectionPage = lazy(() => import('./pages/Collection'))
|
import PredictionsPage from './pages/Predictions'
|
||||||
const DataCompletenessPage = lazy(() => import('./pages/DataCompleteness'))
|
import BacktestPage from './pages/Backtest'
|
||||||
const DataPipelinePage = lazy(() => import('./pages/DataPipeline'))
|
import MonitoringPage from './pages/Monitoring'
|
||||||
const PredictionsPage = lazy(() => import('./pages/PredictionHistory'))
|
import DataSourcesPage from './pages/DataSources'
|
||||||
const BacktestPage = lazy(() => import('./pages/Backtest'))
|
import LLMConfigPage from './pages/LLMConfig'
|
||||||
const MonitoringPage = lazy(() => import('./pages/Monitoring'))
|
import ConfigPage from './pages/Config'
|
||||||
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 = [
|
export const adminRoutes = [
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
element: <AdminLayout />,
|
element: <AdminLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: page(<Dashboard />) },
|
{ index: true, element: <Dashboard /> },
|
||||||
{ path: 'collection', element: page(<CollectionPage />) },
|
{ path: 'collection', element: <CollectionPage /> },
|
||||||
{ path: 'data-completeness', element: page(<DataCompletenessPage />) },
|
{ path: 'predictions', element: <PredictionsPage /> },
|
||||||
{ path: 'data-pipeline', element: page(<DataPipelinePage />) },
|
{ path: 'backtest', element: <BacktestPage /> },
|
||||||
{ path: 'predictions', element: page(<PredictionsPage />) },
|
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||||
{ path: 'backtest', element: page(<BacktestPage />) },
|
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||||
{ path: 'monitoring', element: page(<MonitoringPage />) },
|
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||||
{ path: 'settings', element: page(<SettingsPage />) },
|
{ path: 'config', element: <ConfigPage /> },
|
||||||
{ path: 'logs', element: page(<LogsPage />) },
|
|
||||||
{ path: 'eval', element: page(<EvalPage />) },
|
|
||||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+193
-3
@@ -1,5 +1,195 @@
|
|||||||
/**
|
/**
|
||||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/types.ts`。
|
* Admin 后台 - TypeScript 类型定义
|
||||||
* 新代码请直接从 `../api/types` 导入。
|
*
|
||||||
|
* 与 FastAPI 后端 Pydantic 模型对齐
|
||||||
*/
|
*/
|
||||||
export * from '../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 {
|
||||||
|
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||||
|
leagues?: string[]
|
||||||
|
league?: string
|
||||||
|
season?: string
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EvalSummary {
|
||||||
|
summary: Array<{
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
total: number
|
||||||
|
/** 1X2 准确率,百分数 0-100 */
|
||||||
|
accuracy_1x2?: number
|
||||||
|
avg_score_rmse?: number | null
|
||||||
|
avg_subjective_confidence?: number | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
accuracy_1x2?: number
|
||||||
|
avg_score_rmse?: number
|
||||||
|
results?: Array<{
|
||||||
|
match_id: number
|
||||||
|
actual_home: number
|
||||||
|
actual_away: number
|
||||||
|
pred_home?: number
|
||||||
|
pred_away?: number
|
||||||
|
correct_1x2: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DataSourceStatus {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
keyConfigured: boolean
|
||||||
|
maskedKey: string
|
||||||
|
lastIngestion: string | null
|
||||||
|
status: 'configured' | 'missing_key' | 'untested'
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/**
|
|
||||||
* 应用统一 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 }
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
/**
|
|
||||||
* 应用数据访问层 —— 需要鉴权的管理端点集合。
|
|
||||||
*
|
|
||||||
* 封装所有 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`)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* 前台公开端点 —— 预测主站与积分榜所需的最小数据访问。
|
|
||||||
*
|
|
||||||
* 为什么要单独成文件(而不是继续挂在 `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`)
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
/**
|
|
||||||
* 应用级 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.
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 回到顶部浮动按钮(前台两页共用,此前 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,17 +1,8 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
import { Button } from './ui'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
fallback?: ReactNode
|
fallback?: ReactNode
|
||||||
/**
|
|
||||||
* 该边界是否铺满整个视口。
|
|
||||||
*
|
|
||||||
* 应用最外层的边界应铺满(`true`,默认);而当边界下沉到
|
|
||||||
* 路由级、嵌在 SiteLayout 的 <main> 里时,铺满视口会撑开
|
|
||||||
* 布局并让页头页脚错位 —— 那种场景传 `false`,改为局部卡片。
|
|
||||||
*/
|
|
||||||
fullScreen?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -38,25 +29,20 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
if (this.props.fallback) {
|
if (this.props.fallback) {
|
||||||
return this.props.fallback
|
return this.props.fallback
|
||||||
}
|
}
|
||||||
const fullScreen = this.props.fullScreen ?? true
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||||
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">
|
<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>
|
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
|
||||||
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
||||||
<p className="text-sm leading-relaxed text-ink-500">
|
<p className="text-sm leading-relaxed text-ink-500">
|
||||||
{this.state.error?.message || '未知错误'}
|
{this.state.error?.message || '未知错误'}
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={() => this.setState({ hasError: false, error: null })}>
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
className="btn btn-sm"
|
||||||
|
>
|
||||||
重试
|
重试
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* 报头组件(前台共用)。
|
|
||||||
*
|
|
||||||
* 此前 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* 主客队标志:报刊风小方框字。
|
|
||||||
*
|
|
||||||
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
|
|
||||||
* 用法: <TeamSideTag side="home" /> 队名
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
aria-label={side === 'home' ? '主队' : '客队'}
|
|
||||||
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
|
|
||||||
side === 'home'
|
|
||||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
|
||||||
: 'border-ink-300 text-ink-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{side === 'home' ? '主' : '客'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
|
|
||||||
export function teamSidePrefix(side: 'home' | 'away'): string {
|
|
||||||
return side === 'home' ? '[主] ' : '[客] '
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 状态标记 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* 卡片族: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>
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 数据表格族: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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* 反馈类组件: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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 小节标题 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* 统计与进度可视化: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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'
|
|
||||||
|
|
||||||
// ── 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+74
-177
@@ -2,82 +2,13 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
|
/* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
|
||||||
|
:root {
|
||||||
|
--c-press: #9E1B1B;
|
||||||
|
--c-ink-900: #17140F;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -94,7 +25,7 @@
|
|||||||
|
|
||||||
/* 统一焦点环:印报红细线,键盘可达 */
|
/* 统一焦点环:印报红细线,键盘可达 */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--press);
|
outline: 2px solid var(--c-press);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,115 +57,97 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
/* ── 报头 Oxford 粗细双线:3px 粗线 + 2px 空 + 1px 细线 ── */
|
||||||
.masthead-rule {
|
.masthead-rule {
|
||||||
border-top: 3px solid var(--ink-900);
|
border-top: 3px solid var(--c-ink-900);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.masthead-rule::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--c-ink-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
/* ── 刊名斜体专用:Georgia 提供真斜体(其 old-style 数字不适合比分,已从主栈让位) ── */
|
||||||
|
.wordmark {
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 纵排文字:预测比分两侧的队名,中文报纸原生版面语言 ── */
|
||||||
|
.v-name {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
letter-spacing: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 纸张噪点:feTurbulence 颗粒,给纸面一点纤维感 ──
|
||||||
|
加在页面根元素上,fixed 全屏覆盖,不响应指针 */
|
||||||
|
.grain::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.04;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 入场:整页一次编排好的升起序列 ──
|
||||||
|
只动 opacity/transform;错峰由元素上的 animation-delay 内联指定。
|
||||||
|
隐藏态必须与动画同在一个 no-preference 块里,否则 reduce 用户会看到永久空白 */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.rise-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: rise-in 0.48s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes rise-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 按钮:方正边框式,悬停反白;热区压到 40px(紧凑报纸行距下的折中) ── */
|
||||||
.btn {
|
.btn {
|
||||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3
|
@apply inline-flex min-h-[40px] items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||||
text-sm text-ink-700 transition-colors duration-150
|
text-sm text-ink-700 transition-colors duration-150
|
||||||
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||||
disabled:cursor-not-allowed disabled:opacity-40
|
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 {
|
.btn-sm {
|
||||||
@apply px-2.5 text-xs min-h-[36px];
|
@apply min-h-[32px] px-2.5 py-1 text-xs;
|
||||||
}
|
}
|
||||||
.btn-solid {
|
.btn-solid {
|
||||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||||
}
|
}
|
||||||
.btn-danger {
|
|
||||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
|
||||||
}
|
|
||||||
/* 描边确认按钮:次级动作中需要比默认 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 {
|
.field {
|
||||||
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
@apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
|
||||||
transition-colors hover:border-ink-400
|
transition-colors hover:border-ink-400 focus:border-press;
|
||||||
focus:border-press focus:outline-none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
/* ── 版面切换文字标签(联赛/状态/模式) ──
|
||||||
|
激活态用墨色:印报红只留给 live/终裁/印章/错误,维持 10% 纪律 */
|
||||||
.tab {
|
.tab {
|
||||||
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||||
}
|
}
|
||||||
.tab-on {
|
.tab-on {
|
||||||
@apply font-medium text-press;
|
@apply font-medium text-ink-900;
|
||||||
}
|
}
|
||||||
.tab-on::after {
|
.tab-on::after {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-x-0 bottom-0 h-0.5 bg-press;
|
@apply absolute inset-x-0 bottom-0 h-0.5 bg-ink-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
||||||
@@ -246,22 +159,6 @@
|
|||||||
.skeleton {
|
.skeleton {
|
||||||
@apply animate-pulse rounded-none bg-ink-200;
|
@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 后台 ──
|
/* ── Admin 后台 ──
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* 日期工具(纯函数,零依赖)。
|
|
||||||
*
|
|
||||||
* 从 `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)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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')
|
|
||||||
})
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* 共享 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' }),
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* 滚动相关 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
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
// 中文衬线 webfont:按需子集加载(fontsource 按 unicode-range 切片,只拉用到的字块),
|
||||||
|
// font-display: swap 由包内 CSS 自带,不会白屏
|
||||||
|
import '@fontsource/noto-serif-sc/400.css'
|
||||||
|
import '@fontsource/noto-serif-sc/600.css'
|
||||||
|
import '@fontsource/noto-serif-sc/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
+732
-229
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user