feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动

人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

+140
View File
@@ -1,8 +1,10 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
@@ -140,3 +142,141 @@ func TestAthleteIDCardUnique(t *testing.T) {
t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got)
}
}
// 号码布与证件号同为唯一标识:可读写、重复 409(文案区分字段)、空值不互斥。
func TestAthleteBibUnique(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("仅给号码布应能建人员,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["bib"] != "A001" {
t.Fatalf("建人员应返回 bib: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("重复号码布应 409 CONFLICT,得到 %d %+v", status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "号码布") {
t.Fatalf("409 文案应点明号码布,得到 %+v", env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
"name": "李强", "gender": "男", "teamId": ids.teamB, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("改人员到重复号码布应 409,得到 %d %+v", status, env2.Error)
}
// 空号码布是合法的,多条空值不能互相冲突;唯一索引兜底重复写入。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("空号码布应可建,得到 %d %+v", status, env2.Error)
}
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
if _, err := env.ev.Exec(
`INSERT INTO athletes (name, team_id, bib, active) VALUES ('丙', ?, 'A001', 1)`, ids.teamA); err == nil {
t.Fatalf("唯一索引应拒绝重复号码布")
}
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
got := decodeData[map[string]any](t, env2)
if status != http.StatusOK || got["bib"] != "A001" {
t.Fatalf("读人员应带 bib,得到 %d %+v", status, got)
}
}
// 年龄可读可写;越界或非整数返回 400,不填时为空。
func TestAthleteAgeReadWriteAndRange(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 30, "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["age"] != float64(30) {
t.Fatalf("建人员应返回 age: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
if got := decodeData[map[string]any](t, env2); status != http.StatusOK || got["age"] != float64(30) {
t.Fatalf("读人员应带 age,得到 %d %+v", status, got)
}
// 边界值 0 与 120 合法。
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 0, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(0) {
t.Fatalf("age=0 应可保存,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 120, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(120) {
t.Fatalf("age=120 应可保存,得到 %d %+v", status, env2.Error)
}
for _, age := range []int{-1, 121} {
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": age, "active": true,
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("age=%d 应 400,得到 %d %+v", age, status, env2.Error)
}
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "age": 121, "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("新建年龄越界应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "赵六", "gender": "男", "teamId": ids.teamA, "age": "abc", "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("年龄非整数应 400,得到 %d %+v", status, env2.Error)
}
// 不填年龄是合法的,返回空值。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("不填年龄应可建,得到 %d %+v", status, env2.Error)
}
if created = decodeData[map[string]any](t, env2); created["age"] != nil {
t.Fatalf("没填年龄应返回 null: %+v", created)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, athleteID).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 120 {
t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age)
}
}