feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
1 parent
e5e0944d26
commit
08a5740fb2
64 files changed
+4968
-270
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -385,7 +386,7 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
// ---------- entries ----------
|
||||
|
||||
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
|
||||
e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
|
||||
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
var (
|
||||
@@ -393,6 +394,7 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
athleteID sql.NullInt64
|
||||
memberNames sql.NullString
|
||||
bib, seed, rem sql.NullString
|
||||
heat sql.NullInt64
|
||||
athleteName sql.NullString
|
||||
teamCode sql.NullString
|
||||
teamName sql.NullString
|
||||
@@ -400,13 +402,14 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
categoryName sql.NullString
|
||||
)
|
||||
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
|
||||
&bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.AthleteID = i64Ptr(athleteID)
|
||||
e.MemberNames = parseNames(memberNames)
|
||||
e.Bib = strPtr(bib)
|
||||
e.Seed = strPtr(seed)
|
||||
e.Heat = i64Ptr(heat)
|
||||
e.Remark = strPtr(rem)
|
||||
e.AthleteName = strPtr(athleteName)
|
||||
e.TeamCode = teamCode.String
|
||||
@@ -483,10 +486,10 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -506,9 +509,9 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
||||
bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -532,3 +535,75 @@ func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
||||
}
|
||||
return requireChanges(res, "报名项不存在")
|
||||
}
|
||||
|
||||
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
|
||||
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
|
||||
const maxEntryHeat = 999
|
||||
|
||||
// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。
|
||||
//
|
||||
// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。
|
||||
// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。
|
||||
func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) {
|
||||
ids := dedupeIDs(entryIDs)
|
||||
if len(ids) == 0 {
|
||||
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
|
||||
}
|
||||
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
|
||||
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
found := make(map[int64]bool, len(ids))
|
||||
events := make(map[int64]bool)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
eventID int64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &eventID); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
found[entryID] = true
|
||||
events[eventID] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !found[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing)
|
||||
}
|
||||
// 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。
|
||||
if len(events) > 1 {
|
||||
return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil)
|
||||
}
|
||||
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`,
|
||||
append([]any{nullI64(heat)}, args...)...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
updated, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil
|
||||
}
|
||||
Reference in new issue
Block a user