diff --git a/alembic/versions/0015_bzzoiro_single_source.py b/alembic/versions/0015_bzzoiro_single_source.py new file mode 100644 index 0000000..41d6241 --- /dev/null +++ b/alembic/versions/0015_bzzoiro_single_source.py @@ -0,0 +1,102 @@ +"""bzzoiro 单一数据源重构:新增 standings、扩展 match_stats、删除 injuries + +Revision ID: 0015_bzzoiro_single_source +Revises: 0014_predictions_agent_weights +Create Date: 2026-09-20 + +变更内容: + 1. 新增 standings 表(联赛积分榜快照,来源 bzzoiro /leagues/{id}/standings/) + 2. match_stats 新增 bzzoiro /events/{id}/stats/ 扩展字段: + home/away_big_chances, home/away_fouls + 3. 删除 injuries 表(数据源已下线,不再采集伤停) + 4. Understat 无独立表(xG 写入 match_stats),无需删表; + 历史 source='understat' 数据保留,不再新增。 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '0015_bzzoiro_single_source' +down_revision: Union[str, None] = '0014_predictions_agent_weights' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 0. matches 增加数据血缘列(bzzoiro 上游事件 ID) + op.add_column('matches', sa.Column('source_event_id', sa.BigInteger())) + op.create_index('ix_matches_source_event_id', 'matches', ['source_event_id']) + + # 1. standings 表 + op.create_table( + 'standings', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('league_id', sa.Integer(), sa.ForeignKey('leagues.id'), nullable=False), + sa.Column('season', sa.String(12), nullable=False), + sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id'), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('played', sa.Integer(), nullable=False, server_default='0'), + sa.Column('won', sa.Integer(), nullable=False, server_default='0'), + sa.Column('drawn', sa.Integer(), nullable=False, server_default='0'), + sa.Column('lost', sa.Integer(), nullable=False, server_default='0'), + sa.Column('goals_for', sa.Integer(), nullable=False, server_default='0'), + sa.Column('goals_against', sa.Integer(), nullable=False, server_default='0'), + sa.Column('goal_diff', sa.Integer(), nullable=False, server_default='0'), + sa.Column('points', sa.Integer(), nullable=False, server_default='0'), + sa.Column('xg_for', sa.Float()), + sa.Column('xg_against', sa.Float()), + sa.Column('form', sa.String(20)), + sa.Column('zone', sa.String(50)), + sa.Column('updated_at', sa.DateTime(timezone=True)), + sa.Column('retrieved_at', sa.DateTime(timezone=True)), + sa.UniqueConstraint('league_id', 'season', 'team_id', name='uq_standings_league_season_team'), + ) + op.create_index( + 'ix_standings_league_season_pos', 'standings', + ['league_id', 'season', 'position'], + ) + + # 2. match_stats 扩展字段 + op.add_column('match_stats', sa.Column('home_big_chances', sa.Integer())) + op.add_column('match_stats', sa.Column('away_big_chances', sa.Integer())) + op.add_column('match_stats', sa.Column('home_fouls', sa.Integer())) + op.add_column('match_stats', sa.Column('away_fouls', sa.Integer())) + + # 3. 删除 injuries 表 + op.drop_index('ix_injuries_player_fixture', table_name='injuries') + op.drop_index('ix_injuries_team_date', table_name='injuries') + op.drop_table('injuries') + + +def downgrade() -> None: + # 恢复 injuries 表(不含数据) + op.create_table( + 'injuries', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('player_id', sa.Integer()), + sa.Column('player_name', sa.String(120), nullable=False), + sa.Column('team_id', sa.Integer(), sa.ForeignKey('teams.id')), + sa.Column('fixture_id', sa.Integer()), + sa.Column('league_id', sa.Integer()), + sa.Column('injury_type', sa.String(50)), + sa.Column('reason', sa.String(200)), + sa.Column('injury_date', sa.Date()), + sa.Column('return_date', sa.Date()), + sa.Column('retrieved_at', sa.DateTime(timezone=True)), + ) + op.create_index('ix_injuries_player_fixture', 'injuries', ['player_id', 'fixture_id', 'injury_type']) + op.create_index('ix_injuries_team_date', 'injuries', ['team_id', 'injury_date']) + + op.drop_index('ix_matches_source_event_id', table_name='matches') + op.drop_column('matches', 'source_event_id') + + op.drop_column('match_stats', 'away_fouls') + op.drop_column('match_stats', 'home_fouls') + op.drop_column('match_stats', 'away_big_chances') + op.drop_column('match_stats', 'home_big_chances') + + op.drop_index('ix_standings_league_season_pos', table_name='standings') + op.drop_table('standings') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0179c57..66bc50b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ErrorBoundary } from './components/ErrorBoundary' import Matches from './pages/Matches' +import Standings from './pages/Standings' import { adminRoutes } from './admin/routes' /** 报眉日期行 */ @@ -26,6 +27,52 @@ function dateLine(): string { }) } +function StandingsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+

+ 先知 + + Profeto + +

+

+ 联赛积分榜 · 积分 / 净胜 / xG差 / 近期走势 +

+
+
+ {dateLine()} + +
+
+
+ +
+ {children} +
+ + +
+ ) +} + function HomePage() { return (
@@ -46,6 +93,9 @@ function HomePage() {
{dateLine()}