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:
1 parent
24fff8dd08
commit
6483acf9ad
50 files changed
+2980
-395
No files matched your search
@@ -287,18 +287,20 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
||||
|
||||
// ---------- athletes ----------
|
||||
|
||||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active`
|
||||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
|
||||
|
||||
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
||||
var (
|
||||
a athleteDTO
|
||||
gender, empNo, idCard sql.NullString
|
||||
teamID sql.NullInt64
|
||||
a athleteDTO
|
||||
gender, empNo, idCard, bib sql.NullString
|
||||
teamID, age sql.NullInt64
|
||||
)
|
||||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active)
|
||||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age, &a.Active)
|
||||
a.Gender = strPtr(gender)
|
||||
a.EmployeeNo = strPtr(empNo)
|
||||
a.IDCard = strPtr(idCard)
|
||||
a.Bib = strPtr(bib)
|
||||
a.Age = intPtr(age)
|
||||
if teamID.Valid {
|
||||
a.TeamID = teamID.Int64
|
||||
}
|
||||
@@ -314,6 +316,14 @@ func isIDCardConflict(err error) bool {
|
||||
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
|
||||
}
|
||||
|
||||
// isBibConflict 判断错误是否来自 athletes.bib 的唯一索引,文案要与证件号冲突区分开。
|
||||
func isBibConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "athletes.bib")
|
||||
}
|
||||
|
||||
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
||||
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
||||
args := []any{}
|
||||
@@ -351,30 +361,36 @@ func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// validateAthleteAge 年龄可以留空,填了就必须落在 0–120(JSON 已保证是整数)。
|
||||
func validateAthleteAge(age *int) error {
|
||||
if age != nil && (*age < 0 || *age > 120) {
|
||||
return badRequest("age 需要在 0-120 之间", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
||||
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
||||
}
|
||||
// 证件号是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||||
if in.IDCard != nil && *in.IDCard != "" {
|
||||
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? LIMIT 1`, *in.IDCard)
|
||||
if err != nil {
|
||||
return athleteDTO{}, internal(err)
|
||||
}
|
||||
if dup {
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
// 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||||
if err := s.checkAthleteIdentity(ctx, in, 0); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, active) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active)
|
||||
if isIDCardConflict(err) {
|
||||
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active)
|
||||
switch {
|
||||
case isIDCardConflict(err):
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
case isBibConflict(err):
|
||||
return athleteDTO{}, conflict("号码布已被其它人员使用")
|
||||
case isForeignKeyViolation(err):
|
||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||
}
|
||||
if err != nil {
|
||||
case err != nil:
|
||||
return athleteDTO{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
@@ -384,26 +400,47 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
||||
return s.getAthlete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
||||
// checkAthleteIdentity 校验证件号/号码布是否已被他人占用(id 为 0 表示新建)。
|
||||
func (s *server) checkAthleteIdentity(ctx context.Context, in athleteDTO, id int64) error {
|
||||
if in.IDCard != nil && *in.IDCard != "" {
|
||||
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id)
|
||||
if err != nil {
|
||||
return athleteDTO{}, internal(err)
|
||||
return internal(err)
|
||||
}
|
||||
if dup {
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
return conflict("证件号已被其它人员使用")
|
||||
}
|
||||
}
|
||||
if in.Bib != nil && *in.Bib != "" {
|
||||
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE bib = ? AND id <> ? LIMIT 1`, *in.Bib, id)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if dup {
|
||||
return conflict("号码布已被其它人员使用")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := s.checkAthleteIdentity(ctx, in, id); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, active = ? WHERE id = ?`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active, id)
|
||||
if isIDCardConflict(err) {
|
||||
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, bib = ?, age = ?, active = ? WHERE id = ?`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active, id)
|
||||
switch {
|
||||
case isIDCardConflict(err):
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
case isBibConflict(err):
|
||||
return athleteDTO{}, conflict("号码布已被其它人员使用")
|
||||
case isForeignKeyViolation(err):
|
||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||
}
|
||||
if err != nil {
|
||||
case err != nil:
|
||||
return athleteDTO{}, internal(err)
|
||||
}
|
||||
if err := requireChanges(res, "运动员不存在"); err != nil {
|
||||
|
||||
Reference in new issue
Block a user