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
@@ -14,7 +14,7 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空)。
|
||||
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空,Age 为空表示表里没填)。
|
||||
type athleteImportRow struct {
|
||||
Line int
|
||||
Name string
|
||||
@@ -22,6 +22,16 @@ type athleteImportRow struct {
|
||||
TeamID int64
|
||||
TeamCode string
|
||||
IDCard string
|
||||
Bib string
|
||||
Age *int
|
||||
}
|
||||
|
||||
// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。
|
||||
func (in athleteImportRow) decisionKey() string {
|
||||
if in.IDCard != "" {
|
||||
return in.IDCard
|
||||
}
|
||||
return in.Bib
|
||||
}
|
||||
|
||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||
@@ -38,91 +48,133 @@ type existingAthlete struct {
|
||||
Gender *string
|
||||
TeamID int64
|
||||
EmployeeNo *string
|
||||
IDCard *string
|
||||
Bib *string
|
||||
Age *int
|
||||
}
|
||||
|
||||
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 姓名+单位(库中证件号为空则回填)→ 新建。
|
||||
// 证件号命中但姓名/性别/单位与库中不一致时记为冲突;decisions 里对该证件号有决策时按决策落库。
|
||||
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。
|
||||
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
existing, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
existing, err := s.athleteByIdentity(ctx, in)
|
||||
switch {
|
||||
case err == nil:
|
||||
changed := changedAthleteFields(existing, in)
|
||||
if len(changed) == 0 {
|
||||
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, existing, changed)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
action, decided := decisions[in.IDCard]
|
||||
switch {
|
||||
case !decided:
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
case action == decisionKeepExisting:
|
||||
// 人员字段保持库里的值,报名也跟随库里的单位。
|
||||
return athleteImportMatch{AthleteID: existing.ID, TeamID: existing.TeamID}, nil
|
||||
default:
|
||||
if err := s.overwriteAthleteFromImport(ctx, existing.ID, in); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return s.matchOrCreateAthleteByName(ctx, in)
|
||||
return s.matchOrCreateAthleteByName(ctx, in, decisions)
|
||||
default:
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// matchOrCreateAthleteByName 处理证件号未命中的情况:
|
||||
// 姓名+单位命中且库中证件号为空 → 视为同一人并回填证件号;否则新建人员。
|
||||
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
|
||||
var id int64
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM athletes WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
|
||||
ORDER BY id LIMIT 1`, in.Name, in.TeamID).Scan(&id)
|
||||
// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。
|
||||
func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) {
|
||||
if in.IDCard != "" {
|
||||
ex, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return ex, err
|
||||
}
|
||||
}
|
||||
if in.Bib != "" {
|
||||
return s.athleteByBib(ctx, in.Bib)
|
||||
}
|
||||
return existingAthlete{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段,
|
||||
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
||||
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||
changed := changedAthleteFields(ex, in)
|
||||
if len(changed) == 0 {
|
||||
if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
action, decided := decisions[in.decisionKey()]
|
||||
switch {
|
||||
case !decided:
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
case action == decisionKeepExisting:
|
||||
// 人员字段保持库里的值,报名也跟随库里的单位。
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID}, nil
|
||||
default:
|
||||
if err := s.overwriteAthleteFromImport(ctx, ex.ID, in); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况:
|
||||
// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。
|
||||
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID)
|
||||
switch {
|
||||
case err == nil:
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, id); err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
||||
return s.matchExistingAthlete(ctx, in, existing, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES (?, ?, ?, ?, 1)`,
|
||||
in.Name, nullStr(&in.Gender), in.TeamID, in.IDCard)
|
||||
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
id, err = lastInsertID(res)
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
|
||||
}
|
||||
|
||||
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
|
||||
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
|
||||
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
|
||||
|
||||
func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete, error) {
|
||||
var (
|
||||
a existingAthlete
|
||||
gender, empNo sql.NullString
|
||||
teamID sql.NullInt64
|
||||
a existingAthlete
|
||||
gender, empNo, idCard, bib sql.NullString
|
||||
teamID, age sql.NullInt64
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT id, name, gender, team_id, employee_no FROM athletes WHERE id_card = ? LIMIT 1`, idCard).
|
||||
Scan(&a.ID, &a.Name, &gender, &teamID, &empNo)
|
||||
if err != nil {
|
||||
if err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age); err != nil {
|
||||
return existingAthlete{}, err
|
||||
}
|
||||
a.Gender = strPtr(gender)
|
||||
a.EmployeeNo = strPtr(empNo)
|
||||
a.IDCard = strPtr(idCard)
|
||||
a.Bib = strPtr(bib)
|
||||
a.Age = intPtr(age)
|
||||
a.TeamID = teamID.Int64
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
|
||||
}
|
||||
|
||||
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
||||
}
|
||||
|
||||
// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。
|
||||
func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes
|
||||
WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
|
||||
ORDER BY id LIMIT 1`, name, teamID))
|
||||
}
|
||||
|
||||
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
|
||||
// 证件号/号码布只在两边都有值且不同时算冲突;库中为空属于待回填,不算冲突。
|
||||
func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
|
||||
var changed []string
|
||||
if ex.Name != in.Name {
|
||||
@@ -132,12 +184,47 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
|
||||
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
|
||||
changed = append(changed, "gender")
|
||||
}
|
||||
// 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。
|
||||
if in.Age != nil && ex.Age != nil && *ex.Age != *in.Age {
|
||||
changed = append(changed, "age")
|
||||
}
|
||||
if ex.TeamID != in.TeamID {
|
||||
changed = append(changed, "teamCode")
|
||||
}
|
||||
if identityDiffers(ex.IDCard, in.IDCard) {
|
||||
changed = append(changed, "idCard")
|
||||
}
|
||||
if identityDiffers(ex.Bib, in.Bib) {
|
||||
changed = append(changed, "bib")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// identityDiffers 判断身份字段(证件号/号码布)是否冲突:两边都有值且不同才算。
|
||||
func identityDiffers(dbVal *string, inVal string) bool {
|
||||
return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal
|
||||
}
|
||||
|
||||
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
|
||||
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
|
||||
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
if in.Bib != "" && (ex.Bib == nil || *ex.Bib == "") {
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET bib = ? WHERE id = ?`, in.Bib, ex.ID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
if in.Age != nil && ex.Age == nil {
|
||||
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET age = ? WHERE id = ?`, *in.Age, ex.ID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) {
|
||||
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
|
||||
if err != nil {
|
||||
@@ -146,12 +233,15 @@ func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex
|
||||
return importConflict{
|
||||
Row: in.Line,
|
||||
IDCard: in.IDCard,
|
||||
Bib: in.Bib,
|
||||
AthleteID: ex.ID,
|
||||
Existing: importAthleteView{
|
||||
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
|
||||
IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age,
|
||||
},
|
||||
Incoming: importAthleteView{
|
||||
Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode,
|
||||
IDCard: nilIfEmpty(in.IDCard), Bib: nilIfEmpty(in.Bib), Age: in.Age,
|
||||
},
|
||||
ChangedFields: changed,
|
||||
}, nil
|
||||
@@ -169,11 +259,12 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
|
||||
}
|
||||
|
||||
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
|
||||
// 表里没填性别时保留库里的值,避免决策后反而丢字段。
|
||||
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
|
||||
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender) WHERE id = ?`,
|
||||
in.Name, in.TeamID, nullStr(&in.Gender), id); err != nil {
|
||||
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
|
||||
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
|
||||
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user