Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2190ea2af6 | ||
|
|
1a37c75907 | ||
|
|
cf0751d1ff | ||
|
|
91e406f5ee | ||
|
|
6680da7d61 | ||
|
|
d3284c48c3 | ||
|
|
1219b4fd18 | ||
|
|
312778d995 |
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -m pytest tests/ -v)",
|
||||
"Bash(python -c \"import src.core.http_client; import src.llm.provider; import src.data.understat; import src.data.injuries; import src.llm.agents.orchestrator; import src.llm.context_builder; import src.api.app; print\\('All imports OK'\\)\")",
|
||||
"Bash(python -c \"from src.data.sources import get_source, list_sources; print\\('sources:', list_sources\\(\\)\\); print\\('bzzoiro:', get_source\\('bzzoiro'\\).name\\); print\\('understat:', get_source\\('understat'\\).name\\)\")",
|
||||
"Bash(python -c \"from src.api.routes.ingest import router; print\\('ingest router OK:', len\\(router.routes\\), 'routes'\\)\")",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(python -m pytest tests/ -q)",
|
||||
"Bash(git -C /p rev-parse --git-dir)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git init *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m 'feat: 足球 LLM 预测服务初始提交 *)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(pg_isready -h localhost -p 5432)",
|
||||
"Bash(python -c \"import pytest_asyncio; print\\('pytest-asyncio OK'\\)\")",
|
||||
"Bash(python -c \"import pytest; print\\('pytest', pytest.__version__\\)\")",
|
||||
"Bash(python -c \"import aiosqlite; print\\('aiosqlite', aiosqlite.__version__\\)\")",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(git revert *)",
|
||||
"Bash(python -c \"from src.api.app import app; print\\('OK'\\)\")",
|
||||
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_bad_values_forgiving -v)",
|
||||
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; print\\('OK'\\)\")",
|
||||
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_full_report -v)",
|
||||
"Bash(python -c \"from src.db.unit_of_work import UnitOfWork, get_uow; from src.db.repositories import MatchRepository, TeamRepository; print\\('OK'\\)\")",
|
||||
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; from src.data.understat import UnderstatSource; from src.data.injuries import ingest_injuries; print\\('OK'\\)\")",
|
||||
"Bash(python -m compileall src tests)",
|
||||
"Bash(alembic current *)",
|
||||
"Bash(alembic history *)",
|
||||
"Bash(python -c \"from src.db.models import MatchStats, Prediction; print\\('Models OK'\\)\")",
|
||||
"Bash(git pull *)",
|
||||
"Bash(ocr delegate *)",
|
||||
"Bash(npm i *)",
|
||||
"Bash(python -c \"from src.llm.validation import KNOWN_AGENT_NAMES; print\\(KNOWN_AGENT_NAMES\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -3,7 +3,7 @@ from logging.config import fileConfig
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlalchemy import engine_from_config, pool, text
|
||||
from alembic import context
|
||||
|
||||
# 把项目根加入 pythonpath,让 alembic 能找到 src 包
|
||||
@@ -29,6 +29,23 @@ if config.config_file_name is not None:
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _ensure_version_table(connection):
|
||||
"""确保 alembic_version 表存在且 version_num 列足够长。
|
||||
|
||||
Alembic 默认 version_num 是 String(32),但我们的迁移名较长(如
|
||||
0005_prediction_status_and_stats_provenance 有 41 个字符),会导致
|
||||
StringDataRightTruncation 错误。
|
||||
"""
|
||||
result = connection.execute(text(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'alembic_version')"
|
||||
))
|
||||
if not result.scalar():
|
||||
connection.execute(text(
|
||||
"CREATE TABLE alembic_version (version_num VARCHAR(255) NOT NULL PRIMARY KEY)"
|
||||
))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
@@ -52,6 +69,7 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
_ensure_version_table(connection)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""创建 Bronze 层、死信表、质量监控、血缘追踪 4 张新表
|
||||
|
||||
Revision ID: 0008_raw_event_and_ingest_failure
|
||||
Revises: 0007_predictions_unique_constraint
|
||||
Create Date: 2026-09-17
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = '0008_raw_event_and_ingest_failure'
|
||||
down_revision: Union[str, None] = '0007_predictions_unique_constraint'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. RawEvent - Bronze 层原始事件存档
|
||||
op.create_table(
|
||||
'raw_events',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=False),
|
||||
sa.Column('raw_payload', JSONB(), nullable=False),
|
||||
sa.Column('ingested_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('ingest_batch_id', sa.String(36), nullable=True),
|
||||
sa.UniqueConstraint('source_system', 'source_record_id', name='uq_raw_event'),
|
||||
)
|
||||
op.create_index('ix_raw_event_batch', 'raw_events', ['ingest_batch_id'])
|
||||
|
||||
# 2. IngestFailure - 采集失败死信表
|
||||
op.create_table(
|
||||
'ingest_failures',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('entity_type', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=True),
|
||||
sa.Column('error_type', sa.String(50), nullable=False),
|
||||
sa.Column('error_detail', sa.Text(), nullable=True),
|
||||
sa.Column('raw_payload', JSONB(), nullable=True),
|
||||
sa.Column('retry_count', sa.Integer(), server_default='0'),
|
||||
sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(20), server_default='pending'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index('ix_ingest_failure_status', 'ingest_failures', ['status', 'next_retry_at'])
|
||||
op.create_check_constraint(
|
||||
'ck_ingest_failure_status',
|
||||
'ingest_failures',
|
||||
"status IN ('pending', 'retrying', 'resolved', 'abandoned')",
|
||||
)
|
||||
|
||||
# 3. DataQualityCheck - 数据质量监控
|
||||
op.create_table(
|
||||
'data_quality_checks',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('check_name', sa.String(100), nullable=False),
|
||||
sa.Column('entity_type', sa.String(50), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=True),
|
||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||
sa.Column('actual_value', sa.Float(), nullable=False),
|
||||
sa.Column('passed', sa.Boolean(), nullable=False),
|
||||
sa.Column('severity', sa.String(10), server_default='warning'),
|
||||
sa.Column('detail', JSONB(), nullable=True),
|
||||
sa.Column('checked_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_dqc_checked_at', 'data_quality_checks', ['checked_at'])
|
||||
op.create_index('ix_dqc_entity', 'data_quality_checks', ['entity_type', 'entity_id'])
|
||||
|
||||
# 4. DataLineage - ETL 血缘追踪
|
||||
op.create_table(
|
||||
'data_lineage',
|
||||
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||
sa.Column('source_system', sa.String(50), nullable=False),
|
||||
sa.Column('source_record_id', sa.String(100), nullable=False),
|
||||
sa.Column('target_table', sa.String(50), nullable=False),
|
||||
sa.Column('target_id', sa.Integer(), nullable=True),
|
||||
sa.Column('transform_name', sa.String(50), nullable=False),
|
||||
sa.Column('transform_detail', JSONB(), nullable=True),
|
||||
sa.Column('batch_id', sa.String(36), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_lineage_source', 'data_lineage', ['source_system', 'source_record_id'])
|
||||
op.create_index('ix_lineage_target', 'data_lineage', ['target_table', 'target_id'])
|
||||
op.create_index('ix_lineage_batch', 'data_lineage', ['batch_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_lineage_batch', table_name='data_lineage')
|
||||
op.drop_index('ix_lineage_target', table_name='data_lineage')
|
||||
op.drop_index('ix_lineage_source', table_name='data_lineage')
|
||||
op.drop_table('data_lineage')
|
||||
|
||||
op.drop_index('ix_dqc_entity', table_name='data_quality_checks')
|
||||
op.drop_index('ix_dqc_checked_at', table_name='data_quality_checks')
|
||||
op.drop_table('data_quality_checks')
|
||||
|
||||
op.drop_index('ix_ingest_failure_status', table_name='ingest_failures')
|
||||
op.drop_table('ingest_failures')
|
||||
|
||||
op.drop_index('ix_raw_event_batch', table_name='raw_events')
|
||||
op.drop_table('raw_events')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""为 MatchStats 添加 xG 追踪字段
|
||||
|
||||
Revision ID: 0009_match_stats_xg_fields
|
||||
Revises: 0008_raw_event_and_ingest_failure
|
||||
Create Date: 2026-09-17
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '0009_match_stats_xg_fields'
|
||||
down_revision: Union[str, None] = '0008_raw_event_and_ingest_failure'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('match_stats', sa.Column('xg_source', sa.String(30), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('xg_updated_at', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('match_stats', sa.Column('xg_source_record_id', sa.String(100), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('match_stats', 'xg_source_record_id')
|
||||
op.drop_column('match_stats', 'xg_updated_at')
|
||||
op.drop_column('match_stats', 'xg_source')
|
||||
+13
-1
@@ -6,7 +6,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-football}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -26,6 +26,18 @@ services:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./alembic:/app/alembic
|
||||
- ./alembic.ini:/app/alembic.ini
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html
|
||||
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Serve static files
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health check proxy
|
||||
location /health {
|
||||
proxy_pass http://api:8000/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
gzip_min_length 1000;
|
||||
}
|
||||
Generated
+53
-43
@@ -8,8 +8,10 @@
|
||||
"name": "profeto-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/noto-serif-sc": "^5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -708,6 +710,15 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -766,9 +777,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,6 +824,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -915,9 +932,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -932,9 +946,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -949,9 +960,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -966,9 +974,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -983,9 +988,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1000,9 +1002,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1017,9 +1016,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1034,9 +1030,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1051,9 +1044,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1068,9 +1058,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1085,9 +1072,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1102,9 +1086,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1119,9 +1100,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2287,6 +2265,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-serif-sc": "^5.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
+65
-15
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* 主应用入口
|
||||
*
|
||||
* 整合前台(报纸风格)和后台(暗色管理)的路由。
|
||||
* - / → 先知(Profeto)主站
|
||||
* - /admin/* → 管理后台
|
||||
*/
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Matches from './pages/Matches'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
/** 报眉日期行:2026年9月15日 星期二 */
|
||||
/** 报眉日期行 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
@@ -11,29 +21,46 @@ function dateLine(): string {
|
||||
})
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
/** 期号:当年第几天,报纸连载的仪式感 */
|
||||
function issueNo(): number {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), 0, 0)
|
||||
return Math.floor((now.getTime() - start.getTime()) / 86400000)
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
||||
<header className="masthead-rule">
|
||||
// grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
|
||||
<div className="grain min-h-screen bg-paper-50">
|
||||
{/* ── 报头: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-5 text-center sm:py-6">
|
||||
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
|
||||
<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="ml-3 align-baseline font-serif text-base font-normal italic tracking-normal text-ink-500">
|
||||
<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-2 text-2xs tracking-[0.4em] text-ink-500">
|
||||
<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>{dateLine()}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
|
||||
服务运行中
|
||||
<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"
|
||||
>
|
||||
<span aria-hidden="true">⚙</span>
|
||||
管理后台
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,13 +70,36 @@ export default function App() {
|
||||
<Matches />
|
||||
</main>
|
||||
|
||||
{/* ── 版底 ── */}
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{adminRoutes.map(route => (
|
||||
<Route key={route.path} path={route.path} element={route.element}>
|
||||
{route.children.map(child => (
|
||||
<Route
|
||||
key={child.path ?? 'index'}
|
||||
index={child.index}
|
||||
path={child.path}
|
||||
element={child.element}
|
||||
/>
|
||||
))}
|
||||
</Route>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Admin 后台 - 应用入口
|
||||
*
|
||||
* 独立的 Admin 应用入口,用于 createBrowserRouter。
|
||||
* 也可通过 createHashRouter 直接挂载为独立应用。
|
||||
*/
|
||||
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { adminRoutes } from './routes'
|
||||
|
||||
const router = createBrowserRouter(adminRoutes)
|
||||
|
||||
export default function AdminApp() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Admin 后台 - 布局组件(报刊风)
|
||||
*
|
||||
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
|
||||
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchHealth } from './dal'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
||||
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||
]
|
||||
|
||||
/** 报眉日期行,与前台同款式 */
|
||||
function dateLine(): string {
|
||||
return new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const location = useLocation()
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const h = await fetchHealth()
|
||||
setHealthOk(h?.status === 'healthy' || h?.status === 'ok')
|
||||
} catch {
|
||||
setHealthOk(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth()
|
||||
const t = setInterval(checkHealth, 60_000)
|
||||
return () => clearInterval(t)
|
||||
}, [checkHealth])
|
||||
|
||||
// 路由变化时关闭移动端菜单
|
||||
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
|
||||
useEffect(() => {
|
||||
closeSidebar()
|
||||
}, [location.pathname, closeSidebar])
|
||||
|
||||
// ESC 键关闭菜单
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setSidebarOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
|
||||
{/* ── 移动端遮罩层 ── */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-ink-900/40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 侧边栏 ── */}
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-50 flex w-60 flex-shrink-0 flex-col border-r border-ink-900 bg-paper-50
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
lg:relative lg:z-auto lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
aria-label="主导航"
|
||||
>
|
||||
{/* 报头 */}
|
||||
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
|
||||
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
|
||||
先知
|
||||
<span className="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>
|
||||
|
||||
{/* 导航:选中项以墨色方块标记(红色只留给异常状态,与前台纪律一致) */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
|
||||
<ul className="space-y-0.5">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-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}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="border-t border-ink-200 px-4 py-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
返回前台版面
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── 主内容区 ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* 报眉:日期 + 系统状态 */}
|
||||
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-2xs">
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-500">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 ${
|
||||
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
|
||||
</span>
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Profeto Admin 后台管理系统
|
||||
|
||||
## 概述
|
||||
|
||||
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
|
||||
**与前台共用同一套「报刊风」设计语言**:纸色底(paper)、墨色字(ink)、印报红唯一强调(press),
|
||||
宋体标题、方正边框、细线分隔,无圆角、无彩色药丸标签。
|
||||
支持响应式布局(移动端 / 平板 / 桌面)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/admin/
|
||||
├── AdminApp.tsx # Admin 应用入口(独立路由)
|
||||
├── AdminLayout.tsx # 布局(报头式侧栏 + 报眉顶栏,健康状态每 60s 复检)
|
||||
├── api.ts # 统一 API 客户端(超时、错误处理、X-API-Key 自动附带)
|
||||
├── dal.ts # 数据访问层(封装所有 API 端点调用)
|
||||
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 模型对齐)
|
||||
├── components.tsx # 通用 UI 组件(报刊风 Card/Badge/DataTable/Alert...)
|
||||
├── routes.tsx # 路由定义(/admin/*)
|
||||
└── pages/
|
||||
├── Dashboard.tsx # 仪表盘(系统概览)
|
||||
├── Collection.tsx # 数据采集(触发采集任务,结果结构化展示)
|
||||
├── Predictions.tsx # 预测管理(触发预测 + 结算 + 记录展开)
|
||||
├── Backtest.tsx # 回测管理(汇总指标 + 逐场明细 + 模型评估)
|
||||
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
|
||||
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
||||
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
||||
└── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南)
|
||||
```
|
||||
|
||||
## 页面说明
|
||||
|
||||
### 1. 仪表盘 (`/admin`)
|
||||
- 系统健康状态、联赛 / 比赛 / 预测数量统计(报纸数字版式)
|
||||
- 已入库联赛列表
|
||||
- 快捷操作导航
|
||||
|
||||
### 2. 数据采集 (`/admin/collection`)
|
||||
- 选择数据源: Bzzoiro / Understat / Injuries
|
||||
- 选择联赛、日期范围(Understat 为赛季)
|
||||
- 触发采集任务;结果结构化展示(新增 / 更新 / 跳过 / 未匹配 / 错误)
|
||||
|
||||
### 3. 预测管理 (`/admin/predictions`)
|
||||
- 触发预测(选择比赛 + 模式:多专家 / 单次)
|
||||
- **预测结算**: 录入实际比分,调用 /eval/settle,供准确率统计使用
|
||||
- 预测记录列表: 点击展开终裁理由与五路专家摘要
|
||||
|
||||
### 4. 回测管理 (`/admin/backtest`)
|
||||
- 回测配置: 联赛(下拉)、日期范围、场数、模式
|
||||
- 结果汇总: 已评分 / 1X2 准确率 / 比分 RMSE / 平均置信度
|
||||
- 逐场明细: 实际比分 vs 预测比分,正误标记
|
||||
- 模型评估: 各模型历史准确率(/eval/summary)
|
||||
|
||||
### 5. 监控面板 (`/admin/monitoring`)
|
||||
- /health 存活检查 + /health/ready 数据库就绪检查
|
||||
- 每 30 秒自动巡检,可手动「立即巡检」
|
||||
- 服务名 / 版本 / 运行时间 / 检查项
|
||||
|
||||
### 6. 数据源管理 (`/admin/data-sources`)
|
||||
- 数据源状态: API Key 配置状态(脱敏)
|
||||
- 测试连接: 调用采集 API 验证
|
||||
- 数据源说明文档
|
||||
|
||||
### 7. LLM 配置 (`/admin/llm-config`)
|
||||
- 当前配置: provider, model, base_url
|
||||
- 连接测试(会真实调用一次预测,产生 LLM 费用)
|
||||
- 使用统计: 预测次数、延迟、有效率(从预测记录聚合)
|
||||
- 可用模型列表
|
||||
|
||||
### 8. 系统配置 (`/admin/config`)
|
||||
- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带;
|
||||
后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥
|
||||
- 配置列表: 脱敏显示 .env 配置项
|
||||
- 配置修改指南: SSH 修改 .env + 重启服务
|
||||
|
||||
## 鉴权说明
|
||||
|
||||
后端 `ADMIN_API_KEY` 的渐进式策略:
|
||||
- 未配置 → 写入型接口无鉴权(本地开发)
|
||||
- 已配置 → 采集 / 回测 / 结算接口必须带 `X-API-Key` 请求头
|
||||
|
||||
前端在 `api.ts` 统一注入该请求头;密钥在「系统配置」页设置,
|
||||
仅存于本机浏览器 localStorage。401 错误会提示到该页填写密钥。
|
||||
|
||||
## 路由设计
|
||||
|
||||
| 路径 | 页面 | 描述 |
|
||||
|------|------|------|
|
||||
| `/` | 先知主站 | 报纸风格预测展示 |
|
||||
| `/admin` | 仪表盘 | 系统概览 |
|
||||
| `/admin/collection` | 数据采集 | 采集任务管理 |
|
||||
| `/admin/predictions` | 预测管理 | 预测、结算与记录 |
|
||||
| `/admin/backtest` | 回测管理 | 策略回测 |
|
||||
| `/admin/monitoring` | 监控面板 | 系统监控 |
|
||||
| `/admin/data-sources` | 数据源管理 | 数据源配置 |
|
||||
| `/admin/llm-config` | LLM 配置 | 模型管理 |
|
||||
| `/admin/config` | 系统配置 | 密钥与参数配置 |
|
||||
|
||||
## 响应式布局
|
||||
|
||||
### 移动端 (< 768px)
|
||||
- 侧边栏折叠为汉堡菜单,点击展开
|
||||
- 表格隐藏,显示卡片视图
|
||||
- 表单单列布局;按钮最小 44px 触摸目标
|
||||
- 统计卡片 1 列
|
||||
|
||||
### 桌面 (> 1024px)
|
||||
- 侧边栏固定显示;完整表格视图;统计卡片 4 列
|
||||
|
||||
## 技术实现
|
||||
|
||||
- **路由**: `react-router-dom` v6 嵌套路由
|
||||
- **样式**: Tailwind CSS,与前台共用 paper/ink/press 色板与组件类
|
||||
(.btn / .field / .tab / .section-head / .skeleton 见 `src/index.css`)
|
||||
- **API**: 统一 fetch 客户端,30s 超时,类型安全,X-API-Key 自动附带
|
||||
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
|
||||
- **错误处理**: ApiError 类 + 页面级 Alert 展示
|
||||
- **触摸友好**: 所有可点元素 min-h-[44px]
|
||||
|
||||
## 启动方式
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
# 访问 http://localhost:5173/admin
|
||||
```
|
||||
|
||||
## 访问入口
|
||||
|
||||
主站页面顶部「管理后台」链接可跳转至 `/admin`。
|
||||
Admin 后台侧栏底部「返回前台版面」链接可回到 `/`。
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端
|
||||
*
|
||||
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
|
||||
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带。
|
||||
*/
|
||||
|
||||
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 }
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||
*
|
||||
* 与前台共用同一套设计语言:
|
||||
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||
*/
|
||||
|
||||
import { 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>
|
||||
}
|
||||
|
||||
// ── 统计卡片 ────────────────────────────────────────────────────
|
||||
|
||||
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}`} />
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Admin 后台 - 数据访问层
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
*/
|
||||
|
||||
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 },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Admin 后台 - 回测管理页面(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - 回测配置: 联赛(下拉)、日期范围、场数、模式
|
||||
* - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度
|
||||
* - 逐场明细: 实际比分 vs 预测比分,正误标记
|
||||
* - 模型评估: 各模型历史准确率(/eval/summary)
|
||||
*
|
||||
* 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, EvalSummary, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
interface BacktestResultRow {
|
||||
match_id: number
|
||||
league_code?: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
match_date?: string | null
|
||||
actual_score: string
|
||||
actual_1x2?: string
|
||||
pred_home?: number | null
|
||||
pred_away?: number | null
|
||||
pred_1x2?: string | null
|
||||
subjective_confidence?: number | null
|
||||
correct_1x2: boolean
|
||||
}
|
||||
|
||||
interface BacktestResponse {
|
||||
summary: {
|
||||
total: number
|
||||
scored: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
results: BacktestResultRow[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
function fmtDate(s?: string | null): string {
|
||||
if (!s) return '—'
|
||||
return s.slice(0, 10)
|
||||
}
|
||||
|
||||
export default function BacktestPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [leagueId, setLeagueId] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [mode, setMode] = useState<'single' | 'multi'>('single')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<BacktestResponse | null>(null)
|
||||
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(null)
|
||||
const [evalLoading, setEvalLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLeagues().then(setLeagues)
|
||||
loadEval()
|
||||
}, [])
|
||||
|
||||
async function loadEval() {
|
||||
setEvalLoading(true)
|
||||
try {
|
||||
setEvalSummary(await fetchEvalSummary())
|
||||
} finally {
|
||||
setEvalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBacktest(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const req: BacktestRequest = {
|
||||
league_id: leagueId ? parseInt(leagueId) : undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
limit,
|
||||
mode,
|
||||
}
|
||||
const res = await triggerBacktest(req as BacktestRequest)
|
||||
setResult(res as unknown as BacktestResponse)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '回测失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = result?.summary
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="回测管理"
|
||||
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 回测配置 */}
|
||||
<Card>
|
||||
<CardHeader title="回测配置" />
|
||||
<CardBody>
|
||||
<form onSubmit={handleBacktest} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
value={leagueId}
|
||||
onChange={e => setLeagueId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.id ?? l.code} value={String(l.id)}>
|
||||
{l.name_zh || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="single">单次调用 (快)</option>
|
||||
<option value="multi">多专家 (慢,贵)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 结果汇总 */}
|
||||
<div className="space-y-6">
|
||||
{summary && (
|
||||
<Card>
|
||||
<CardHeader title="回测结果" />
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.scored}/{summary.total}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">已评分 / 总场数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
|
||||
? `${summary.accuracy_1x2.toFixed(1)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">1X2 准确率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
|
||||
? summary.avg_score_rmse.toFixed(2)
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">比分 RMSE</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
|
||||
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均置信度</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 模型评估 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="模型评估"
|
||||
description="已结算预测的准确率统计"
|
||||
action={
|
||||
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{evalSummary && evalSummary.summary?.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{evalSummary.summary.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
准确率 <span className="font-serif text-sm font-bold text-ink-900">
|
||||
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
<span className="ml-2">{s.total} 场</span>
|
||||
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-xs text-ink-400">
|
||||
暂无评估数据。到「预测管理」完成结算后,这里会给出各模型准确率。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 逐场明细 */}
|
||||
{result && result.results?.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="逐场明细" />
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
<div>
|
||||
{result.results.map(r => (
|
||||
<div
|
||||
key={r.match_id}
|
||||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
|
||||
>
|
||||
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
|
||||
{fmtDate(r.match_date)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
|
||||
{r.home_team} vs {r.away_team}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
|
||||
实际 <span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
|
||||
<span className="mx-2 text-ink-200">|</span>
|
||||
预测 <span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 sm:w-16 sm:text-right">
|
||||
{r.correct_1x2 ? <Badge status="success">命中</Badge> : <Badge status="loss">未中</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面(报刊风)
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { triggerCollection, fetchLeagues } from '../dal'
|
||||
import type { CollectionRequest, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
const SOURCES = [
|
||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
||||
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
||||
] as const
|
||||
|
||||
/** 把采集接口返回摘要成一两行可读文字 */
|
||||
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
||||
if (res && typeof res === 'object') {
|
||||
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
||||
return {
|
||||
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
if ('count' in res || 'updated' in res) {
|
||||
const parts = [
|
||||
`新增 ${res.count ?? 0}`,
|
||||
`更新 ${res.updated ?? 0}`,
|
||||
`跳过 ${res.skipped ?? 0}`,
|
||||
`未匹配 ${res.unmatched ?? 0}`,
|
||||
]
|
||||
return {
|
||||
title: `采集完成:${parts.join(' / ')}`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
||||
}
|
||||
|
||||
export default function CollectionPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [source, setSource] = useState<string>('bzzoiro')
|
||||
const [leagueCode, setLeagueCode] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [season, setSeason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||
|
||||
const loadLeagues = useCallback(async () => {
|
||||
const lg = await fetchLeagues()
|
||||
setLeagues(lg)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
source: source as CollectionRequest['source'],
|
||||
leagues: leagueCode ? [leagueCode] : undefined,
|
||||
league: leagueCode || undefined,
|
||||
season: season || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
}
|
||||
const res = await triggerCollection(body)
|
||||
setResult(summarizeResult(res, source))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据采集"
|
||||
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 采集表单 */}
|
||||
<Card>
|
||||
<CardHeader title="新建采集任务" />
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 数据源选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
{SOURCES.map(s => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label} — {s.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 联赛选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Understat 专用: 赛季 */}
|
||||
{source === 'understat' && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="2025"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期范围 */}
|
||||
{source !== 'injuries' && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息提示 */}
|
||||
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
||||
{result && (
|
||||
<Alert
|
||||
kind="ok"
|
||||
title={result.title}
|
||||
message={result.detail || undefined}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 数据源说明 */}
|
||||
<Card>
|
||||
<CardHeader title="数据源说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
{SOURCES.map(s => (
|
||||
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge status="info">{s.label}</Badge>
|
||||
<p className="text-xs text-ink-600">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
若后端配置了 ADMIN_API_KEY,采集接口需要管理员密钥。
|
||||
遇到 401 请到「系统配置」页填写密钥。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
*
|
||||
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchDashboard, fetchHealth } from '../dal'
|
||||
import type { DashboardStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([fetchDashboard(), fetchHealth()])
|
||||
.then(([stats, h]) => {
|
||||
if (active) {
|
||||
setData(stats)
|
||||
setHealth(h)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
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 =
|
||||
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计:报纸数字版式 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="健康状态"
|
||||
value={loading ? '—' : healthText}
|
||||
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
||||
/>
|
||||
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
||||
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
||||
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* 联赛列表 */}
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
||||
))}
|
||||
</div>
|
||||
) : data && data.leagues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.leagues.map(l => (
|
||||
<span
|
||||
key={l.code}
|
||||
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
|
||||
>
|
||||
{l.name_zh || l.name}
|
||||
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-ink-500">
|
||||
暂无联赛数据,请先到「数据采集」导入比赛数据。
|
||||
</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<Card>
|
||||
<CardHeader title="快捷操作" />
|
||||
<CardBody>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/admin/collection"
|
||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-serif text-sm font-bold text-ink-900">触发采集</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-500">从数据源获取最新赛程与比分</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/predictions"
|
||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-serif text-sm font-bold text-ink-900">新建预测</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-500">调 LLM 生成比赛预测</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/admin/backtest"
|
||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div>
|
||||
<div className="font-serif text-sm font-bold text-ink-900">运行回测</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-500">在历史数据上检验准确率</div>
|
||||
</div>
|
||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { api } from '../api'
|
||||
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastCheck, setLastCheck] = useState<string>('')
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [h, r] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
const t = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(t)
|
||||
}, [refresh])
|
||||
|
||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||
</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置。`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 存活状态 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库就绪 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
|
||||
>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务名 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">服务</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
|
||||
{health?.service || 'profeto'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本 */}
|
||||
{health?.version && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 运行时间 */}
|
||||
{health?.uptime_seconds != null && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检查项 */}
|
||||
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">健康检查</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(health.checks).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-2xs text-ink-500">{key}</span>
|
||||
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
|
||||
{String(val)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Admin 后台 - 路由配置
|
||||
*
|
||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||
* 挂载路径: /admin/*
|
||||
*/
|
||||
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import AdminLayout from './AdminLayout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import CollectionPage from './pages/Collection'
|
||||
import PredictionsPage from './pages/Predictions'
|
||||
import BacktestPage from './pages/Backtest'
|
||||
import MonitoringPage from './pages/Monitoring'
|
||||
import DataSourcesPage from './pages/DataSources'
|
||||
import LLMConfigPage from './pages/LLMConfig'
|
||||
import ConfigPage from './pages/Config'
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'predictions', element: <PredictionsPage /> },
|
||||
{ path: 'backtest', element: <BacktestPage /> },
|
||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||
{ path: 'config', element: <ConfigPage /> },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export { adminRoutes as default }
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Admin 后台 - TypeScript 类型定义
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
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
|
||||
}
|
||||
@@ -30,16 +30,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
|
||||
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3">
|
||||
<div className="text-4xl">⚠️</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">页面出现错误</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
|
||||
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
||||
<p className="text-sm leading-relaxed text-ink-500">
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
|
||||
className="btn btn-sm"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
|
||||
+77
-13
@@ -3,6 +3,12 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
|
||||
:root {
|
||||
--c-press: #9E1B1B;
|
||||
--c-ink-900: #17140F;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -19,7 +25,7 @@
|
||||
|
||||
/* 统一焦点环:印报红细线,键盘可达 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #9e1b1b;
|
||||
outline: 2px solid var(--c-press);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -51,43 +57,97 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||
/* ── 报头 Oxford 粗细双线:3px 粗线 + 2px 空 + 1px 细线 ── */
|
||||
.masthead-rule {
|
||||
border-top: 3px solid #17140f;
|
||||
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 {
|
||||
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
|
||||
@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
|
||||
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
|
||||
disabled:cursor-not-allowed disabled:opacity-40
|
||||
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply px-2.5 py-1 text-xs;
|
||||
@apply min-h-[32px] px-2.5 py-1 text-xs;
|
||||
}
|
||||
.btn-solid {
|
||||
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
|
||||
}
|
||||
|
||||
/* ── 表单控件:方正、无圆角 ── */
|
||||
/* ── 表单控件:方正、无圆角;焦点态保留全局焦点环,只追加红边 ── */
|
||||
.field {
|
||||
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
|
||||
transition-colors hover:border-ink-400
|
||||
focus:border-press focus:outline-none;
|
||||
@apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
|
||||
transition-colors hover:border-ink-400 focus:border-press;
|
||||
}
|
||||
|
||||
/* ── 版面切换文字标签(联赛/状态/模式) ── */
|
||||
/* ── 版面切换文字标签(联赛/状态/模式) ──
|
||||
激活态用墨色:印报红只留给 live/终裁/印章/错误,维持 10% 纪律 */
|
||||
.tab {
|
||||
@apply relative whitespace-nowrap px-0.5 py-1 text-sm text-ink-500 transition-colors hover:text-ink-900;
|
||||
}
|
||||
.tab-on {
|
||||
@apply font-medium text-press;
|
||||
@apply font-medium text-ink-900;
|
||||
}
|
||||
.tab-on::after {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── 小节标题:宋体加粗 + 墨色底线 ── */
|
||||
@@ -100,3 +160,7 @@
|
||||
@apply animate-pulse rounded-none bg-ink-200;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Admin 后台 ──
|
||||
与前台共用同一套报刊风组件(.btn/.field/.tab/.section-head/.skeleton),
|
||||
不再单独维护暗色主题。 */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
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'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
+138
-40
@@ -176,6 +176,30 @@ function OutcomeLine({
|
||||
)
|
||||
}
|
||||
|
||||
/** 状态/模式一组的文字切换(模块级组件:定义在组件体内会每次 render 重建,导致焦点丢失) */
|
||||
function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Matches() {
|
||||
const [league, setLeague] = useState('E0')
|
||||
const [status, setStatus] = useState('scheduled')
|
||||
@@ -276,28 +300,6 @@ export default function Matches() {
|
||||
|
||||
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
const Switch = ({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) => (
|
||||
<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>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
@@ -368,13 +370,19 @@ export default function Matches() {
|
||||
{loading && <SkeletonRows n={4} />}
|
||||
|
||||
{!loading && matches.length === 0 && (
|
||||
<div className="border-y border-ink-200 py-14 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
||||
/* 空态做成报纸「启事」体:外粗线 + 内细线框 */
|
||||
<div className="border-b border-ink-900 py-10 text-center">
|
||||
<div className="mx-auto max-w-sm border border-ink-300 px-6 py-6">
|
||||
<p className="font-serif text-xs tracking-[0.5em] text-ink-500">启事</p>
|
||||
<p className="mt-3 font-serif text-sm font-bold text-ink-800">本版暂无赛程</p>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-ink-500">
|
||||
请先通过采集接口导入 {leagueName} 的比赛数据,再回到本版查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && matches.map(m => {
|
||||
{!loading && matches.map((m, idx) => {
|
||||
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
|
||||
const homeName = m.home_team_zh || m.home_team
|
||||
const awayName = m.away_team_zh || m.away_team
|
||||
@@ -384,10 +392,17 @@ export default function Matches() {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
className={`rise-in group relative border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
|
||||
active ? 'bg-press-wash/50' : ''
|
||||
}`}
|
||||
// 入场错峰:60ms 步进,封顶 420ms,长列表不拖沓
|
||||
style={{ animationDelay: `${Math.min(idx * 60, 420)}ms` }}
|
||||
>
|
||||
{/* 悬停书签:左缘红刻度,报眼式标记 */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-0.5 bg-press opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
|
||||
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
@@ -482,6 +497,89 @@ export default function Matches() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 终裁印章:预测版右上角的红印,全站强调色的点睛之处 */
|
||||
function FinalSeal() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-0 flex h-9 w-9 -rotate-6 items-center justify-center rounded-[2px] bg-press"
|
||||
>
|
||||
<span className="font-serif text-[13px] font-bold leading-[1.2] tracking-[0.15em] text-paper-50">
|
||||
终裁
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 预测比分:版面核心,大号宋体 + 纵排队名 + 终裁印章 */
|
||||
function ScoreMast({
|
||||
homeName,
|
||||
awayName,
|
||||
home,
|
||||
away,
|
||||
}: {
|
||||
homeName: string
|
||||
awayName: string
|
||||
home: number | null
|
||||
away: number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="relative text-center">
|
||||
<FinalSeal />
|
||||
<div className="flex items-center justify-center gap-5 sm:gap-7">
|
||||
<span className="v-name font-serif text-sm text-ink-700">{homeName}</span>
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{home ?? '-'}
|
||||
<span className="mx-2 font-normal text-ink-300 sm:mx-3">:</span>
|
||||
{away ?? '-'}
|
||||
</p>
|
||||
<span className="v-name font-serif text-sm text-ink-700">{awayName}</span>
|
||||
</div>
|
||||
<p className="mt-4 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 五路专家倾向条:home_edge 的分歧可视化,红=利主,墨=利客,条高=强度 */
|
||||
function ExpertLeans({ reports }: { reports: AgentReport[] }) {
|
||||
const withEdge = reports.filter(
|
||||
(r): r is AgentReport & { home_edge: number } => r.status === 'ok' && r.home_edge !== null,
|
||||
)
|
||||
if (withEdge.length === 0) return null
|
||||
const summary = withEdge
|
||||
.map(r => `${AGENT_LABELS[r.agent] ?? r.agent} ${r.home_edge > 0 ? '利主' : '利客'} ${Math.abs(r.home_edge).toFixed(2)}`)
|
||||
.join(',')
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex items-end justify-center gap-5"
|
||||
role="img"
|
||||
aria-label={`五路专家倾向:${summary}`}
|
||||
>
|
||||
{withEdge.map((r, i) => {
|
||||
const up = r.home_edge >= 0
|
||||
const h = Math.round(Math.min(1, Math.abs(r.home_edge)) * 50)
|
||||
return (
|
||||
<div
|
||||
key={r.agent}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
title={`${AGENT_LABELS[r.agent] ?? r.agent} · 主队优势 ${(r.home_edge > 0 ? '+' : '') + r.home_edge.toFixed(2)}`}
|
||||
>
|
||||
<div className="relative h-16 w-2 bg-ink-200">
|
||||
<span className="absolute inset-x-0 top-1/2 h-px bg-ink-400" aria-hidden="true" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-x-0 ${up ? 'bottom-1/2 bg-press' : 'top-1/2 bg-ink-600'}`}
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-serif text-2xs text-ink-500">{CN_NUM[i] ?? i + 1}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PredictionPanel({
|
||||
prediction,
|
||||
match,
|
||||
@@ -496,12 +594,13 @@ function PredictionPanel({
|
||||
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
|
||||
|
||||
return (
|
||||
<article className="border border-ink-900 bg-paper-50">
|
||||
// rise-in:预测版面整块入场,内部区块再错峰
|
||||
<article className="rise-in border border-ink-900 bg-paper-50">
|
||||
{/* 版头 */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">
|
||||
<h2 className="font-serif text-sm font-bold text-ink-900">
|
||||
预测版 · {homeName} 对 {awayName}
|
||||
</h3>
|
||||
</h2>
|
||||
<span className="text-2xs tabular-nums text-ink-500">
|
||||
{prediction.provider} / {prediction.model}
|
||||
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||
@@ -509,15 +608,13 @@ function PredictionPanel({
|
||||
</div>
|
||||
|
||||
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
||||
<div className="text-center">
|
||||
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||
{prediction.pred_home_goals ?? '-'}
|
||||
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||
{prediction.pred_away_goals ?? '-'}
|
||||
</p>
|
||||
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||
</div>
|
||||
{/* ── 预测比分:版面核心,纵排队名 + 终裁印章 ── */}
|
||||
<ScoreMast
|
||||
homeName={homeName}
|
||||
awayName={awayName}
|
||||
home={prediction.pred_home_goals}
|
||||
away={prediction.pred_away_goals}
|
||||
/>
|
||||
|
||||
{/* ── 胜平负 ── */}
|
||||
<div className="border-y border-ink-200 py-4">
|
||||
@@ -547,6 +644,7 @@ function PredictionPanel({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ExpertLeans reports={prediction.agent_outputs} />
|
||||
{prediction.agent_outputs.map((r, i) => (
|
||||
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||
))}
|
||||
@@ -557,7 +655,7 @@ function PredictionPanel({
|
||||
{/* ── 终裁意见:引文式,红竖线 ── */}
|
||||
{prediction.reasoning && (
|
||||
<section>
|
||||
<h4 className="section-head mb-3">终裁意见</h4>
|
||||
<h3 className="section-head mb-3">终裁意见</h3>
|
||||
<blockquote className="border-l-2 border-press pl-4">
|
||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
||||
{prediction.reasoning}
|
||||
|
||||
@@ -12,12 +12,13 @@ export default {
|
||||
300: '#DDD7C7',
|
||||
},
|
||||
// 墨色:暖黑灰阶,替代冷调 slate
|
||||
// 400 档压在 4.5:1 线上(paper-100 底 4.55:1 / paper-50 底 4.87:1),可作正文级小字
|
||||
ink: {
|
||||
50: '#FAF9F7',
|
||||
100: '#F0EEE9',
|
||||
200: '#E2DFD7',
|
||||
300: '#C9C4B8',
|
||||
400: '#9C9587',
|
||||
400: '#756F61',
|
||||
500: '#6E675B',
|
||||
600: '#524C42',
|
||||
700: '#3B362E',
|
||||
@@ -33,7 +34,10 @@ export default {
|
||||
},
|
||||
fontFamily: {
|
||||
// 标题与比分:宋体血统,报纸版面的骨架
|
||||
// Noto Serif SC 站首位:拉丁与数字走它的直线字形(Georgia 是 old-style 数字,
|
||||
// 比分 3:1 会高低不齐);Georgia 只保留给刊名斜体(.wordmark)
|
||||
serif: [
|
||||
'"Noto Serif SC"',
|
||||
'Georgia',
|
||||
'"Times New Roman"',
|
||||
'"Songti SC"',
|
||||
|
||||
Reference in New Issue
Block a user