
体育赛事网站
需要AI提供一个较为完整的体育赛事管理网站,包括前后端,涵盖小组赛和淘汰赛的一系列功能。
Prompt
已将文档通用化,去掉项目专属细节,只保留可复用的淘汰赛设计思路与实现要点: ```markdown # 淘汰赛功能改造 · 通用设计参考 > 适用场景:已有「比赛(matches)」为核心的赛程管理系统,需要从小组赛/循环赛扩展到淘汰赛(半决赛、决赛、三四名赛等)。 > > 本文档定位:讲清楚数据模型扩展、晋级联动、改判锁定、前后端职责划分与常见坑,供实施者理解全貌后自行决策。不是具体代码行号清单。 --- ## 目录 1. [现状与缺口](#1-现状与缺口) 2. [总体设计决策](#2-总体设计决策) 3. [数据模型设计](#3-数据模型设计) 4. [核心逻辑一:TBD 与队伍来源解析](#4-核心逻辑一tbd-与队伍来源解析) 5. [核心逻辑二:晋级联动](#5-核心逻辑二晋级联动) 6. [核心逻辑三:改判、删除与锁定](#6-核心逻辑三改判删除与锁定) 7. [出线机制:手动指定 vs 自动解析](#7-出线机制) 8. [后端改动要点](#8-后端改动要点) 9. [前端改动要点](#9-前端改动要点) 10. [排行榜与积分的业务决策](#10-排行榜与积分) 11. [注意事项与坑](#11-注意事项与坑) 12. [实施顺序建议](#12-实施顺序建议) 13. [附录:关键伪代码与 SQL 示例](#13-附录) --- ## 1. 现状与缺口 ### 现有模型通常具备的能力(可复用) - 胜负判定(按比分写 result) - 比赛状态流转(pending → in_progress → completed) - 平局开关(按项目配置) - 权限与操作日志 - 公开读赛程 ### 淘汰赛引入的四个新问题 1. **队伍未定(TBD)**:半决赛/决赛在上游结束前,参赛队伍尚不存在。现有 `home_team_id` / `away_team_id` 通常非空,无法表达「待定」。 2. **晋级关系**:决赛队伍来自半决赛胜者。现有模型比赛之间无关联,录完上游比分后系统不知道要把胜者送到哪里。 3. **阶段概念**:仅有「轮次/周次」无法区分小组赛、半决赛、决赛,前台无法决定用表格还是对阵树展示。 4. **小组归属**(若有小组赛):系统不知道某队属于哪一组,也就无法表达「A 组第一名出线」。 > 改造本质:给比赛记录补上 **阶段(stage)、小组(group)、队伍来源(source)**,并让「录分」触发一次**下游联动**。其余能力尽量复用。 --- ## 2. 总体设计决策 | 方案 | 做法 | 评价 | |---|---|---| | **A. 扩展现有 matches 表**(推荐) | 增加 stage / group_label / home_source / away_source,队伍字段改可空 | 一场比赛仍是一行;录分、状态、权限、日志、公开读全部零成本复用 | | B. 新建独立淘汰赛表 | 独立表 + 独立管理页 | 录分、状态机、展示组件都要做两套;跨表统计与一致性成本高 | **推荐方案 A**。淘汰赛与小组赛的差异主要在「队伍何时确定」和「输了就淘汰」,属于字段与联动逻辑,而非表结构。 配套原则:淘汰赛比赛仍保留真实的轮次/周次字段,保持筛选与日历浏览习惯不变。 --- ## 3. 数据模型设计 ### 新增字段示例 ```sql -- 阶段:group(小组赛)| semi(半决赛)| third(三四名)| final(决赛) stage text NOT NULL DEFAULT 'group' -- 仅小组赛使用:'A' | 'B' | 'C' | ... group_label text -- 队伍来源表达式(NULL 表示队伍已确定) home_source text away_source text -- 示例: -- 'group:A:1' → A 组第 1 名 -- 'winner:123' → 比赛 id=123 的胜者 -- 'loser:123' → 比赛 id=123 的负者 -- 允许 TBD home_team_id 可空 away_team_id 可空 ``` ### 典型数据形态(以 4 强为例) | 场次 | stage | home_source → team_id | away_source → team_id | |----------|-------|---------------------------|---------------------------| | 半决赛1 | semi | group:A:1 → 某队 | group:B:1 → 某队 | | 半决赛2 | semi | group:C:1 → 某队 | group:D:1 → 某队 | | 决赛 | final | winner:{半决赛1 id} | winner:{半决赛2 id} | | 三四名 | third | loser:{半决赛1 id} | loser:{半决赛2 id} | 推荐创建时序: 1. 小组赛进行前或同时,先创建半决赛/决赛「骨架」,队伍位置全是 TBD(team_id 为 NULL,source 指向来源)。 2. 小组赛全部结束后,管理员**手动指定**各组出线队,系统把 team_id 填入半决赛 TBD 位。 3. 半决赛录分后,**晋级联动**自动把胜者填入决赛。 --- ## 4. 核心逻辑一:TBD 与队伍来源解析 ### source 表达式解析时机 - **被动解析(录分联动)**:某场比赛录分后,反向查找「谁的 source 指向我」,把结果写过去。 - **主动解析(首轮填充)**:形如 `group:A:1` 的位置,由管理员在小组赛结束后指定队伍填入。 ### 状态规则 一个 TBD 位置只有两种合法状态: - team_id 为空 + source 非空 → **待定**(展示来源标签) - team_id 非空(source 可保留作为出身记录)→ **已定** 保留 source 的好处:对阵树可继续显示「A组第一」等小标签,改判时也能追溯晋级路径。 --- ## 5. 核心逻辑二:晋级联动 录分接口内部新增逻辑(伪代码): ``` 录分(matchId, homeScore, awayScore): 1. 常规校验 2. 淘汰赛校验:stage != 'group' 且比分相同 → 报错「淘汰赛不允许平局」 3. 写入比分、result、status = completed 4. 晋级解析: winner = 比分高的一方 loser = 另一方 对每一场下游比赛 D(home_source 或 away_source 形如 winner:{matchId} / loser:{matchId}): 若 D 已开打或已录分 → 走改判保护(见第 6 节) 否则把 winner/loser 写入 D 对应位置的 team_id 5. 重算排行榜(是否计入淘汰赛见第 10 节) ``` 要点: - **幂等**:重复提交相同比分时,直接覆盖下游 team_id 即可。 - **只传播一层**:半决赛 → 决赛。决赛结束后无需再写「冠军表」,由前端从决赛 result 推导即可。 --- ## 6. 核心逻辑三:改判、删除与锁定 ### 改判(修改已录比分) | 情况 | 处理 | |------|------| | 新胜者 == 旧胜者 | 只更新比分与排行榜,下游不受影响 | | 新胜者不同,且下游 status = pending 且未录分 | 更新比分,并联动替换下游对应位置的 team_id | | 新胜者不同,且下游已开打或已录分 | **拒绝修改**,提示「下一轮已开打,不能改判。如需修改请先重置下一轮」 | 原则:**晋级一旦被消耗(下游开打),上游冻结**。选择拒绝而非级联撤销,避免前台出现「打完的决赛突然消失」。 ### 辅助操作:重置比赛 提供「清空比分 + 回 pending」能力,方便需要改判链路时操作:先重置下游 → 再改上游 → 联动重新填充。 ### 删除比赛 - 若被下游 source 引用(`winner:{id}` / `loser:{id}`)→ 拒绝删除,提示先删下游。 - 删除骨架(决赛等)不限制。 - 批量删除时按 stage 降序(决赛 → 半决赛 → 小组赛),避免被引用检查逐个打回。 --- ## 7. 出线机制 小组赛结束后「谁出线」理论上可自动计算,但常见障碍: 1. 同分 tie-break 规则不完善或需要人工判断。 2. 排行榜缺少 group 维度,自动按组取第一名改动成本较高。 **推荐第一版:手动指定** - 后台展示各组积分快照(可前端现算)。 - 管理员点选出线队 → 写入半决赛 TBD 位。 - 优点:兼容任意出线规则(小组第二、外卡、跨组调整),老师拥有完全裁量权。 自动解析(`group:A:1` 自动落位)可作为二期增强,source 表达式从设计上兼容两种模式。 --- ## 8. 后端改动要点 ### 创建 / 更新 / 删除接口 - create:接受 stage / group_label / home_source / away_source。 - stage = group 时:group_label 与双方 team_id 必填。 - stage ∈ {semi, final, third} 时:source 至少一方必填,team_id 允许为空。 - source 形如 winner:{id} / loser:{id} 时,校验 id 存在且阶段合理。 - update:允许更新新字段,并执行改判保护。 - delete:检查是否被下游 source 引用。 ### 录分接口 - 淘汰赛强制无平局(即使项目配置允许平局)。 - 录分成功后执行晋级解析。 ### 重算排行榜 - 无论是否把淘汰赛计入积分,都必须**显式跳过 team_id 为 NULL 的 TBD 比赛**。 ### 操作日志 建议新增类型:创建淘汰赛骨架、指定出线队、重置比赛等,便于审计。 --- ## 9. 前端改动要点 ### 前台 - 入口:筛选区增加「淘汰赛对阵」链接。 - 对阵页(公开路由):按 stage 分组取数据,空态提示「淘汰赛尚未排布」。 - **对阵树组件**(4 强可硬编码结构): - 列1:两场半决赛 - 列2:决赛 - 列3:冠军卡 - TBD 显示「待定」+ 来源标签;胜者高亮,负者降透明度。 - 移动端:内容定宽 + 横向滚动,避免缩放字体。 ### 后台管理页 功能分三块: 1. **创建骨架**:生成半决赛 + 决赛(+ 可选三四名),防重复创建。 2. **首轮填充**:展示各组积分 → 点选出线队 → 写入半决赛 TBD。 3. **录分与重置**:列表 + 录分弹窗 + 重置按钮。 ### 兼容注意 - 原有赛程列表/录分弹窗需容忍 team 为空(显示「待定」)。 - 批量导入无 stage 字段时默认 `group`,保持向后兼容。 - 导出增加「阶段」列。 --- ## 10. 排行榜与积分 | 选项 | 效果 | 代价 | |------|------|------| | **计入**(推荐) | 淘汰赛胜利按现有规则加分 | 逻辑零额外成本 | | 不计入 | 总榜只反映小组赛 | 需按 stage 过滤 | | 单独加权 | 决赛胜场权重更高 | 积分模型需扩展,过重 | 无论选哪个,必须显式验证 TBD 比赛不参与计算。 冠军展示不依赖积分榜,直接由决赛 result 推导。 --- ## 11. 注意事项与坑 ### 数据库与部署 1. 上线顺序:先 migration → 再后端 → 最后前端。 2. stage 必须带默认值 `group`,存量数据自动成为小组赛。 3. 队伍字段可空后,所有依赖非空的视图/统计/前端展示都要复查。 4. source / group_label 对公开读无敏感信息,RLS 通常无需调整。 ### 业务逻辑 5. 淘汰赛平局必须双重拦截(后端强制 + 前端提示)。 6. 改判锁定粒度是「下游已开打」,不是「下游存在」。 7. 批量删除按 stage 降序。 8. 重算排行榜显式跳过 TBD。 9. 创建时校验 source 引用完整性;删除时检查下游引用。 10. 同年级/同项目重复创建骨架要拦截。 ### 展示与体验 11. 移动端是第一战场,对阵树用横向滚动。 12. 三种空态都要有合理显示:未创建骨架 / 已建未填人 / 已填未开打。 13. 深色模式适配,颜色走主题变量。 14. 淘汰赛保留真实轮次字段,确认筛选语义符合运营预期。 ### 流程与运维 15. 关键操作写日志,前端同步中文标签。 16. 先用一个小范围试点跑通全链路再推广。 17. 后端函数若不在仓库内,改完务必在 staging 验证。 --- ## 12. 实施顺序建议 | 步骤 | 内容 | 验证 | |------|------|------| | 1 | 数据库加字段 + 可空改造 | 插入一条带 stage 的测试比赛 | | 2 | 后端创建/更新/删除支持新字段与校验 | 手动调用接口 | | 3 | 录分接口:平局拦截 + 晋级解析 | 建骨架 → 录半决赛 → 看决赛是否被填充 | | 4 | 前端类型与数据层适配 | 构建通过 | | 5 | 前台对阵页 + 对阵树组件 | 真实数据渲染 + 手机横滚 | | 6 | 后台管理页(骨架 / 填人 / 重置) | 全链路跑通 | | 7 | 周边兼容(日志、导出、批量删除) | 回归测试 | ### 上线前回归清单 - [ ] 小组赛全流程与改造前一致 - [ ] 淘汰赛录平局被拒;录有效比分后下游自动出现胜者 - [ ] 上游改判(下游未开打)联动更新;下游已开打则拒绝且提示清晰 - [ ] 删除被引用的比赛被拒;删除骨架不受限 - [ ] TBD 在各处显示一致 - [ ] 重算排行榜不报错,TBD 不产生积分 - [ ] 操作日志有新类型与中文标签 - [ ] 手机端对阵树横滑流畅,空态正常 --- ## 13. 附录 ### Migration 示例 ```sql ALTER TABLE matches ADD COLUMN IF NOT EXISTS stage text NOT NULL DEFAULT 'group', ADD COLUMN IF NOT EXISTS group_label text, ADD COLUMN IF NOT EXISTS home_source text, ADD COLUMN IF NOT EXISTS away_source text; ALTER TABLE matches ALTER COLUMN home_team_id DROP NOT NULL; ALTER TABLE matches ALTER COLUMN away_team_id DROP NOT NULL; ALTER TABLE matches ADD CONSTRAINT chk_stage CHECK (stage IN ('group', 'semi', 'third', 'final')); ``` ### 晋级解析伪代码 ```ts async function resolvePromotion(matchId: number) { const m = await getMatch(matchId); if (m.stage === 'group' || m.result === 'pending' || m.result === 'draw') return; const winnerId = m.result === 'home_win' ? m.home_team_id : m.awa