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差 / 近期走势
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
function HomePage() {
return (
@@ -46,6 +93,9 @@ function HomePage() {
{dateLine()}