From 72462792ec4ee46abc087827d91f73e47b56d4aa Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 9 Sep 2026 19:51:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E5=8F=98=E6=9B=B4=E5=8E=86=E5=8F=B2=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=9B=E4=BF=AE=E5=A4=8D=20result=5Frevisions.paren?= =?UTF-8?q?t=5Fid=20=E4=BB=8E=E6=9C=AA=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/main/displays.js | 18 +- server/internal/httpapi/dto.go | 74 ++ server/internal/httpapi/handlers_revisions.go | 554 ++++++++++++ server/internal/httpapi/handlers_users.go | 241 ++++++ server/internal/httpapi/payload.go | 24 + server/internal/httpapi/scoring.go | 12 +- server/internal/httpapi/server.go | 10 + .../internal/httpapi/users_revisions_test.go | 288 +++++++ .../admin/components/baseData/FormDialog.vue | 81 ++ .../admin/components/baseData/FormField.vue | 47 + .../baseData/SportsEventsSection.vue | 736 ++++++++++++++++ .../components/baseData/TeamsSection.vue | 321 +++++++ .../admin/components/baseData/baseData.css | 211 +++++ .../admin/components/baseData/baseDataApi.ts | 132 +++ .../admin/components/baseData/mockBaseData.ts | 529 ++++++++++++ web/src/admin/components/baseData/tabs.ts | 29 + .../admin/components/baseData/useBaseData.ts | 312 +++++++ .../components/history/RevisionGraph.vue | 111 +++ web/src/admin/components/history/graph.ts | 188 ++++ .../admin/components/history/mockRevisions.ts | 178 ++++ .../components/history/revisionsSource.ts | 35 + web/src/admin/components/history/types.ts | 167 ++++ .../admin/components/system/BackupPanel.vue | 249 ++++++ .../components/system/EventInfoPanel.vue | 262 ++++++ web/src/admin/components/system/JsonDiff.vue | 285 +++++++ web/src/admin/components/system/MockBadge.vue | 21 + .../admin/components/system/UsersPanel.vue | 446 ++++++++++ web/src/admin/components/system/systemApi.ts | 804 ++++++++++++++++++ web/src/admin/pages/AboutPage.vue | 315 +++++++ web/src/admin/pages/AdjustmentsPage.vue | 605 +++++++++++++ web/src/admin/pages/ExportPage.vue | 787 +++++++++++++++++ web/src/admin/pages/LogsPage.vue | 314 +++++++ web/src/admin/pages/RecordsPage.vue | 649 ++++++++++++++ web/src/admin/pages/ReviewPage.vue | 729 ++++++++++++++++ web/src/admin/pages/SystemPage.vue | 90 ++ web/src/display/App.vue | 58 +- .../display/components/WindowedTitleBar.vue | 89 ++ web/src/display/useStageScale.ts | 14 +- web/src/display/windowed.ts | 14 + 39 files changed, 10001 insertions(+), 28 deletions(-) create mode 100644 server/internal/httpapi/handlers_revisions.go create mode 100644 server/internal/httpapi/handlers_users.go create mode 100644 server/internal/httpapi/users_revisions_test.go create mode 100644 web/src/admin/components/baseData/FormDialog.vue create mode 100644 web/src/admin/components/baseData/FormField.vue create mode 100644 web/src/admin/components/baseData/SportsEventsSection.vue create mode 100644 web/src/admin/components/baseData/TeamsSection.vue create mode 100644 web/src/admin/components/baseData/baseData.css create mode 100644 web/src/admin/components/baseData/baseDataApi.ts create mode 100644 web/src/admin/components/baseData/mockBaseData.ts create mode 100644 web/src/admin/components/baseData/tabs.ts create mode 100644 web/src/admin/components/baseData/useBaseData.ts create mode 100644 web/src/admin/components/history/RevisionGraph.vue create mode 100644 web/src/admin/components/history/graph.ts create mode 100644 web/src/admin/components/history/mockRevisions.ts create mode 100644 web/src/admin/components/history/revisionsSource.ts create mode 100644 web/src/admin/components/history/types.ts create mode 100644 web/src/admin/components/system/BackupPanel.vue create mode 100644 web/src/admin/components/system/EventInfoPanel.vue create mode 100644 web/src/admin/components/system/JsonDiff.vue create mode 100644 web/src/admin/components/system/MockBadge.vue create mode 100644 web/src/admin/components/system/UsersPanel.vue create mode 100644 web/src/admin/components/system/systemApi.ts create mode 100644 web/src/admin/pages/AboutPage.vue create mode 100644 web/src/admin/pages/AdjustmentsPage.vue create mode 100644 web/src/admin/pages/ExportPage.vue create mode 100644 web/src/admin/pages/LogsPage.vue create mode 100644 web/src/admin/pages/RecordsPage.vue create mode 100644 web/src/admin/pages/ReviewPage.vue create mode 100644 web/src/admin/pages/SystemPage.vue create mode 100644 web/src/display/components/WindowedTitleBar.vue create mode 100644 web/src/display/windowed.ts diff --git a/console/main/displays.js b/console/main/displays.js index 0005ff6..7f9954f 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -92,7 +92,7 @@ class DisplayManager { this.applyGeometry(existing.win, display, single) if (existing.page !== page) { existing.page = page - this.loadPage(existing, display, page) + this.loadPage(existing, display, page, single) } return } @@ -107,7 +107,11 @@ class DisplayManager { const win = new BrowserWindow({ ...this.geometryFor(display, single), show: false, - frame: single, + // 窗口化模式由页面自绘标题栏(与应用其它窗口同风格),保留原生边框会多出一条标题栏; + // 多屏 kiosk 本来就不要边框 + frame: false, + // 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false + titleBarStyle: 'hidden', kiosk: !single && this.display.kiosk !== false, autoHideMenuBar: true, backgroundColor: this.display.backgroundColor || '#000000', @@ -156,7 +160,7 @@ class DisplayManager { } this.applyGeometry(win, display, single) - this.loadPage(entry, display, page) + this.loadPage(entry, display, page, single) this.log.push('console', `打开大屏窗口 display=${display.id} bounds=${bounds.width}x${bounds.height}@${bounds.x},${bounds.y} page=${page}`) return entry } @@ -186,9 +190,9 @@ class DisplayManager { win.setBounds(this.geometryFor(display, single)) } - loadPage(entry, display, page) { + loadPage(entry, display, page, single) { if (!entry || !entry.win || entry.win.isDestroyed()) return - const url = this.urlFor(display, page) + const url = this.urlFor(display, page, single) this.log.push('console', `大屏加载 ${url}`) entry.win.loadURL(url).catch((err) => { // 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载 @@ -196,13 +200,15 @@ class DisplayManager { }) } - urlFor(display, page) { + urlFor(display, page, single) { const params = new URLSearchParams() params.set(this.display.pageParam || 'page', page) // lock=1 告知页面锁死单页,不参与轮播 params.set('lock', '1') params.set('screen', String(display.id)) params.set('instance', `${os.hostname()}-${display.id}`) + // 只有单屏窗口化才画标题栏;多屏 kiosk 全屏无人值守,不能占高度 + if (single) params.set('windowed', '1') return `http://127.0.0.1:${this.server.port}/display?${params.toString()}` } diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 77b81a7..d3d6557 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -286,6 +286,80 @@ type eventSettingsStandings struct { ShowIndividualBoard bool `json:"showIndividualBoard"` } +// ---------- 用户管理 ---------- + +type userListItemDTO struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Active bool `json:"active"` + MustChangePassword bool `json:"mustChangePassword"` + LastLoginAt *string `json:"lastLoginAt"` +} + +// resetPasswordResultDTO 里的 password 是唯一一次明文,落库只存哈希。 +type resetPasswordResultDTO struct { + ID int64 `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + Generated bool `json:"generated"` + MustChangePassword bool `json:"mustChangePassword"` +} + +// ---------- 变更历史 ---------- + +type revisionPageDTO struct { + Items []revisionListItemDTO `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type revisionListItemDTO struct { + ID int64 `json:"id"` + ParentID *int64 `json:"parentId"` + EventID int64 `json:"eventId"` + EventName string `json:"eventName"` + Action string `json:"action"` + ActorID *int64 `json:"actorId"` + ActorName string `json:"actorName"` + Message *string `json:"message"` + CreatedAt string `json:"createdAt"` + ChangedCount int `json:"changedCount"` +} + +type revisionDetailDTO struct { + revisionListItemDTO + Snapshot []resultDTO `json:"snapshot"` + Diff revisionDiffDTO `json:"diff"` +} + +type revisionDiffDTO struct { + Results []revisionResultChangeDTO `json:"results"` + Teams []revisionTeamDeltaDTO `json:"teams"` +} + +type revisionResultChangeDTO struct { + ResultID int64 `json:"resultId"` + EntryID int64 `json:"entryId"` + TeamID *int64 `json:"teamId"` + Change string `json:"change"` + Fields []string `json:"fields,omitempty"` + Before *resultDTO `json:"before"` + After *resultDTO `json:"after"` +} + +type revisionTeamDeltaDTO struct { + TeamID int64 `json:"teamId"` + TeamCode string `json:"teamCode"` + TeamName string `json:"teamName"` + PointsBefore float64 `json:"pointsBefore"` + PointsAfter float64 `json:"pointsAfter"` + PointsDelta float64 `json:"pointsDelta"` + ChangedResults int `json:"changedResults"` +} + // ---------- domain → DTO ---------- func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO { diff --git a/server/internal/httpapi/handlers_revisions.go b/server/internal/httpapi/handlers_revisions.go new file mode 100644 index 0000000..c113e33 --- /dev/null +++ b/server/internal/httpapi/handlers_revisions.go @@ -0,0 +1,554 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "sort" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// ---------- 变更历史(recorder/admin 可读) ---------- + +// 成绩变化的三种形态,与前端展示文案一一对应。 +const ( + revisionChangeAdded = "added" + revisionChangeRemoved = "removed" + revisionChangeChanged = "changed" +) + +func (s *server) listRevisionsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + eventID, _ := int64Query(r, "eventId") + limit := intQuery(r, "limit", 50) + if limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + offset := intQuery(r, "offset", 0) + if offset < 0 { + offset = 0 + } + return s.listRevisions(r.Context(), eventID, limit, offset) +} + +func (s *server) getRevisionDetailHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getRevisionDetail(r.Context(), id) +} + +// revisionRow 是 result_revisions 的一行,含解析后的快照。 +type revisionRow struct { + id int64 + parentID *int64 + eventID int64 + eventName string + action string + actorID *int64 + message *string + createdAt string + snapshot []resultDTO +} + +const revisionColumns = `r.id, r.parent_id, r.event_id, r.action, r.actor_id, r.message, r.created_at, r.snapshot_json` + +func scanRevision(row interface{ Scan(...any) error }) (revisionRow, error) { + var ( + out revisionRow + parentID sql.NullInt64 + actorID sql.NullInt64 + message sql.NullString + snapshot string + eventName sql.NullString + ) + if err := row.Scan(&out.id, &parentID, &out.eventID, &out.action, &actorID, &message, + &out.createdAt, &snapshot, &eventName); err != nil { + return out, err + } + out.parentID = i64Ptr(parentID) + out.actorID = i64Ptr(actorID) + out.message = strPtr(message) + out.eventName = eventName.String + if err := json.Unmarshal([]byte(snapshot), &out.snapshot); err != nil { + return out, internal(err) + } + return out, nil +} + +func (s *server) listRevisions(ctx context.Context, eventID int64, limit, offset int) (revisionPageDTO, error) { + where := []string{"1 = 1"} + args := []any{} + if eventID > 0 { + where = append(where, "r.event_id = ?") + args = append(args, eventID) + } + clause := " WHERE " + joinAnd(where) + + var total int + if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM result_revisions r`+clause, args...).Scan(&total); err != nil { + return revisionPageDTO{}, internal(err) + } + + query := `SELECT ` + revisionColumns + `, COALESCE(e.name, '') + FROM result_revisions r + LEFT JOIN events e ON e.id = r.event_id` + clause + ` ORDER BY r.id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return revisionPageDTO{}, internal(err) + } + defer func() { _ = rows.Close() }() + + list := make([]revisionRow, 0) + for rows.Next() { + row, err := scanRevision(rows) + if err != nil { + return revisionPageDTO{}, internal(err) + } + list = append(list, row) + } + if err := rows.Err(); err != nil { + return revisionPageDTO{}, internal(err) + } + + items, err := s.revisionListItems(ctx, list) + if err != nil { + return revisionPageDTO{}, err + } + return revisionPageDTO{Items: items, Total: total, Limit: limit, Offset: offset}, nil +} + +// revisionListItems 为每行补上操作人姓名与「与父节点相比的成绩变化条数」。 +func (s *server) revisionListItems(ctx context.Context, list []revisionRow) ([]revisionListItemDTO, error) { + actorIDs := make([]int64, 0, len(list)) + for _, row := range list { + if row.actorID != nil { + actorIDs = append(actorIDs, *row.actorID) + } + } + names, err := s.userNames(ctx, actorIDs) + if err != nil { + return nil, err + } + + items := make([]revisionListItemDTO, 0, len(list)) + for _, row := range list { + parent, _, err := s.parentSnapshot(ctx, row) + if err != nil { + return nil, err + } + items = append(items, revisionListItem(row, names, len(diffSnapshots(parent, row.snapshot)))) + } + return items, nil +} + +func revisionListItem(row revisionRow, names map[int64]string, changedCount int) revisionListItemDTO { + item := revisionListItemDTO{ + ID: row.id, + ParentID: row.parentID, + EventID: row.eventID, + EventName: row.eventName, + Action: row.action, + ActorID: row.actorID, + Message: row.message, + CreatedAt: row.createdAt, + ChangedCount: changedCount, + } + if row.actorID != nil { + item.ActorName = names[*row.actorID] + } + return item +} + +func (s *server) getRevision(ctx context.Context, id int64) (revisionRow, error) { + row, err := scanRevision(s.ev.QueryRowContext(ctx, + `SELECT `+revisionColumns+`, COALESCE(e.name, '') + FROM result_revisions r + LEFT JOIN events e ON e.id = r.event_id + WHERE r.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return row, notFound("变更记录不存在") + } + if err != nil { + return row, internal(err) + } + return row, nil +} + +func (s *server) getRevisionDetail(ctx context.Context, id int64) (revisionDetailDTO, error) { + row, err := s.getRevision(ctx, id) + if err != nil { + return revisionDetailDTO{}, err + } + parent, _, err := s.parentSnapshot(ctx, row) + if err != nil { + return revisionDetailDTO{}, err + } + changes := diffSnapshots(parent, row.snapshot) + + entryTeams, err := s.entryTeams(ctx, changeEntryIDs(changes)) + if err != nil { + return revisionDetailDTO{}, err + } + for i := range changes { + if teamID := entryTeams[changes[i].EntryID]; teamID != 0 { + changes[i].TeamID = &teamID + } + } + + // 分数增减依赖当前计分配置重算快照;配置被删改时降级为只返回成绩差异。 + teams, err := s.revisionTeamDeltas(ctx, row.eventID, parent, row.snapshot, changes, entryTeams) + if err != nil { + s.logger.Warn("变更历史分数差异计算失败", "revisionId", id, "eventId", row.eventID, "err", err) + teams = []revisionTeamDeltaDTO{} + } + + names, err := s.userNames(ctx, actorIDList(row.actorID)) + if err != nil { + return revisionDetailDTO{}, err + } + return revisionDetailDTO{ + revisionListItemDTO: revisionListItem(row, names, len(changes)), + Snapshot: row.snapshot, + Diff: revisionDiffDTO{Results: changes, Teams: teams}, + }, nil +} + +// parentSnapshot 返回父节点快照:优先用显式 parent_id; +// 现有写入逻辑不写 parent_id,因此退化为「同小项上一条记录」,与文档「每个小项一条主线」一致。 +func (s *server) parentSnapshot(ctx context.Context, row revisionRow) ([]resultDTO, bool, error) { + var ( + raw string + err error + ) + if row.parentID != nil { + err = s.ev.QueryRowContext(ctx, `SELECT snapshot_json FROM result_revisions WHERE id = ?`, *row.parentID).Scan(&raw) + } else { + err = s.ev.QueryRowContext(ctx, + `SELECT snapshot_json FROM result_revisions WHERE event_id = ? AND id < ? ORDER BY id DESC LIMIT 1`, + row.eventID, row.id).Scan(&raw) + } + if errors.Is(err, sql.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, internal(err) + } + var snapshot []resultDTO + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil { + return nil, false, internal(err) + } + return snapshot, true, nil +} + +func actorIDList(id *int64) []int64 { + if id == nil { + return nil + } + return []int64{*id} +} + +func changeEntryIDs(changes []revisionResultChangeDTO) []int64 { + out := make([]int64, 0, len(changes)) + for _, c := range changes { + out = append(out, c.EntryID) + } + return out +} + +// diffSnapshots 按成绩 id 对比两份快照,输出新增/删除/修改。 +func diffSnapshots(parent, current []resultDTO) []revisionResultChangeDTO { + parentByID := make(map[int64]resultDTO, len(parent)) + for _, r := range parent { + parentByID[r.ID] = r + } + currentByID := make(map[int64]resultDTO, len(current)) + for _, r := range current { + currentByID[r.ID] = r + } + + out := make([]revisionResultChangeDTO, 0) + for _, r := range current { + after := r + old, ok := parentByID[r.ID] + if !ok { + out = append(out, revisionResultChangeDTO{ + ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeAdded, After: &after, + }) + continue + } + before := old + if fields := changedResultFields(before, after); len(fields) > 0 { + out = append(out, revisionResultChangeDTO{ + ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeChanged, + Fields: fields, Before: &before, After: &after, + }) + } + } + for _, r := range parent { + if _, ok := currentByID[r.ID]; ok { + continue + } + before := r + out = append(out, revisionResultChangeDTO{ + ResultID: r.ID, EntryID: r.EntryID, Change: revisionChangeRemoved, Before: &before, + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ResultID < out[j].ResultID }) + return out +} + +func changedResultFields(a, b resultDTO) []string { + fields := make([]string, 0, 4) + if !intPtrEqual(a.Rank, b.Rank) { + fields = append(fields, "rank") + } + if a.ResultText != b.ResultText { + fields = append(fields, "resultText") + } + if !f64PtrEqual(a.ResultValue, b.ResultValue) { + fields = append(fields, "resultValue") + } + if a.Unit != b.Unit { + fields = append(fields, "unit") + } + if a.IsTie != b.IsTie { + fields = append(fields, "isTie") + } + if !f64PtrEqual(a.PointsOverride, b.PointsOverride) { + fields = append(fields, "pointsOverride") + } + if a.Status != b.Status { + fields = append(fields, "status") + } + if a.State != b.State { + fields = append(fields, "state") + } + if !strPtrEqual(a.Note, b.Note) { + fields = append(fields, "note") + } + return fields +} + +func intPtrEqual(a, b *int) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func f64PtrEqual(a, b *float64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func strPtrEqual(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// revisionTeamDeltas 用快照重算受影响单位的分数增减。 +// 快照里没有单位与积分,只能借当前计分配置重算;仅用于展示,不参与任何计算。 +func (s *server) revisionTeamDeltas(ctx context.Context, eventID int64, parent, current []resultDTO, changes []revisionResultChangeDTO, entryTeams map[int64]int64) ([]revisionTeamDeltaDTO, error) { + before, err := s.snapshotTeamPoints(ctx, eventID, parent) + if err != nil { + return nil, err + } + after, err := s.snapshotTeamPoints(ctx, eventID, current) + if err != nil { + return nil, err + } + + counts := make(map[int64]int) + for _, c := range changes { + if teamID := entryTeams[c.EntryID]; teamID != 0 { + counts[teamID]++ + } + } + // 成绩本身没变、但积分因重算而变的单位也要列出来。 + for teamID, points := range after { + if points != before[teamID] { + if _, ok := counts[teamID]; !ok { + counts[teamID] = 0 + } + } + } + + teams, err := s.teamMap(ctx) + if err != nil { + return nil, err + } + out := make([]revisionTeamDeltaDTO, 0, len(counts)) + for teamID, n := range counts { + t := teams[teamID] + out = append(out, revisionTeamDeltaDTO{ + TeamID: teamID, + TeamCode: t.Code, + TeamName: t.Name, + PointsBefore: before[teamID], + PointsAfter: after[teamID], + PointsDelta: after[teamID] - before[teamID], + ChangedResults: n, + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].TeamID < out[j].TeamID }) + return out, nil +} + +// snapshotTeamPoints 把一份成绩快照按当前通道/模板重算成「单位 → 总分」。 +func (s *server) snapshotTeamPoints(ctx context.Context, eventID int64, snapshot []resultDTO) (map[int64]float64, error) { + out := make(map[int64]float64) + ev, err := loadEventQ(ctx, s.ev, eventID) + if err != nil { + return nil, err + } + categories, err := loadCategoriesQ(ctx, s.ev, eventID) + if err != nil { + return nil, err + } + channels, err := loadChannelsQ(ctx, s.ev, eventID) + if err != nil { + return nil, err + } + profileIDs := make(map[int64]bool, len(channels)) + for _, ch := range channels { + profileIDs[ch.ProfileID] = true + } + profiles, err := loadProfilesQ(ctx, s.ev, profileIDs) + if err != nil { + return nil, err + } + entries, err := loadEntriesQ(ctx, s.ev, eventID, 0) + if err != nil { + return nil, err + } + bonus, err := loadRecordBonusQ(ctx, s.ev) + if err != nil { + return nil, err + } + breaks, err := loadRecordBreaksQ(ctx, s.ev, eventID) + if err != nil { + return nil, err + } + + results := make([]domain.Result, 0, len(snapshot)) + for _, r := range snapshot { + results = append(results, resultDTOToDomain(r)) + } + + for _, cat := range categories { + score, err := domain.ScoreEvent(domain.EventScoreInput{ + Event: ev, + Category: cat, + Channels: channels, + Profiles: profiles, + Entries: entries, + Results: results, + RecordBonus: &bonus, + RecordBreaks: breaks, + }) + if err != nil { + return nil, mapDomainError(err) + } + for _, l := range score.Ledger { + out[l.TeamID] += l.Points + } + } + return out, nil +} + +func resultDTOToDomain(r resultDTO) domain.Result { + out := domain.Result{ + ID: r.ID, + EventID: r.EventID, + EntryID: r.EntryID, + ResultText: r.ResultText, + ResultValue: r.ResultValue, + Unit: r.Unit, + IsTie: r.IsTie, + PointsOverride: r.PointsOverride, + Status: domain.ResultStatus(r.Status), + State: domain.ResultState(r.State), + } + if r.Rank != nil { + out.Rank = *r.Rank + } + if r.Note != nil { + out.Note = *r.Note + } + return out +} + +func (s *server) entryTeams(ctx context.Context, ids []int64) (map[int64]int64, error) { + out := make(map[int64]int64) + if len(ids) == 0 { + return out, nil + } + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.ev.QueryContext(ctx, + `SELECT id, team_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var entryID, teamID int64 + if err := rows.Scan(&entryID, &teamID); err != nil { + return nil, internal(err) + } + out[entryID] = teamID + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + return out, nil +} + +// userNames 从系统库取用户显示名(result_revisions.actor_id 跨库,不能 JOIN)。 +func (s *server) userNames(ctx context.Context, ids []int64) (map[int64]string, error) { + out := make(map[int64]string, len(ids)) + if len(ids) == 0 { + return out, nil + } + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.sys.QueryContext(ctx, + `SELECT id, display_name FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id int64 + var name string + if err := rows.Scan(&id, &name); err != nil { + return nil, internal(err) + } + out[id] = name + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + return out, nil +} + +func placeholders(n int) string { + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} diff --git a/server/internal/httpapi/handlers_users.go b/server/internal/httpapi/handlers_users.go new file mode 100644 index 0000000..8191192 --- /dev/null +++ b/server/internal/httpapi/handlers_users.go @@ -0,0 +1,241 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// ---------- 用户管理(仅 admin,见 devdocs/08 第 2 节) ---------- + +func (s *server) listUsersHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.listUsers(r.Context()) +} + +func (s *server) createUserHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in createUserInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createUser(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "user.create", "user", created.ID, nil, created) + return created, nil +} + +func (s *server) updateUserHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in updateUserInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getUser(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateUser(r.Context(), id, in, middlewareUserID(r)) + if err != nil { + return nil, err + } + s.audit(r.Context(), "user.update", "user", id, before, updated) + return updated, nil +} + +func (s *server) resetUserPasswordHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in resetUserPasswordInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + result, err := s.resetUserPassword(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "user.reset_password", "user", id, nil, map[string]any{ + "username": result.Username, + "generated": result.Generated, + }) + return result, nil +} + +const userColumns = `id, username, display_name, role, active, must_change_password, last_login_at` + +func scanUser(row interface{ Scan(...any) error }) (userListItemDTO, error) { + var ( + u userListItemDTO + lastLogin sql.NullString + ) + if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.Active, &u.MustChangePassword, &lastLogin); err != nil { + return u, err + } + u.LastLoginAt = strPtr(lastLogin) + return u, nil +} + +func (s *server) listUsers(ctx context.Context) ([]userListItemDTO, error) { + rows, err := s.sys.QueryContext(ctx, `SELECT `+userColumns+` FROM users ORDER BY id`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + items := make([]userListItemDTO, 0) + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, internal(err) + } + items = append(items, u) + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + return items, nil +} + +func (s *server) getUser(ctx context.Context, id int64) (userListItemDTO, error) { + u, err := scanUser(s.sys.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return u, notFound("用户不存在") + } + if err != nil { + return u, internal(err) + } + return u, nil +} + +// userRole 只接受落库的两种角色;guest 是「未登录」,不能建也不能改出来。 +func userRole(raw string) (string, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case auth.RoleRecorder.String(): + return auth.RoleRecorder.String(), nil + case auth.RoleAdmin.String(): + return auth.RoleAdmin.String(), nil + default: + return "", badRequest("role 只能是 recorder 或 admin", raw) + } +} + +func (s *server) createUser(ctx context.Context, in createUserInput) (userListItemDTO, error) { + username := strings.TrimSpace(in.Username) + if username == "" { + return userListItemDTO{}, badRequest("username 必填", nil) + } + role, err := userRole(in.Role) + if err != nil { + return userListItemDTO{}, err + } + if len(in.Password) < 8 { + return userListItemDTO{}, badRequest("password 至少 8 位", nil) + } + displayName := strings.TrimSpace(in.DisplayName) + if displayName == "" { + displayName = username + } + + // 先占位插入再交给认证服务写哈希,避免在 httpapi 里复制 bcrypt 成本配置。 + res, err := s.sys.ExecContext(ctx, + `INSERT INTO users (username, password_hash, display_name, role, active, must_change_password) + VALUES (?, '!', ?, ?, 1, 1)`, username, displayName, role) + if isUniqueViolation(err) { + return userListItemDTO{}, conflict("用户名已存在") + } + if err != nil { + return userListItemDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return userListItemDTO{}, internal(err) + } + if err := s.auth.SetPassword(ctx, username, in.Password, true); err != nil { + // 占位行没有可用哈希,改密失败就删掉,避免留下登不进去的僵尸用户。 + _, _ = s.sys.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + return userListItemDTO{}, internal(err) + } + return s.getUser(ctx, id) +} + +func (s *server) updateUser(ctx context.Context, id int64, in updateUserInput, actorID int64) (userListItemDTO, error) { + before, err := s.getUser(ctx, id) + if err != nil { + return before, err + } + + role := before.Role + if in.Role != nil { + role, err = userRole(*in.Role) + if err != nil { + return before, err + } + } + displayName := before.DisplayName + if in.DisplayName != nil { + displayName = strings.TrimSpace(*in.DisplayName) + if displayName == "" { + return before, badRequest("displayName 不能为空", nil) + } + } + active := before.Active + if in.Active != nil { + active = *in.Active + } + + // 管理员把自己降级或停用会当场失去管理入口,无法再改回来,直接拒绝。 + if id == actorID && (role != auth.RoleAdmin.String() || !active) { + return before, badRequest("不能把自己降级或停用", nil) + } + + if _, err := s.sys.ExecContext(ctx, + `UPDATE users SET display_name = ?, role = ?, active = ?, updated_at = datetime('now') WHERE id = ?`, + displayName, role, active, id); err != nil { + return before, internal(err) + } + if !active { + // 停用必须连带踢掉已登录会话,否则旧 Cookie 还能继续写。 + if err := s.auth.RevokeUserSessions(ctx, id); err != nil { + return before, internal(err) + } + } + return s.getUser(ctx, id) +} + +func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPasswordInput) (resetPasswordResultDTO, error) { + u, err := s.getUser(ctx, id) + if err != nil { + return resetPasswordResultDTO{}, err + } + password := in.Password + generated := false + if password == "" { + password, err = auth.RandomPassword(16) + if err != nil { + return resetPasswordResultDTO{}, internal(err) + } + generated = true + } else if len(password) < 8 { + return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil) + } + // SetPassword 会作废该用户全部会话,被重置的人必须重新登录。 + if err := s.auth.SetPassword(ctx, u.Username, password, true); err != nil { + return resetPasswordResultDTO{}, internal(err) + } + return resetPasswordResultDTO{ + ID: u.ID, + Username: u.Username, + Password: password, + Generated: generated, + MustChangePassword: true, + }, nil +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index ee1ab3d..c1b4984 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -106,3 +106,27 @@ type categoriesPayload struct { type channelsPayload struct { Channels []channelDTO `json:"channels"` } + +// ---------- 用户管理 ---------- + +type createUserInput struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Password string `json:"password"` + RequestID string `json:"requestId"` +} + +// updateUserInput 只带需要改的字段:nil 表示保持原值。 +type updateUserInput struct { + DisplayName *string `json:"displayName"` + Role *string `json:"role"` + Active *bool `json:"active"` + RequestID string `json:"requestId"` +} + +// resetUserPasswordInput 的 password 留空时由服务端随机生成。 +type resetUserPasswordInput struct { + Password string `json:"password"` + RequestID string `json:"requestId"` +} diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index a4fe4cc..5983627 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -584,10 +584,16 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin if err != nil { return internal(err) } + // 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。 + var parentID sql.NullInt64 + if err := tx.QueryRowContext(ctx, + `SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil { + return internal(err) + } _, err = tx.ExecContext(ctx, - `INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message) - VALUES (?, ?, ?, '[]', ?, ?)`, - eventID, action, string(snapshot), nullID(actorID), message) + `INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message) + VALUES (?, ?, ?, ?, '[]', ?, ?)`, + parentID, eventID, action, string(snapshot), nullID(actorID), message) if err != nil { return internal(err) } diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 05cab07..17fa610 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -207,6 +207,16 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler)) + // 用户管理(系统配置,仅 admin;guest 读 403、写 401) + mux.HandleFunc("GET /api/users", s.read(auth.RoleAdmin, s.listUsersHandler)) + mux.HandleFunc("POST /api/users", s.write(auth.RoleAdmin, s.createUserHandler)) + mux.HandleFunc("PUT /api/users/{id}", s.write(auth.RoleAdmin, s.updateUserHandler)) + mux.HandleFunc("POST /api/users/{id}/password", s.write(auth.RoleAdmin, s.resetUserPasswordHandler)) + + // 变更历史(recorder 及以上可读) + mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler)) + mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler)) + // 由其他模块负责的接口:先占位,避免落到静态资源处理器。 mux.HandleFunc("/api/export/", s.notWired("导出")) mux.HandleFunc("/api/rules/", s.notWired("规则导入导出")) diff --git a/server/internal/httpapi/users_revisions_test.go b/server/internal/httpapi/users_revisions_test.go new file mode 100644 index 0000000..bf10b4a --- /dev/null +++ b/server/internal/httpapi/users_revisions_test.go @@ -0,0 +1,288 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// 用户管理是系统配置:guest 读 403、写 401,recorder 一律 403(见 devdocs/08 第 2 节)。 +func TestUsersAdminOnly(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + + guest, _ := env.client() + status, body := env.do(guest, http.MethodGet, "/api/users", nil, false) + if status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" { + t.Fatalf("guest 读用户列表应 403 FORBIDDEN,得到 %d %+v", status, body.Error) + } + status, body = env.do(guest, http.MethodPost, "/api/users", map[string]any{ + "username": "hacker", "role": "admin", "password": "password-1", + }, false) + if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("guest 建用户应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error) + } + if status, _ := env.do(guest, http.MethodPost, "/api/users/1/password", map[string]any{}, false); status != http.StatusUnauthorized { + t.Fatalf("guest 重置密码应 401,得到 %d", status) + } + + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, body := env.do(client, http.MethodGet, "/api/users", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" { + t.Fatalf("recorder 读用户列表应 403,得到 %d %+v", status, body.Error) + } + if status, _ := env.do(client, http.MethodPost, "/api/users", map[string]any{ + "username": "someone", "role": "recorder", "password": "password-1", + }, true); status != http.StatusForbidden { + t.Fatalf("recorder 建用户应 403,得到 %d", status) + } + if status, _ := env.do(client, http.MethodPut, "/api/users/1", map[string]any{"active": false}, true); status != http.StatusForbidden { + t.Fatalf("recorder 停用用户应 403,得到 %d", status) + } +} + +// admin 建用户、改资料、重置密码、停用;并拒绝把自己降级或停用。 +func TestAdminManagesUsers(t *testing.T) { + env := newTestEnv(t) + adminID := env.createUser("admin", "admin-pass", "admin") + admin, _ := env.client() + env.login(admin, "admin", "admin-pass") + + status, body := env.do(admin, http.MethodGet, "/api/users", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("admin 读用户列表应 200,得到 %d %+v", status, body.Error) + } + users := decodeData[[]map[string]any](t, body) + if len(users) != 1 || users[0]["username"] != "admin" || users[0]["role"] != "admin" { + t.Fatalf("用户列表内容不对: %+v", users) + } + if users[0]["lastLoginAt"] == nil { + t.Fatalf("登录后应回填 lastLoginAt: %+v", users[0]) + } + + status, body = env.do(admin, http.MethodPost, "/api/users", map[string]any{ + "username": "zhang", "displayName": "张三", "role": "recorder", "password": "init-pass-1", + }, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("建用户应 200,得到 %d %+v", status, body.Error) + } + created := decodeData[map[string]any](t, body) + newID := int64(created["id"].(float64)) + if created["username"] != "zhang" || created["displayName"] != "张三" || + created["role"] != "recorder" || created["active"] != true || created["mustChangePassword"] != true { + t.Fatalf("新建用户返回不对: %+v", created) + } + + if status, body := env.do(admin, http.MethodPost, "/api/users", map[string]any{ + "username": "zhang", "role": "recorder", "password": "init-pass-1", + }, true); status != http.StatusConflict || body.Error == nil || body.Error.Code != "CONFLICT" { + t.Fatalf("重名用户应 409 CONFLICT,得到 %d %+v", status, body.Error) + } + if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{ + "username": "weak", "role": "recorder", "password": "123", + }, true); status != http.StatusBadRequest { + t.Fatalf("弱密码应 400,得到 %d", status) + } + if status, _ := env.do(admin, http.MethodPost, "/api/users", map[string]any{ + "username": "guestish", "role": "guest", "password": "password-1", + }, true); status != http.StatusBadRequest { + t.Fatalf("role=guest 应 400,得到 %d", status) + } + + // 新用户拿到初始密码即可登录。 + sub, _ := env.client() + env.login(sub, "zhang", "init-pass-1") + + // 改资料:displayName + role。 + status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{ + "displayName": "张三丰", "role": "admin", + }, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("改用户应 200,得到 %d %+v", status, body.Error) + } + updated := decodeData[map[string]any](t, body) + if updated["displayName"] != "张三丰" || updated["role"] != "admin" || updated["active"] != true { + t.Fatalf("改用户返回不对: %+v", updated) + } + + // 管理员重置密码:留空随机生成,返回一次性明文。 + status, body = env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", newID), map[string]any{}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error) + } + reset := decodeData[map[string]any](t, body) + password, _ := reset["password"].(string) + if reset["generated"] != true || len(password) < 12 || reset["mustChangePassword"] != true { + t.Fatalf("随机密码返回不对: %+v", reset) + } + // 旧密码立即失效,新密码可用;被重置者的会话已被作废。 + if status, _ := env.do(sub, http.MethodPost, "/api/auth/login", map[string]any{ + "username": "zhang", "password": "init-pass-1", + }, false); status != http.StatusUnauthorized { + t.Fatalf("旧密码应失效(401),得到 %d", status) + } + sub2, _ := env.client() + env.login(sub2, "zhang", password) + + // 停用后不能登录。 + status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", newID), map[string]any{"active": false}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error) + } + if disabled := decodeData[map[string]any](t, body); disabled["active"] != false { + t.Fatalf("停用后 active 应为 false: %+v", disabled) + } + sub3, _ := env.client() + if status, _ := env.do(sub3, http.MethodPost, "/api/auth/login", map[string]any{ + "username": "zhang", "password": password, + }, false); status != http.StatusUnauthorized { + t.Fatalf("停用用户登录应 401,得到 %d", status) + } + + // 不能把自己降级或停用。 + status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"role": "recorder"}, true) + if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("自己降级应 400 VALIDATION_FAILED,得到 %d %+v", status, body.Error) + } + if status, _ := env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"active": false}, true); status != http.StatusBadRequest { + t.Fatalf("自己停用应 400,得到 %d", status) + } + // 只改自己的显示名仍然允许。 + status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", adminID), map[string]any{"displayName": "总管理员"}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("改自己显示名应 200,得到 %d %+v", status, body.Error) + } +} + +// 变更历史:recorder 可读列表与详情,guest 403;详情含快照与成绩/分数差异。 +func TestRevisionsListAndDetail(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") + + guest, _ := env.client() + if status, body := env.do(guest, http.MethodGet, "/api/revisions", nil, false); status != http.StatusForbidden || body.Error == nil || body.Error.Code != "FORBIDDEN" { + t.Fatalf("guest 读变更历史应 403,得到 %d %+v", status, body.Error) + } + if status, _ := env.do(guest, http.MethodGet, "/api/revisions/1", nil, false); status != http.StatusForbidden { + t.Fatalf("guest 读变更详情应 403,得到 %d", status) + } + + // 第一次发布:A 10 分、B 8 分。 + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true) + status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, body.Error) + } + + status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("读变更列表应 200,得到 %d %+v", status, body.Error) + } + page := decodeData[map[string]any](t, body) + items := page["items"].([]any) + if len(items) != 1 { + t.Fatalf("首次发布后应有 1 条变更记录,得到 %d", len(items)) + } + first := items[0].(map[string]any) + if first["action"] != "publish" || first["eventName"] != "100米跑" || first["actorName"] != "recorder" { + t.Fatalf("变更列表字段不对: %+v", first) + } + if first["changedCount"].(float64) != 2 { + t.Fatalf("首次发布应记 2 条成绩变化,得到 %v", first["changedCount"]) + } + if first["parentId"] != nil { + t.Fatalf("首条记录没有父节点: %+v", first) + } + firstID := int64(first["id"].(float64)) + + status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", firstID), nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("读变更详情应 200,得到 %d %+v", status, body.Error) + } + detail := decodeData[map[string]any](t, body) + if snapshot := detail["snapshot"].([]any); len(snapshot) != 2 { + t.Fatalf("快照应含 2 条成绩,得到 %d", len(snapshot)) + } + diff := detail["diff"].(map[string]any) + changes := diff["results"].([]any) + if len(changes) != 2 || changes[0].(map[string]any)["change"] != "added" { + t.Fatalf("首次发布的差异应为 2 条 added: %+v", changes) + } + teams := diff["teams"].([]any) + if len(teams) != 2 { + t.Fatalf("应涉及 2 个单位,得到 %d", len(teams)) + } + got := map[string]map[string]any{} + for _, raw := range teams { + m := raw.(map[string]any) + got[m["teamCode"].(string)] = m + } + if got["A"]["pointsAfter"].(float64) != 10 || got["A"]["pointsDelta"].(float64) != 10 { + t.Fatalf("A 单位分数不对: %+v", got["A"]) + } + if got["B"]["pointsAfter"].(float64) != 8 || got["B"]["pointsDelta"].(float64) != 8 { + t.Fatalf("B 单位分数不对: %+v", got["B"]) + } + + // 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。 + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true) + status, body = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ids.event}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("第二次发布应 200,得到 %d %+v", status, body.Error) + } + + status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=10", ids.event), nil, false) + items = decodeData[map[string]any](t, body)["items"].([]any) + if len(items) != 2 { + t.Fatalf("两次发布后应有 2 条变更记录,得到 %d", len(items)) + } + second := items[0].(map[string]any) + if second["changedCount"].(float64) != 2 { + t.Fatalf("第二次发布应记 2 条成绩变化,得到 %v", second["changedCount"]) + } + secondID := int64(second["id"].(float64)) + + status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions/%d", secondID), nil, false) + detail = decodeData[map[string]any](t, body) + diff = detail["diff"].(map[string]any) + changes = diff["results"].([]any) + if len(changes) != 2 || changes[0].(map[string]any)["change"] != "changed" { + t.Fatalf("第二次发布的差异应为 changed: %+v", changes) + } + if fields := changes[0].(map[string]any)["fields"].([]any); len(fields) == 0 { + t.Fatalf("changed 记录应给出变化字段: %+v", changes[0]) + } + teams = diff["teams"].([]any) + got = map[string]map[string]any{} + for _, raw := range teams { + m := raw.(map[string]any) + got[m["teamCode"].(string)] = m + } + if got["A"]["pointsDelta"].(float64) != -2 || got["B"]["pointsDelta"].(float64) != 2 { + t.Fatalf("第二次发布的分数增减不对: A=%+v B=%+v", got["A"], got["B"]) + } + + // 分页:limit/offset 生效。 + status, body = env.do(client, http.MethodGet, fmt.Sprintf("/api/revisions?eventId=%d&limit=1&offset=1", ids.event), nil, false) + page = decodeData[map[string]any](t, body) + if page["total"].(float64) != 2 || page["limit"].(float64) != 1 || len(page["items"].([]any)) != 1 { + t.Fatalf("分页参数未生效: %+v", page) + } +} diff --git a/web/src/admin/components/baseData/FormDialog.vue b/web/src/admin/components/baseData/FormDialog.vue new file mode 100644 index 0000000..6fe0e95 --- /dev/null +++ b/web/src/admin/components/baseData/FormDialog.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/web/src/admin/components/baseData/FormField.vue b/web/src/admin/components/baseData/FormField.vue new file mode 100644 index 0000000..5238453 --- /dev/null +++ b/web/src/admin/components/baseData/FormField.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/web/src/admin/components/baseData/SportsEventsSection.vue b/web/src/admin/components/baseData/SportsEventsSection.vue new file mode 100644 index 0000000..3b1c0f5 --- /dev/null +++ b/web/src/admin/components/baseData/SportsEventsSection.vue @@ -0,0 +1,736 @@ + + + + + diff --git a/web/src/admin/components/baseData/TeamsSection.vue b/web/src/admin/components/baseData/TeamsSection.vue new file mode 100644 index 0000000..bfc1026 --- /dev/null +++ b/web/src/admin/components/baseData/TeamsSection.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/web/src/admin/components/baseData/baseData.css b/web/src/admin/components/baseData/baseData.css new file mode 100644 index 0000000..63ffec3 --- /dev/null +++ b/web/src/admin/components/baseData/baseData.css @@ -0,0 +1,211 @@ +/* + * 基础数据页的共享布局类。 + * + * 七个分区组件各自 scoped 样式,但表格工具栏、两栏分栏、表单网格必须一致, + * 否则同一页里每块的间距都会不一样;这里统一,并在页面里全局引入一次。 + */ + +.bd-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.bd-toolbar-left, +.bd-toolbar-right { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.bd-search { + width: 240px; +} + +.bd-select { + width: 180px; +} + +.bd-split { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.bd-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.bd-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.bd-form-grid .span-2 { + grid-column: 1 / -1; +} + +.bd-note { + font-size: 12px; + line-height: 1.6; + color: var(--mx-t3); +} + +.bd-warn { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--mx-r-sm); + background: rgba(251, 191, 36, 0.08); + color: var(--mx-amber); + font-size: 12px; + line-height: 1.6; + margin-bottom: 12px; +} + +.bd-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +.bd-mono { + font-family: var(--mx-mono); + font-size: 12px; +} + +.bd-color { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--mx-divider); + vertical-align: middle; +} + +.bd-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 62dvh; + overflow-y: auto; + padding: 4px; +} + +.bd-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 9px 10px; + border-radius: var(--mx-r-sm); + background: transparent; + border: 1px solid transparent; + color: var(--mx-t2); + font-size: 13px; + text-align: left; + cursor: pointer; + transition: all var(--mx-d1) var(--mx-ease); +} + +.bd-list-item:hover { + background: var(--mx-s-hover); + color: var(--mx-text); +} + +.bd-list-item.active { + background: var(--mx-primary-glow); + border-color: var(--mx-primary); + color: var(--mx-primary); + font-weight: 600; +} + +.bd-list-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.bd-list-main small { + font-size: 11px; + color: var(--mx-t3); +} + +.bd-cell-narrow { + width: 88px; +} + +.bd-cell-mini { + width: 72px; +} + +.bd-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.bd-detail-strip { + display: flex; + flex-wrap: wrap; + gap: 10px 24px; + padding: 12px 14px; + border-radius: var(--mx-r-sm); + background: var(--mx-s3); + font-size: 12px; + color: var(--mx-t3); + margin-bottom: 14px; +} + +.bd-detail-strip strong { + color: var(--mx-text); + font-weight: 600; +} + +.bd-empty { + padding: 32px 0; +} + +.bd-check { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--mx-t2); + cursor: pointer; +} + +.bd-check input { + width: 16px; + height: 16px; + accent-color: var(--mx-primary); +} + +@media (max-width: 1279px) { + .bd-split { + grid-template-columns: 1fr; + } + .bd-list { + max-height: none; + } +} + +@media (max-width: 767px) { + .bd-form-grid { + grid-template-columns: 1fr; + } +} diff --git a/web/src/admin/components/baseData/baseDataApi.ts b/web/src/admin/components/baseData/baseDataApi.ts new file mode 100644 index 0000000..160ce6e --- /dev/null +++ b/web/src/admin/components/baseData/baseDataApi.ts @@ -0,0 +1,132 @@ +import { ref } from 'vue' +import { ApiError, adminApi, mockMode, mockReason } from '../../api/client' +import type { + Athlete, + AthleteInput, + EntryImportResult, + Event, + EventCategory, + EventCategoryInput, + EventInput, + ScoringProfile, + ScoringProfileInput, + ScoreChannel, + ScoreChannelInput, + Sport, + SportInput, + Team, + TeamInput, +} from '@/shared' +import { mockBaseData } from './mockBaseData' + +/** + * 基础数据接口入口。 + * + * 复用 adminApi.client 的 REST 方法,但补上与管理端一致的降级策略: + * 只有「服务端未就绪」(网络/超时/解析失败/404)才落回示例数据, + * 业务错误(鉴权、校验、冲突)原样抛出,避免把真实报错伪装成示例数据。 + * + * 之所以不直接用 adminApi 的封装:api/client.ts 不在本页允许修改的范围内, + * 且这里需要区分「基础数据接口没实现」与「整个后端没起来」两种降级。 + */ + +const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED'] + +/** + * 本页接口的降级标记。 + * + * 不能只看全局 mockMode:服务端可能整体正常、只有基础数据接口尚未实现, + * 此时 adminApi.bootstrap 会把 mockMode 重置为 false,页面却仍在用示例数据。 + */ +export const baseDataFallback = ref(false) + +function isFallback(error: unknown): boolean { + // 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理。 + return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404) +} + +async function call(real: () => Promise, mock: () => T | Promise): Promise { + try { + const value = await real() + baseDataFallback.value = false + if (mockMode.value) { + mockMode.value = false + mockReason.value = null + } + return value + } catch (error) { + if (!isFallback(error)) throw error + baseDataFallback.value = true + mockMode.value = true + mockReason.value = error instanceof Error ? error.message : '无法连接服务端' + return await mock() + } +} + +export const baseDataApi = { + teams: { + list: (): Promise => call(() => adminApi.client.teams.list(), () => mockBaseData.listTeams()), + create: (input: TeamInput): Promise => + call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)), + update: (id: number, input: TeamInput): Promise => + call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)), + }, + + sports: { + list: (): Promise => call(() => adminApi.client.sports.list(), () => mockBaseData.listSports()), + create: (input: SportInput): Promise => + call(() => adminApi.client.sports.create(input), () => mockBaseData.createSport(input)), + update: (id: number, input: SportInput): Promise => + call(() => adminApi.client.sports.update(id, input), () => mockBaseData.updateSport(id, input)), + remove: (id: number): Promise => + call(() => adminApi.client.sports.remove(id), () => mockBaseData.removeSport(id)), + }, + + events: { + list: (): Promise => call(() => adminApi.client.events.list(), () => mockBaseData.listEvents()), + get: (id: number): Promise => call(() => adminApi.client.events.get(id), () => mockBaseData.getEvent(id)), + create: (input: EventInput): Promise => + call(() => adminApi.client.events.create(input), () => mockBaseData.createEvent(input)), + update: (id: number, input: EventInput): Promise => + call(() => adminApi.client.events.update(id, input), () => mockBaseData.updateEvent(id, input)), + remove: (id: number): Promise => + call(() => adminApi.client.events.remove(id), () => mockBaseData.removeEvent(id)), + saveCategories: (eventId: number, inputs: EventCategoryInput[]): Promise => + call( + () => adminApi.client.events.saveCategories(eventId, inputs), + () => mockBaseData.saveCategories(eventId, inputs), + ), + saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise => + call( + () => adminApi.client.events.saveChannels(eventId, inputs), + () => mockBaseData.saveChannels(eventId, inputs), + ), + }, + + profiles: { + list: (): Promise => + call(() => adminApi.client.scoringProfiles.list(), () => mockBaseData.listProfiles()), + create: (input: ScoringProfileInput): Promise => + call(() => adminApi.client.scoringProfiles.create(input), () => mockBaseData.createProfile(input)), + update: (id: number, input: ScoringProfileInput): Promise => + call(() => adminApi.client.scoringProfiles.update(id, input), () => mockBaseData.updateProfile(id, input)), + }, + + athletes: { + list: (): Promise => call(() => adminApi.client.athletes.list(), () => mockBaseData.listAthletes()), + create: (input: AthleteInput): Promise => + call(() => adminApi.client.athletes.create(input), () => mockBaseData.createAthlete(input)), + update: (id: number, input: AthleteInput): Promise => + call(() => adminApi.client.athletes.update(id, input), () => mockBaseData.updateAthlete(id, input)), + remove: (id: number): Promise => + call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)), + }, + + entries: { + importFile: (file: File): Promise => + call( + () => adminApi.client.entries.importFile(file), + () => mockBaseData.importEntries(file), + ), + }, +} diff --git a/web/src/admin/components/baseData/mockBaseData.ts b/web/src/admin/components/baseData/mockBaseData.ts new file mode 100644 index 0000000..5d66a5c --- /dev/null +++ b/web/src/admin/components/baseData/mockBaseData.ts @@ -0,0 +1,529 @@ +import type { + Athlete, + AthleteInput, + EntryImportResult, + Event, + EventCategory, + EventCategoryInput, + EventInput, + ScoringProfile, + ScoringProfileInput, + ScoreChannel, + ScoreChannelInput, + Sport, + SportInput, + Team, + TeamInput, +} from '@/shared' + +/** + * 基础数据页的离线示例数据源。 + * + * 后端未就绪(连接失败 / 接口未实现)时页面降级到这里,保证每个分区都能走通 + * 「列表 → 新增 → 编辑 → 停用/删除 → 整体保存」的完整交互。数据只在内存里, + * 刷新即重置;界面必须显式标注「示例数据」。 + */ + +const TEAM_COLORS = ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C'] + +const SEED_TEAMS: Team[] = [ + ['JG', '机关本部', '机关'], + ['F1', '第一分公司', '一分公司'], + ['F2', '第二分公司', '二分公司'], + ['F3', '第三分公司', '三分公司'], + ['F4', '第四分公司', '四分公司'], + ['F5', '第五分公司', '五分公司'], + ['F6', '第六分公司', '六分公司'], +].map(([code, name, short], index) => ({ + id: index + 1, + code: code ?? '', + name: name ?? '', + shortName: short ?? null, + logoPath: null, + color: TEAM_COLORS[index] ?? null, + sortOrder: index + 1, + active: true, +})) + +const SEED_SPORTS: Sport[] = [ + { id: 1, code: 'TJ', name: '田径', shortName: '田径', defaultProfileId: 1, sortOrder: 1, active: true }, + { id: 2, code: 'QL', name: '球类', shortName: '球类', defaultProfileId: 2, sortOrder: 2, active: true }, + { id: 3, code: 'QW', name: '趣味', shortName: '趣味', defaultProfileId: 2, sortOrder: 3, active: true }, +] + +const SEED_PROFILES: ScoringProfile[] = [ + { + id: 1, + code: 'individual', + name: '个人项目前六名', + tieMode: 'skip', + recordBonusMode: 'max', + awardRanks: 6, + lastPlaceZero: false, + rules: [ + { rank: 1, points: 10 }, + { rank: 2, points: 8 }, + { rank: 3, points: 6 }, + { rank: 4, points: 3 }, + { rank: 5, points: 2 }, + { rank: 6, points: 1 }, + ], + }, + { + id: 2, + code: 'team', + name: '团体项目前六名', + tieMode: 'skip', + recordBonusMode: 'max', + awardRanks: 6, + lastPlaceZero: false, + rules: [ + { rank: 1, points: 15 }, + { rank: 2, points: 10 }, + { rank: 3, points: 8 }, + { rank: 4, points: 4 }, + { rank: 5, points: 2 }, + { rank: 6, points: 1 }, + ], + }, +] + +interface EventSeed { + event: Omit + category: string + scope: ScoreChannel['scope'] + basis: ScoreChannel['basis'] + profileId: number + /** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */ + entryCount: number +} + +const SEED_EVENTS: EventSeed[] = [ + { + event: { + sportId: 1, code: 'M100', name: '男子 100 米', eventType: 'individual', resultMode: 'auto', + resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 1, remark: null, + }, + category: '男子组', scope: 'athlete', basis: 'result', profileId: 1, + entryCount: 7, + }, + { + event: { + sportId: 1, code: 'W100', name: '女子 100 米', eventType: 'individual', resultMode: 'auto', + resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 2, remark: null, + }, + category: '女子组', scope: 'athlete', basis: 'result', profileId: 1, + entryCount: 0, + }, + { + event: { + sportId: 1, code: 'M800', name: '男子 800 米', eventType: 'individual', resultMode: 'auto', + resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 3, remark: null, + }, + category: '男子组', scope: 'athlete', basis: 'result', profileId: 1, + entryCount: 5, + }, + { + event: { + sportId: 1, code: 'LJ', name: '跳远', eventType: 'individual', resultMode: 'auto', + resultType: 'distance', compare: 'desc', recordEligible: true, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 4, remark: null, + }, + category: '男子组', scope: 'athlete', basis: 'result', profileId: 1, + entryCount: 0, + }, + { + event: { + sportId: 3, code: 'PU', name: '引体向上', eventType: 'individual', resultMode: 'auto', + resultType: 'count', compare: 'desc', recordEligible: false, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 5, remark: null, + }, + category: '男子组', scope: 'athlete', basis: 'result', profileId: 1, + entryCount: 0, + }, + { + event: { + sportId: 2, code: 'BB', name: '篮球(团体)', eventType: 'team', resultMode: 'manual', + resultType: 'score', compare: 'none', recordEligible: false, scored: true, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 6, remark: null, + }, + category: '混合组', scope: 'team', basis: 'manual', profileId: 2, + entryCount: 3, + }, + { + event: { + sportId: 3, code: 'FUN', name: '趣味接力(不计分)', eventType: 'team', resultMode: 'manual', + resultType: 'none', compare: 'none', recordEligible: false, scored: false, multiplier: 1, + status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 7, remark: null, + }, + category: '混合组', scope: 'team', basis: 'manual', profileId: 2, + entryCount: 0, + }, +] + +const ATHLETE_NAMES = [ + '张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛', + '吴敏', '郑浩', '冯雪', '褚健', '卫东', '蒋楠', '沈莉', '韩旭', + '杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓', +] + +/** 报名表模板列,与服务端 importColumns 保持一致(大小写不敏感)。 */ +const IMPORT_COLUMNS = [ + 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names', +] + +const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试' + +class MockBaseData { + private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team })) + private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport })) + private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({ + ...profile, + rules: profile.rules.map((rule) => ({ ...rule })), + })) + private events: Event[] = [] + private categories: EventCategory[] = [] + private channels: ScoreChannel[] = [] + private athletes: Athlete[] = [] + + private nextTeamId = 100 + private nextSportId = 100 + private nextEventId = 1000 + private nextCategoryId = 1000 + private nextChannelId = 1000 + private nextProfileId = 100 + private nextAthleteId = 1000 + /** 每个小项的示例报名条数,用于复刻服务端「有报名就不能删项目 / 替换组别」的约束。 */ + private readonly entryCounts = new Map() + + constructor() { + SEED_EVENTS.forEach((seed, index) => { + const id = 101 + index + this.events.push({ ...seed.event, id }) + this.entryCounts.set(id, seed.entryCount) + this.categories.push({ + id: (this.nextCategoryId += 1), + eventId: id, + name: seed.category, + multiplier: 1, + gender: seed.category.startsWith('男子') ? '男' : seed.category.startsWith('女子') ? '女' : null, + ageMin: null, + ageMax: null, + sortOrder: 1, + }) + this.channels.push({ + id: (this.nextChannelId += 1), + eventId: id, + scope: seed.scope, + basis: seed.basis, + profileId: seed.profileId, + sortOrder: 1, + }) + }) + + this.teams.forEach((team, teamIndex) => { + ATHLETE_NAMES.slice(teamIndex * 3, teamIndex * 3 + 3).forEach((name, nameIndex) => { + this.athletes.push({ + id: (this.nextAthleteId += 1), + name, + gender: nameIndex % 2 === 0 ? '男' : '女', + teamId: team.id, + employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`, + active: true, + }) + }) + }) + } + + // ── 参赛单位 ────────────────────────────────────────────────────────────── + + listTeams(): Team[] { + return this.teams.map((team) => ({ ...team })) + } + + createTeam(input: TeamInput): Team { + if (this.teams.some((team) => team.code === input.code)) throw new Error(`单位代码 ${input.code} 已存在`) + const team: Team = { ...input, id: (this.nextTeamId += 1) } + this.teams = [...this.teams, team] + return { ...team } + } + + updateTeam(id: number, input: TeamInput): Team { + const index = this.teams.findIndex((team) => team.id === id) + if (index < 0) throw new Error(`单位 ${id} 不存在`) + if (this.teams.some((team) => team.id !== id && team.code === input.code)) { + throw new Error(`单位代码 ${input.code} 已存在`) + } + const team: Team = { ...input, id } + this.teams[index] = team + return { ...team } + } + + // ── 大项 ────────────────────────────────────────────────────────────────── + + listSports(): Sport[] { + return this.sports.map((sport) => ({ ...sport })) + } + + createSport(input: SportInput): Sport { + if (this.sports.some((sport) => sport.code === input.code)) throw new Error(`大项代码 ${input.code} 已存在`) + const sport: Sport = { ...input, id: (this.nextSportId += 1) } + this.sports = [...this.sports, sport] + return { ...sport } + } + + updateSport(id: number, input: SportInput): Sport { + const index = this.sports.findIndex((sport) => sport.id === id) + if (index < 0) throw new Error(`大项 ${id} 不存在`) + const sport: Sport = { ...input, id } + this.sports[index] = sport + return { ...sport } + } + + removeSport(id: number): void { + const index = this.sports.findIndex((sport) => sport.id === id) + if (index < 0) throw new Error(`大项 ${id} 不存在`) + if (this.events.some((event) => event.sportId === id)) throw new Error('该大项下还有小项,请先删除小项') + this.sports.splice(index, 1) + } + + // ── 小项 ────────────────────────────────────────────────────────────────── + + listEvents(): Event[] { + return this.events.map((event) => ({ ...event })) + } + + getEvent(id: number): Event { + const event = this.events.find((item) => item.id === id) + if (event === undefined) throw new Error(`小项 ${id} 不存在`) + return { + ...event, + categories: this.categories.filter((item) => item.eventId === id).map((item) => ({ ...item })), + channels: this.channels.filter((item) => item.eventId === id).map((item) => ({ ...item })), + } + } + + createEvent(input: EventInput): Event { + if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`) + const id = (this.nextEventId += 1) + const event: Event = { ...input, id } + this.events = [...this.events, event] + this.entryCounts.set(id, 0) + return { ...event } + } + + updateEvent(id: number, input: EventInput): Event { + const index = this.events.findIndex((event) => event.id === id) + if (index < 0) throw new Error(`小项 ${id} 不存在`) + if (this.events.some((event) => event.id !== id && event.code === input.code)) { + throw new Error(`小项代码 ${input.code} 已存在`) + } + const event: Event = { ...input, id } + this.events[index] = event + return { ...event } + } + + removeEvent(id: number): void { + const index = this.events.findIndex((event) => event.id === id) + if (index < 0) throw new Error(`小项 ${id} 不存在`) + if ((this.entryCounts.get(id) ?? 0) > 0) throw new Error('小项已有报名数据,不能删除') + this.events.splice(index, 1) + this.entryCounts.delete(id) + this.categories = this.categories.filter((item) => item.eventId !== id) + this.channels = this.channels.filter((item) => item.eventId !== id) + } + + saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] { + this.getEvent(eventId) + if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别') + const names = new Set() + for (const input of inputs) { + const name = input.name.trim() + if (name === '') throw new Error('组别名称必填') + if (names.has(name)) throw new Error(`组别名称重复:${name}`) + names.add(name) + } + this.categories = this.categories.filter((item) => item.eventId !== eventId) + for (const input of inputs) { + this.categories.push({ ...input, id: (this.nextCategoryId += 1), eventId }) + } + return this.getEvent(eventId).categories ?? [] + } + + saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] { + this.getEvent(eventId) + const combinations = new Set() + for (const input of inputs) { + const key = `${input.scope}:${input.basis}` + if (combinations.has(key)) throw new Error(`同一 scope+basis 只能配置一个通道:${key}`) + combinations.add(key) + } + this.channels = this.channels.filter((item) => item.eventId !== eventId) + for (const input of inputs) { + this.channels.push({ ...input, id: (this.nextChannelId += 1), eventId }) + } + return this.getEvent(eventId).channels ?? [] + } + + // ── 积分模板 ────────────────────────────────────────────────────────────── + + listProfiles(): ScoringProfile[] { + return this.profiles.map((profile) => ({ ...profile, rules: profile.rules.map((rule) => ({ ...rule })) })) + } + + createProfile(input: ScoringProfileInput): ScoringProfile { + if (this.profiles.some((profile) => profile.code === input.code)) throw new Error(`模板代码 ${input.code} 已存在`) + const profile: ScoringProfile = { + ...input, + id: (this.nextProfileId += 1), + rules: input.rules.map((rule) => ({ ...rule })), + } + this.profiles = [...this.profiles, profile] + return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) } + } + + updateProfile(id: number, input: ScoringProfileInput): ScoringProfile { + const index = this.profiles.findIndex((profile) => profile.id === id) + if (index < 0) throw new Error(`积分模板 ${id} 不存在`) + const profile: ScoringProfile = { + ...input, + id, + rules: input.rules.map((rule) => ({ ...rule })), + } + this.profiles[index] = profile + return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) } + } + + // ── 运动员 ──────────────────────────────────────────────────────────────── + + listAthletes(): Athlete[] { + return this.athletes.map((athlete) => ({ ...athlete })) + } + + createAthlete(input: AthleteInput): Athlete { + const athlete: Athlete = { ...input, id: (this.nextAthleteId += 1) } + this.athletes = [...this.athletes, athlete] + return { ...athlete } + } + + updateAthlete(id: number, input: AthleteInput): Athlete { + const index = this.athletes.findIndex((athlete) => athlete.id === id) + if (index < 0) throw new Error(`运动员 ${id} 不存在`) + const athlete: Athlete = { ...input, id } + this.athletes[index] = athlete + return { ...athlete } + } + + removeAthlete(id: number): void { + const index = this.athletes.findIndex((athlete) => athlete.id === id) + if (index < 0) throw new Error(`运动员 ${id} 不存在`) + this.athletes.splice(index, 1) + } + + // ── 报名导入 ────────────────────────────────────────────────────────────── + + async importEntries(file: File): Promise { + const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase() + if (extension !== '.csv') { + return { created: 0, updated: 0, skipped: 0, errors: [XLSX_HINT] } + } + const rows = parseCsv(await file.text()) + if (rows.length < 2) throw new Error('报名表为空') + const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase()) + const index = new Map(header.map((name, position) => [name, position])) + for (const column of IMPORT_COLUMNS) { + if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`) + } + + const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [] } + rows.slice(1).forEach((row, offset) => { + const line = offset + 2 + if (row.every((cell) => cell.trim() === '')) return + const cell = (name: string): string => { + const position = index.get(name) + return position === undefined ? '' : (row[position] ?? '').trim() + } + const eventCode = cell('event_code') + const teamCode = cell('team_code') + const categoryName = cell('category') + if (eventCode === '' || teamCode === '') { + result.skipped += 1 + result.errors.push(`第 ${line} 行:event_code / team_code 必填`) + return + } + const event = this.events.find((item) => item.code === eventCode) + if (event === undefined) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:小项 ${eventCode} 不存在`) + return + } + const team = this.teams.find((item) => item.code === teamCode) + if (team === undefined) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:单位 ${teamCode} 不存在`) + return + } + const categories = this.categories.filter((item) => item.eventId === event.id) + if (categoryName !== '' && !categories.some((item) => item.name === categoryName)) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:组别 ${categoryName} 不存在`) + return + } + if (categoryName === '' && categories.length === 0) { + result.skipped += 1 + result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`) + return + } + result.created += 1 + }) + return result + } +} + +/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */ +function parseCsv(text: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let cell = '' + let quoted = false + + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (quoted) { + if (char === '"') { + if (text[index + 1] === '"') { + cell += '"' + index += 1 + } else { + quoted = false + } + } else { + cell += char + } + continue + } + if (char === '"') { + quoted = true + } else if (char === ',') { + row.push(cell) + cell = '' + } else if (char === '\n') { + row.push(cell) + rows.push(row) + row = [] + cell = '' + } else if (char !== '\r') { + cell += char + } + } + if (cell !== '' || row.length > 0) { + row.push(cell) + rows.push(row) + } + return rows +} + +export const mockBaseData = new MockBaseData() diff --git a/web/src/admin/components/baseData/tabs.ts b/web/src/admin/components/baseData/tabs.ts new file mode 100644 index 0000000..b7ad178 --- /dev/null +++ b/web/src/admin/components/baseData/tabs.ts @@ -0,0 +1,29 @@ +/** + * 基础数据页的分区定义。 + * + * 单独成文件是为了让各分区组件能 emit 一个类型安全的跳转目标, + * 不必反向 import 页面组件(那会形成页面 ↔ 分区的循环依赖)。 + */ +export type BaseDataTabKey = + | 'teams' + | 'events' + | 'categories' + | 'channels' + | 'profiles' + | 'athletes' + | 'import' + +export interface BaseDataTab { + key: BaseDataTabKey + label: string +} + +export const BASE_DATA_TABS: BaseDataTab[] = [ + { key: 'teams', label: '参赛单位' }, + { key: 'events', label: '大项 / 小项' }, + { key: 'categories', label: '组别' }, + { key: 'channels', label: '计分通道' }, + { key: 'profiles', label: '积分模板' }, + { key: 'athletes', label: '运动员' }, + { key: 'import', label: '报名导入' }, +] diff --git a/web/src/admin/components/baseData/useBaseData.ts b/web/src/admin/components/baseData/useBaseData.ts new file mode 100644 index 0000000..0f5f83b --- /dev/null +++ b/web/src/admin/components/baseData/useBaseData.ts @@ -0,0 +1,312 @@ +import { computed, ref } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import type { + Athlete, + AthleteInput, + EntryImportResult, + Event, + EventCategory, + EventCategoryInput, + EventInput, + ScoringProfile, + ScoringProfileInput, + ScoreChannel, + ScoreChannelInput, + Sport, + SportInput, + Team, + TeamInput, +} from '@/shared' +import { mockMode, mockReason } from '../../api/client' +import { useAuthStore } from '../../stores/auth' +import { useEventStore } from '../../stores/event' +import { baseDataApi, baseDataFallback } from './baseDataApi' + +/** + * 基础数据页的共享状态。 + * + * 七个分区(单位 / 大项 / 小项 / 组别 / 通道 / 模板 / 运动员 / 导入)读写同一份列表, + * 且「组别」「通道」依赖「当前选中的小项」,所以状态提到模块级单例, + * 避免逐层透传 props 把分区组件焊死在页面上。 + */ + +const teams = ref([]) +const sports = ref([]) +const events = ref([]) +const profiles = ref([]) +const athletes = ref([]) + +const loading = ref(false) +const loaded = ref(false) +const error = ref(null) +const selectedEventId = ref(null) +/** 小项详情(含组别 / 通道)按需拉取并缓存,保存后原地更新。 */ +const details = ref>({}) + +function sortTeams(list: Team[]): Team[] { + return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)) +} + +function sortSports(list: Sport[]): Sport[] { + return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)) +} + +function sortEvents(list: Event[]): Event[] { + return [...list].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)) +} + +function sortAthletes(list: Athlete[]): Athlete[] { + return [...list].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN')) +} + +async function load(force = false): Promise { + if (loaded.value && !force) return + loading.value = true + error.value = null + try { + const [nextTeams, nextSports, nextEvents, nextProfiles, nextAthletes] = await Promise.all([ + baseDataApi.teams.list(), + baseDataApi.sports.list(), + baseDataApi.events.list(), + baseDataApi.profiles.list(), + baseDataApi.athletes.list(), + ]) + teams.value = sortTeams(nextTeams) + sports.value = sortSports(nextSports) + events.value = sortEvents(nextEvents) + profiles.value = nextProfiles + athletes.value = sortAthletes(nextAthletes) + loaded.value = true + if (selectedEventId.value === null || !events.value.some((event) => event.id === selectedEventId.value)) { + selectedEventId.value = events.value[0]?.id ?? null + } + } catch (caught) { + error.value = caught instanceof Error ? caught.message : '基础数据加载失败' + } finally { + loading.value = false + } +} + +async function reloadTeams(): Promise { + teams.value = sortTeams(await baseDataApi.teams.list()) +} + +async function reloadSports(): Promise { + sports.value = sortSports(await baseDataApi.sports.list()) +} + +async function reloadEvents(): Promise { + events.value = sortEvents(await baseDataApi.events.list()) + // 项目列表是共享 store 的一部分,改动后同步过去,成绩录入页不必等整页刷新。 + void useEventStore().refreshEvents() +} + +async function reloadProfiles(): Promise { + profiles.value = await baseDataApi.profiles.list() +} + +async function reloadAthletes(): Promise { + athletes.value = sortAthletes(await baseDataApi.athletes.list()) +} + +async function loadEventDetail(id: number, force = false): Promise { + if (!force && details.value[id] !== undefined) return details.value[id] ?? null + try { + const detail = await baseDataApi.events.get(id) + details.value = { ...details.value, [id]: detail } + return detail + } catch (caught) { + error.value = caught instanceof Error ? caught.message : '小项详情加载失败' + return null + } +} + +async function selectEvent(id: number): Promise { + selectedEventId.value = id + await loadEventDetail(id) +} + +function eventById(id: number | null): Event | undefined { + if (id === null) return undefined + return details.value[id] ?? events.value.find((event) => event.id === id) +} + +function categoriesOf(id: number | null): EventCategory[] { + if (id === null) return [] + const detail = details.value[id] + if (detail?.categories === undefined) return [] + return [...detail.categories].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) +} + +function channelsOf(id: number | null): ScoreChannel[] { + if (id === null) return [] + const detail = details.value[id] + if (detail?.channels === undefined) return [] + return [...detail.channels].sort((a, b) => a.sortOrder - b.sortOrder) +} + +function profileById(id: number | null): ScoringProfile | undefined { + if (id === null) return undefined + return profiles.value.find((profile) => profile.id === id) +} + +function teamById(id: number | null): Team | undefined { + if (id === null) return undefined + return teams.value.find((team) => team.id === id) +} + +function sportById(id: number | null): Sport | undefined { + if (id === null) return undefined + return sports.value.find((sport) => sport.id === id) +} + +async function saveTeam(input: TeamInput, id: number | null): Promise { + const saved = id === null ? await baseDataApi.teams.create(input) : await baseDataApi.teams.update(id, input) + await reloadTeams() + return saved +} + +async function saveSport(input: SportInput, id: number | null): Promise { + const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input) + await reloadSports() + return saved +} + +async function removeSport(id: number): Promise { + await baseDataApi.sports.remove(id) + await reloadSports() +} + +async function saveEvent(input: EventInput, id: number | null): Promise { + const saved = id === null ? await baseDataApi.events.create(input) : await baseDataApi.events.update(id, input) + await reloadEvents() + if (id === null) { + selectedEventId.value = saved.id + await loadEventDetail(saved.id, true) + } else { + // 详情缓存里可能有旧值,改完必须重拉,否则列表与组别/通道页显示不一致。 + await loadEventDetail(id, true) + } + return saved +} + +async function removeEvent(id: number): Promise { + await baseDataApi.events.remove(id) + const nextDetails = { ...details.value } + delete nextDetails[id] + details.value = nextDetails + await reloadEvents() + if (selectedEventId.value === id) selectedEventId.value = events.value[0]?.id ?? null +} + +async function saveCategories(eventId: number, inputs: EventCategoryInput[]): Promise { + const saved = await baseDataApi.events.saveCategories(eventId, inputs) + const detail = details.value[eventId] + if (detail !== undefined) details.value = { ...details.value, [eventId]: { ...detail, categories: saved } } +} + +async function saveChannels(eventId: number, inputs: ScoreChannelInput[]): Promise { + const saved = await baseDataApi.events.saveChannels(eventId, inputs) + const detail = details.value[eventId] + if (detail !== undefined) details.value = { ...details.value, [eventId]: { ...detail, channels: saved } } +} + +async function saveProfile(input: ScoringProfileInput, id: number | null): Promise { + const saved = + id === null + ? await baseDataApi.profiles.create(input) + : await baseDataApi.profiles.update(id, input) + await reloadProfiles() + return saved +} + +async function saveAthlete(input: AthleteInput, id: number | null): Promise { + const saved = id === null ? await baseDataApi.athletes.create(input) : await baseDataApi.athletes.update(id, input) + await reloadAthletes() + return saved +} + +async function removeAthlete(id: number): Promise { + await baseDataApi.athletes.remove(id) + await reloadAthletes() +} + +function importEntries(file: File): Promise { + return baseDataApi.entries.importFile(file) +} + +export interface BaseDataController { + teams: Ref + sports: Ref + events: Ref + profiles: Ref + athletes: Ref + loading: Ref + error: Ref + selectedEventId: Ref + isMock: ComputedRef + mockLabel: ComputedRef + canWrite: ComputedRef + load: (force?: boolean) => Promise + loadEventDetail: (id: number, force?: boolean) => Promise + selectEvent: (id: number) => Promise + eventById: (id: number | null) => Event | undefined + categoriesOf: (id: number | null) => EventCategory[] + channelsOf: (id: number | null) => ScoreChannel[] + profileById: (id: number | null) => ScoringProfile | undefined + teamById: (id: number | null) => Team | undefined + sportById: (id: number | null) => Sport | undefined + saveTeam: (input: TeamInput, id: number | null) => Promise + saveSport: (input: SportInput, id: number | null) => Promise + removeSport: (id: number) => Promise + saveEvent: (input: EventInput, id: number | null) => Promise + removeEvent: (id: number) => Promise + saveCategories: (eventId: number, inputs: EventCategoryInput[]) => Promise + saveChannels: (eventId: number, inputs: ScoreChannelInput[]) => Promise + saveProfile: (input: ScoringProfileInput, id: number | null) => Promise + saveAthlete: (input: AthleteInput, id: number | null) => Promise + removeAthlete: (id: number) => Promise + importEntries: (file: File) => Promise +} + +export function useBaseData(): BaseDataController { + const auth = useAuthStore() + const shared = useEventStore() + + return { + teams, + sports, + events, + profiles, + athletes, + loading, + error, + selectedEventId, + isMock: computed(() => baseDataFallback.value || mockMode.value), + mockLabel: computed(() => + baseDataFallback.value || mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null, + ), + // 示例数据模式允许本地写入,否则整页只能看不能改,交互无从验证。 + canWrite: computed(() => !auth.readOnly && (baseDataFallback.value || shared.canWrite)), + load, + loadEventDetail, + selectEvent, + eventById, + categoriesOf, + channelsOf, + profileById, + teamById, + sportById, + saveTeam, + saveSport, + removeSport, + saveEvent, + removeEvent, + saveCategories, + saveChannels, + saveProfile, + saveAthlete, + removeAthlete, + importEntries, + } +} diff --git a/web/src/admin/components/history/RevisionGraph.vue b/web/src/admin/components/history/RevisionGraph.vue new file mode 100644 index 0000000..d2cfe48 --- /dev/null +++ b/web/src/admin/components/history/RevisionGraph.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/admin/components/history/graph.ts b/web/src/admin/components/history/graph.ts new file mode 100644 index 0000000..0192057 --- /dev/null +++ b/web/src/admin/components/history/graph.ts @@ -0,0 +1,188 @@ +import type { DiffKind, Revision, RevisionSnapshotRow } from './types' + +/** + * 仿 Git Graph 的布局计算(纯函数,便于单独推理)。 + * + * 每个小项一条主线:父节点是当前主线末端的修订沿主线前进, + * 否则(父节点已被别的修订占用,或没有父节点)另开一条泳道——这就是「撤销后重新发布分出新分支」。 + */ + +export const LANE_WIDTH = 16 +export const ROW_HEIGHT = 64 +export const PAD_X = 10 +export const CENTER_Y = ROW_HEIGHT / 2 + +export const LANE_COLORS = ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C'] + +export interface GraphSegment { + lane: number + /** 竖线的起止 y(行内坐标)。 */ + from: number + to: number +} + +export interface GraphCurve { + fromLane: number + toLane: number +} + +export interface GraphRow { + revision: Revision + lane: number + parentLane: number | null + isBranch: boolean + segments: GraphSegment[] + curves: GraphCurve[] +} + +export interface GraphLayout { + rows: GraphRow[] + laneCount: number + width: number +} + +function compare(a: Revision, b: Revision): number { + if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1 + return a.id - b.id +} + +export function buildGraph(revisions: readonly Revision[]): GraphLayout { + const chronological = [...revisions].sort(compare) + const laneOf = new Map() + const tips: Array = [] + + for (const revision of chronological) { + let lane: number | null = null + if (revision.parentId !== null) { + const parentLane = laneOf.get(revision.parentId) + if (parentLane !== undefined && tips[parentLane] === revision.parentId) lane = parentLane + } + if (lane === null) { + const free = tips.indexOf(null) + if (free >= 0) { + lane = free + } else { + tips.push(null) + lane = tips.length - 1 + } + } + tips[lane] = revision.id + laneOf.set(revision.id, lane) + } + + // 展示顺序:最新在上,父节点在下方。 + const ordered = [...chronological].reverse() + const indexOf = new Map() + ordered.forEach((revision, index) => indexOf.set(revision.id, index)) + + const laneCount = ordered.reduce((max, revision) => Math.max(max, (laneOf.get(revision.id) ?? 0) + 1), 1) + const segments: GraphSegment[][] = ordered.map(() => []) + const curves: GraphCurve[][] = ordered.map(() => []) + + for (let childIndex = 0; childIndex < ordered.length; childIndex += 1) { + const revision = ordered[childIndex] + if (revision === undefined || revision.parentId === null) continue + const parentIndex = indexOf.get(revision.parentId) + if (parentIndex === undefined) continue + const childLane = laneOf.get(revision.id) ?? 0 + const parentLane = laneOf.get(revision.parentId) ?? childLane + + for (let row = childIndex; row <= parentIndex; row += 1) { + const from = row === childIndex ? CENTER_Y : 0 + const to = row === parentIndex ? CENTER_Y : ROW_HEIGHT + if (from === to) continue + const bucket = segments[row] + if (bucket !== undefined) bucket.push({ lane: childLane, from, to }) + } + if (childLane !== parentLane) { + const bucket = curves[parentIndex] + if (bucket !== undefined) bucket.push({ fromLane: childLane, toLane: parentLane }) + } + } + + const rows: GraphRow[] = ordered.map((revision, index) => { + const lane = laneOf.get(revision.id) ?? 0 + const parentLane = revision.parentId === null ? null : (laneOf.get(revision.parentId) ?? null) + return { + revision, + lane, + parentLane, + isBranch: parentLane !== null && parentLane !== lane, + segments: segments[index] ?? [], + curves: curves[index] ?? [], + } + }) + + return { rows, laneCount, width: laneCount * LANE_WIDTH + PAD_X * 2 } +} + +export function laneColor(lane: number): string { + return LANE_COLORS[lane % LANE_COLORS.length] ?? LANE_COLORS[0] ?? '#3482FF' +} + +export function laneX(lane: number): number { + return PAD_X + lane * LANE_WIDTH + LANE_WIDTH / 2 +} + +/** 两泳道之间的换道曲线,画在父节点那一行。 */ +export function curvePath(fromLane: number, toLane: number): string { + const ax = laneX(fromLane) + const bx = laneX(toLane) + const c1 = ax + (bx - ax) * 0.4 + const c2 = ax + (bx - ax) * 0.6 + return `M ${ax} ${CENTER_Y} C ${c1} ${CENTER_Y} ${c2} ${CENTER_Y} ${bx} ${CENTER_Y}` +} + +export interface DiffEntry { + entryId: number + kind: DiffKind + before: RevisionSnapshotRow | null + after: RevisionSnapshotRow | null +} + +function sameRow(a: RevisionSnapshotRow, b: RevisionSnapshotRow): boolean { + return ( + a.resultText === b.resultText && + a.rank === b.rank && + a.status === b.status && + a.state === b.state + ) +} + +/** 当前快照 vs 父快照;父为空时全部视为新增(首次发布)。 */ +export function diffSnapshots( + current: readonly RevisionSnapshotRow[], + parent: readonly RevisionSnapshotRow[] | null, +): DiffEntry[] { + const before = new Map(parent?.map((row) => [row.entryId, row]) ?? []) + const after = new Map(current.map((row) => [row.entryId, row])) + const ids = new Set([...before.keys(), ...after.keys()]) + const entries: DiffEntry[] = [] + + for (const entryId of ids) { + const previous = before.get(entryId) ?? null + const next = after.get(entryId) ?? null + if (previous === null && next === null) continue + if (previous === null) { + entries.push({ entryId, kind: 'added', before: null, after: next }) + continue + } + if (next === null) { + entries.push({ entryId, kind: 'removed', before: previous, after: null }) + continue + } + if (!sameRow(previous, next)) entries.push({ entryId, kind: 'changed', before: previous, after: next }) + } + + return entries.sort((a, b) => a.entryId - b.entryId) +} + +export function describeRow(row: RevisionSnapshotRow | null): string { + if (row === null) return '—' + const parts: string[] = [] + if (row.resultText !== '') parts.push(row.resultText) + if (row.rank !== null) parts.push(`第 ${row.rank} 名`) + if (row.status !== null && row.status !== 'ok') parts.push(row.status.toUpperCase()) + if (row.state === 'draft') parts.push('草稿') + return parts.length === 0 ? '—' : parts.join(' · ') +} diff --git a/web/src/admin/components/history/mockRevisions.ts b/web/src/admin/components/history/mockRevisions.ts new file mode 100644 index 0000000..6682949 --- /dev/null +++ b/web/src/admin/components/history/mockRevisions.ts @@ -0,0 +1,178 @@ +import type { Revision, RevisionSnapshotRow } from './types' + +/** + * `/api/revisions` 未就绪时的降级数据。 + * + * 只用于把「时间轴 + 分支 + diff」走通,界面必须显式标注「示例数据」; + * entryId 按示例数据的约定生成(eventId * 1000 + teamId * 10 + 1),单位名由全局数据补齐。 + */ + +function iso(hoursAgo: number): string { + return new Date(Date.now() - hoursAgo * 3_600_000).toISOString() +} + +function row( + eventId: number, + teamId: number, + rank: number | null, + resultText: string, + resultValue: number | null, + points: number | null, + state: string, +): RevisionSnapshotRow { + return { + entryId: eventId * 1000 + teamId * 10 + 1, + teamId, + rank, + resultText, + resultValue, + points, + status: 'ok', + state, + } +} + +const TIMES_101 = ['11.58', '11.92', '12.01', '12.20', '12.35', '12.48', '12.60'] +const POINTS_101 = [10, 8, 6, 3, 2, 1, 0] + +function snapshot101(state: string, order: readonly number[]): RevisionSnapshotRow[] { + // order 是「名次顺序」的单位 id;用于演示编辑后名次与积分的变化。 + return order.map((teamId, index) => + row(101, teamId, index + 1, TIMES_101[index] ?? '—', Number(TIMES_101[index] ?? 0), POINTS_101[index] ?? 0, state), + ) +} + +export function createMockRevisions(): Revision[] { + return [ + { + id: 9, + parentId: null, + eventId: 106, + action: 'publish', + snapshot: [ + row(106, 3, 1, '第一名', null, 15, 'published'), + row(106, 1, 2, '第二名', null, 10, 'published'), + row(106, 5, 3, '第三名', null, 8, 'published'), + ], + ledgerIds: [901, 902, 903], + actorId: 2, + actorName: '张裁判', + message: '篮球团体名次确认', + createdAt: iso(8), + }, + { + id: 8, + parentId: 7, + eventId: 103, + action: 'publish', + snapshot: [ + row(103, 2, 1, '2:13.90', 133.9, 10, 'published'), + row(103, 1, 2, '2:16.05', 136.05, 8, 'published'), + row(103, 4, 3, '2:18.77', 138.77, 6, 'published'), + row(103, 3, 4, '2:20.10', 140.1, 3, 'published'), + row(103, 5, 5, '2:22.44', 142.44, 2, 'published'), + ], + ledgerIds: [801, 802, 803, 804, 805], + actorId: 2, + actorName: '张裁判', + message: '更正第二名成绩后重新发布', + createdAt: iso(12), + }, + { + id: 7, + parentId: 6, + eventId: 103, + action: 'edit', + snapshot: [ + row(103, 2, 1, '2:13.90', 133.9, null, 'draft'), + row(103, 1, 2, '2:16.05', 136.05, null, 'draft'), + row(103, 4, 3, '2:18.77', 138.77, null, 'draft'), + row(103, 3, 4, '2:20.10', 140.1, null, 'draft'), + row(103, 5, 5, '2:22.44', 142.44, null, 'draft'), + ], + ledgerIds: [], + actorId: 2, + actorName: '张裁判', + message: '第 2 名成绩由 2:14.30 更正为 2:13.90', + createdAt: iso(14), + }, + { + id: 6, + parentId: null, + eventId: 103, + action: 'publish', + snapshot: [ + row(103, 2, 1, '2:14.30', 134.3, 10, 'published'), + row(103, 1, 2, '2:16.05', 136.05, 8, 'published'), + row(103, 4, 3, '2:18.77', 138.77, 6, 'published'), + row(103, 3, 4, '2:20.10', 140.1, 3, 'published'), + row(103, 5, 5, '2:22.44', 142.44, 2, 'published'), + ], + ledgerIds: [601, 602, 603, 604, 605], + actorId: 1, + actorName: '李管理员', + message: null, + createdAt: iso(20), + }, + { + id: 5, + parentId: 4, + eventId: 101, + action: 'publish', + snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]), + ledgerIds: [501, 502, 503, 504, 505, 506, 507], + actorId: 2, + actorName: '张裁判', + message: '修正后重新发布', + createdAt: iso(22), + }, + { + id: 4, + parentId: 2, + eventId: 101, + action: 'edit', + snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]), + ledgerIds: [], + actorId: 2, + actorName: '张裁判', + message: '第三、四名成绩录入对调,等待重新发布', + createdAt: iso(24), + }, + { + id: 3, + parentId: 2, + eventId: 101, + action: 'publish', + snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]), + ledgerIds: [301, 302, 303, 304, 305, 306, 307], + actorId: 1, + actorName: '李管理员', + message: null, + createdAt: iso(26), + }, + { + id: 2, + parentId: 1, + eventId: 101, + action: 'unpublish', + snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]), + ledgerIds: [], + actorId: 1, + actorName: '李管理员', + message: '成绩有误,撤销发布', + createdAt: iso(28), + }, + { + id: 1, + parentId: null, + eventId: 101, + action: 'publish', + snapshot: snapshot101('published', [1, 2, 3, 4, 5, 6, 7]), + ledgerIds: [101, 102, 103, 104, 105, 106, 107], + actorId: 1, + actorName: '李管理员', + message: null, + createdAt: iso(30), + }, + ] +} diff --git a/web/src/admin/components/history/revisionsSource.ts b/web/src/admin/components/history/revisionsSource.ts new file mode 100644 index 0000000..c9be77c --- /dev/null +++ b/web/src/admin/components/history/revisionsSource.ts @@ -0,0 +1,35 @@ +import { parseRevisions, type Revision } from './types' + +/** + * `GET /api/revisions`(05 §7 的变更历史接口,由服务端另一处实现)。 + * + * 直接 fetch 而不是走 adminApi:client.ts 的接口表里暂时没有这个方法, + * 而 api/** 不在本页的改动范围内;解析失败 / 404 时由页面降级到示例数据。 + */ + +export interface RevisionQuery { + eventId?: number + from?: string + to?: string +} + +const REVISIONS_PATH = '/api/revisions' + +function buildUrl(query: RevisionQuery): string { + const params = new URLSearchParams() + if (query.eventId !== undefined) params.set('eventId', String(query.eventId)) + if (query.from !== undefined) params.set('from', query.from) + if (query.to !== undefined) params.set('to', query.to) + const search = params.toString() + return search === '' ? REVISIONS_PATH : `${REVISIONS_PATH}?${search}` +} + +export async function fetchRevisions(query: RevisionQuery = {}): Promise { + const response = await fetch(buildUrl(query), { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (!response.ok) throw new Error(`变更历史接口返回 HTTP ${response.status}`) + const body: unknown = await response.json() + return parseRevisions(body) +} diff --git a/web/src/admin/components/history/types.ts b/web/src/admin/components/history/types.ts new file mode 100644 index 0000000..3ea1e86 --- /dev/null +++ b/web/src/admin/components/history/types.ts @@ -0,0 +1,167 @@ +/** + * 变更历史(result_revisions,见 04-数据模型)的前端类型与容错解析。 + * + * 该接口由服务端另一处实现,字段以 04-数据模型为准;这里把响应当 unknown 解析, + * 缺字段就降级而不是抛异常——历史页只读,展示不出来比整页崩掉好。 + */ + +export type RevisionAction = 'publish' | 'unpublish' | 'edit' + +/** 快照里的一行成绩,对应服务端 resultDTO 的子集。 */ +export interface RevisionSnapshotRow { + entryId: number + teamId: number | null + rank: number | null + resultText: string + resultValue: number | null + points: number | null + status: string | null + state: string | null +} + +export interface Revision { + id: number + parentId: number | null + eventId: number + action: RevisionAction + snapshot: RevisionSnapshotRow[] + ledgerIds: number[] + actorId: number | null + /** 服务端可能联表带出操作人姓名;没有就显示 id。 */ + actorName: string | null + message: string | null + createdAt: string +} + +export const ACTION_LABELS: Record = { + publish: '发布', + unpublish: '撤销发布', + edit: '编辑', +} + +export type DiffKind = 'added' | 'removed' | 'changed' + +/** diff 的展示行:单位 / 运动员名由页面查全局数据补齐。 */ +export interface RevisionDiffRow { + entryId: number + kind: DiffKind + teamLabel: string + athleteLabel: string + beforeText: string + afterText: string + beforePoints: number | null + afterPoints: number | null + pointsDelta: number +} + +export interface TeamImpact { + teamId: number + label: string + delta: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readNumber(source: Record, key: string): number | null { + const value = source[key] + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' ? value : null +} + +function readAction(value: unknown): RevisionAction { + return value === 'unpublish' || value === 'edit' ? value : 'publish' +} + +/** 快照可能是 JSON 文本、数组,或包在 results / rows 里,逐一收窄。 */ +function parseSnapshot(value: unknown): RevisionSnapshotRow[] { + let source: unknown = value + if (typeof source === 'string') { + try { + source = JSON.parse(source) + } catch { + return [] + } + } + if (isRecord(source)) { + source = source.results ?? source.rows ?? source.items ?? [] + } + if (!Array.isArray(source)) return [] + const rows: RevisionSnapshotRow[] = [] + for (const item of source) { + if (!isRecord(item)) continue + const entryId = readNumber(item, 'entryId') + if (entryId === null) continue + rows.push({ + entryId, + teamId: readNumber(item, 'teamId'), + rank: readNumber(item, 'rank'), + resultText: readString(item, 'resultText') ?? '', + resultValue: readNumber(item, 'resultValue'), + points: readNumber(item, 'points'), + status: readString(item, 'status'), + state: readString(item, 'state'), + }) + } + return rows +} + +function parseLedgerIds(value: unknown): number[] { + let source: unknown = value + if (typeof source === 'string') { + try { + source = JSON.parse(source) + } catch { + return [] + } + } + if (!Array.isArray(source)) return [] + return source + .map((item) => (typeof item === 'number' && Number.isFinite(item) ? item : null)) + .filter((item): item is number => item !== null) +} + +function parseRevision(value: unknown): Revision | null { + if (!isRecord(value)) return null + const id = readNumber(value, 'id') + const eventId = readNumber(value, 'eventId') + if (id === null || eventId === null) return null + return { + id, + parentId: readNumber(value, 'parentId'), + eventId, + action: readAction(value['action']), + snapshot: parseSnapshot(value['snapshotJson'] ?? value['snapshot'] ?? value['snapshot_json']), + ledgerIds: parseLedgerIds(value['ledgerIds'] ?? value['ledger_ids']), + actorId: readNumber(value, 'actorId'), + actorName: readString(value, 'actorName') ?? readString(value, 'actor'), + message: readString(value, 'message'), + createdAt: readString(value, 'createdAt') ?? readString(value, 'created_at') ?? '', + } +} + +function extractList(body: unknown): unknown[] { + if (Array.isArray(body)) return body + if (!isRecord(body)) return [] + if (Array.isArray(body['items'])) return body['items'] + if (Array.isArray(body['revisions'])) return body['revisions'] + // 兼容未拆包的 { ok, data } 统一响应。 + if ('data' in body) return extractList(body['data']) + return [] +} + +export function parseRevisions(body: unknown): Revision[] { + return extractList(body) + .map(parseRevision) + .filter((item): item is Revision => item !== null) +} diff --git a/web/src/admin/components/system/BackupPanel.vue b/web/src/admin/components/system/BackupPanel.vue new file mode 100644 index 0000000..ec80534 --- /dev/null +++ b/web/src/admin/components/system/BackupPanel.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/web/src/admin/components/system/EventInfoPanel.vue b/web/src/admin/components/system/EventInfoPanel.vue new file mode 100644 index 0000000..5a1a511 --- /dev/null +++ b/web/src/admin/components/system/EventInfoPanel.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/web/src/admin/components/system/JsonDiff.vue b/web/src/admin/components/system/JsonDiff.vue new file mode 100644 index 0000000..9701287 --- /dev/null +++ b/web/src/admin/components/system/JsonDiff.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/web/src/admin/components/system/MockBadge.vue b/web/src/admin/components/system/MockBadge.vue new file mode 100644 index 0000000..acac049 --- /dev/null +++ b/web/src/admin/components/system/MockBadge.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/web/src/admin/components/system/UsersPanel.vue b/web/src/admin/components/system/UsersPanel.vue new file mode 100644 index 0000000..49261f8 --- /dev/null +++ b/web/src/admin/components/system/UsersPanel.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts new file mode 100644 index 0000000..366bfc9 --- /dev/null +++ b/web/src/admin/components/system/systemApi.ts @@ -0,0 +1,804 @@ +import { ApiError, type ApiErrorCode, type EventSettings } from '@/shared' +import { mockMode, mockReason } from '../../api/client' + +/** + * 系统管理页的补充接口。 + * + * `api/client.ts` 只封装了 ApiClient 已有的端点,用户管理 / 备份列表 / 赛事项目包 / + * 规则包这几组接口还没进共享客户端(且本任务不允许改 api 层),所以在组件目录里补一层薄封装。 + * + * 降级策略与 client.ts 完全一致:只有「连不上 / 超时 / 响应不是约定结构 / 404 未实现」 + * 才降级示例数据;403、校验失败这类业务回答必须原样抛出,否则会把「没权限」伪装成「示例数据」。 + */ + +const BASE_URL = '/api' +const DEFAULT_TIMEOUT_MS = 15_000 +/** 上传整包可能上百 MB,超时必须放宽,否则现场弱网必然失败。 */ +const UPLOAD_TIMEOUT_MS = 120_000 + +const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED'] + +const API_ERROR_CODES: readonly string[] = [ + 'AUTH_REQUIRED', + 'AUTH_FAILED', + 'FORBIDDEN', + 'VALIDATION_FAILED', + 'CONFLICT', + 'STATE_INVALID', + 'RATE_LIMITED', + 'INTERNAL', + 'NETWORK', + 'TIMEOUT', + 'PARSE_FAILED', +] + +function isApiErrorCode(value: unknown): value is ApiErrorCode { + return typeof value === 'string' && API_ERROR_CODES.includes(value) +} + +export function isBackendUnavailable(error: unknown): boolean { + return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404) +} + +export interface MockAware { + data: T + /** 本次调用是否真的降级到了示例数据,供各面板显示局部标记。 */ + mock: boolean +} + +function markMock(reason: string): void { + mockMode.value = true + mockReason.value = reason +} + +/** 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。 */ +export async function withMockInfo( + real: () => Promise, + mock: () => T | Promise, +): Promise> { + try { + const data = await real() + if (mockMode.value) { + mockMode.value = false + mockReason.value = null + } + return { data, mock: false } + } catch (error) { + if (!isBackendUnavailable(error)) throw error + markMock(error instanceof Error ? error.message : '无法连接服务端') + return { data: await mock(), mock: true } + } +} + +export async function withMock(real: () => Promise, mock: () => T | Promise): Promise { + return (await withMockInfo(real, mock)).data +} + +// --------------------------------------------------------------------------- +// 底层 HTTP(补齐 ApiClient 未覆盖的路径) +// --------------------------------------------------------------------------- + +type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' +type QueryValue = string | number | boolean | undefined + +interface RawOptions { + method?: Method + query?: Record + json?: unknown + body?: BodyInit + contentType?: string + timeoutMs?: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value !== '' ? value : null +} + +function readNumber(source: Record, key: string): number | null { + const value = source[key] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +function readCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) + const raw = match?.[1] + return raw === undefined ? null : decodeURIComponent(raw) +} + +function newRequestId(): string { + const cryptoApi: Crypto | undefined = globalThis.crypto + if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +function buildUrl(path: string, query?: Record): string { + const base = path.startsWith('/') ? `${BASE_URL}${path}` : `${BASE_URL}/${path}` + if (query === undefined) return base + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) params.append(key, String(value)) + } + const search = params.toString() + return search === '' ? base : `${base}?${search}` +} + +function toApiError(body: unknown, status: number): ApiError { + if (isRecord(body) && isRecord(body.error)) { + return new ApiError( + isApiErrorCode(body.error.code) ? body.error.code : 'INTERNAL', + readString(body.error, 'message') ?? `请求失败(HTTP ${status})`, + status, + body.error.detail, + ) + } + return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body) +} + +async function send(path: string, options: RawOptions = {}): Promise { + const method = options.method ?? 'GET' + const headers: Record = { Accept: 'application/json' } + if (options.json !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' + else if (options.contentType !== undefined) headers['Content-Type'] = options.contentType + + if (method !== 'GET') { + const token = readCsrfToken() + if (token !== null && token !== '') headers['X-CSRF-Token'] = token + headers['X-Request-Id'] = newRequestId() + } + + const controller = new AbortController() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const timer = window.setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(buildUrl(path, options.query), { + method, + headers, + credentials: 'include', + body: options.json !== undefined ? JSON.stringify(options.json) : options.body, + signal: controller.signal, + }) + } catch (error) { + if (controller.signal.aborted) { + throw new ApiError('TIMEOUT', `请求超时(${timeoutMs}ms)`, 0, error) + } + throw new ApiError('NETWORK', '无法连接服务端', 0, error) + } finally { + clearTimeout(timer) + } +} + +async function readBody(response: Response): Promise { + if (response.status === 204) return null + const text = await response.text() + if (text.trim() === '') return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function unwrap(body: unknown, status: number): T { + if (!isRecord(body) || typeof body.ok !== 'boolean') { + throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', status, body) + } + if (body.ok) return body.data as T + throw toApiError(body, status) +} + +async function requestJson(path: string, options: RawOptions = {}): Promise { + const response = await send(path, options) + const body = await readBody(response) + if (!response.ok) throw toApiError(body, response.status) + return unwrap(body, response.status) +} + +async function requestVoid(path: string, options: RawOptions = {}): Promise { + const response = await send(path, options) + const body = await readBody(response) + if (!response.ok) throw toApiError(body, response.status) + if (body === null || body === undefined) return + unwrap(body, response.status) +} + +async function requestBlob(path: string, options: RawOptions = {}): Promise { + const response = await send(path, options) + if (!response.ok) throw toApiError(await readBody(response), response.status) + return response.blob() +} + +async function requestText(path: string, options: RawOptions = {}): Promise { + const response = await send(path, options) + const text = await response.text() + if (!response.ok) { + let body: unknown = text + try { + body = JSON.parse(text) + } catch { + // 非 JSON 的错误体原样透传,报错里带上原文才定位得到问题。 + } + throw toApiError(body, response.status) + } + return text +} + +// --------------------------------------------------------------------------- +// 用户管理(devdocs/08 第 2 节角色:guest / recorder / admin) +// --------------------------------------------------------------------------- + +export interface AdminUser { + id: number + username: string + displayName: string + role: string + active: boolean + mustChangePassword: boolean + lastLoginAt: string | null +} + +export interface AdminUserInput { + username: string + displayName: string + role: string + active: boolean + password: string +} + +export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'admin', label: '管理员' }, + { value: 'recorder', label: '录入员' }, + { value: 'guest', label: '只读访客' }, +] + +function toAdminUser(value: unknown): AdminUser | null { + if (!isRecord(value)) return null + const id = readNumber(value, 'id') + const username = readString(value, 'username') + if (id === null || username === null) return null + return { + id, + username, + displayName: readString(value, 'displayName') ?? username, + role: readString(value, 'role') ?? 'recorder', + // 后端未返回 active 时按「启用」处理,避免老接口把所有人显示成停用。 + active: value.active !== false, + mustChangePassword: value.mustChangePassword === true, + lastLoginAt: readString(value, 'lastLoginAt'), + } +} + +function randomPassword(): string { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789' + const bytes = new Uint8Array(12) + crypto.getRandomValues(bytes) + let out = '' + for (const byte of bytes) out += alphabet[byte % alphabet.length] + return out +} + +let mockUserSeq = 3 +const mockUserRows: AdminUser[] = [ + { + id: 1, + username: 'admin', + displayName: '裁判长', + role: 'admin', + active: true, + mustChangePassword: false, + lastLoginAt: new Date().toISOString(), + }, + { + id: 2, + username: 'recorder01', + displayName: '录入员一', + role: 'recorder', + active: true, + mustChangePassword: false, + lastLoginAt: null, + }, + { + id: 3, + username: 'lookup', + displayName: '只读账号', + role: 'guest', + active: false, + mustChangePassword: true, + lastLoginAt: null, + }, +] + +function mockUsers() { + return { + list: (): AdminUser[] => mockUserRows.map((row) => ({ ...row })), + create: (input: AdminUserInput): AdminUser => { + const created: AdminUser = { + id: (mockUserSeq += 1), + username: input.username, + displayName: input.displayName === '' ? input.username : input.displayName, + role: input.role, + active: input.active, + mustChangePassword: input.password !== '', + lastLoginAt: null, + } + mockUserRows.push(created) + return { ...created } + }, + update: (id: number, patch: Partial): AdminUser => { + const index = mockUserRows.findIndex((row) => row.id === id) + if (index < 0) throw new ApiError('VALIDATION_FAILED', '用户不存在', 0) + const current = mockUserRows[index] as AdminUser + const next: AdminUser = { + ...current, + displayName: patch.displayName ?? current.displayName, + role: patch.role ?? current.role, + active: patch.active ?? current.active, + } + mockUserRows[index] = next + return { ...next } + }, + resetPassword: (id: number): { password: string } => { + const index = mockUserRows.findIndex((row) => row.id === id) + if (index < 0) throw new ApiError('VALIDATION_FAILED', '用户不存在', 0) + const current = mockUserRows[index] as AdminUser + mockUserRows[index] = { ...current, mustChangePassword: true } + return { password: randomPassword() } + }, + } +} + +export const usersApi = { + async list(): Promise> { + return withMockInfo( + async () => { + const body = await requestJson('/users') + const raw = Array.isArray(body) + ? body + : isRecord(body) && Array.isArray(body.items) + ? body.items + : null + if (raw === null) throw new ApiError('PARSE_FAILED', '用户列表响应格式不符', 0, body) + return raw.map(toAdminUser).filter((row): row is AdminUser => row !== null) + }, + () => mockUsers().list(), + ) + }, + + async create(input: AdminUserInput): Promise> { + return withMockInfo( + async () => { + const body = await requestJson('/users', { method: 'POST', json: input }) + const created = toAdminUser(body) + if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body) + return created + }, + () => mockUsers().create(input), + ) + }, + + async update(id: number, patch: Partial): Promise> { + return withMockInfo( + async () => { + const body = await requestJson(`/users/${id}`, { method: 'PUT', json: patch }) + const updated = toAdminUser(body) + if (updated === null) throw new ApiError('PARSE_FAILED', '更新用户响应格式不符', 0, body) + return updated + }, + () => mockUsers().update(id, patch), + ) + }, + + async resetPassword(id: number): Promise> { + return withMockInfo( + async () => { + const body = await requestJson(`/users/${id}/password`, { method: 'POST', json: {} }) + const password = isRecord(body) ? readString(body, 'password') : null + if (password === null) throw new ApiError('PARSE_FAILED', '重置密码响应缺少 password', 0, body) + return { password } + }, + () => mockUsers().resetPassword(id), + ) + }, +} + +// --------------------------------------------------------------------------- +// 备份(devdocs/08 第 6 节) +// --------------------------------------------------------------------------- + +export interface BackupItem { + name: string + path: string + size: number + reason: string + createdAt: string +} + +function toBackupItem(value: unknown): BackupItem | null { + if (!isRecord(value)) return null + const name = readString(value, 'name') + const createdAt = readString(value, 'createdAt') + if (name === null || createdAt === null) return null + return { + name, + path: readString(value, 'path') ?? name, + size: readNumber(value, 'size') ?? 0, + reason: readString(value, 'reason') ?? '', + createdAt, + } +} + +function mockBackupList(): BackupItem[] { + const now = Date.now() + return [0, 1, 2].map((offset) => ({ + name: `event-示例-${offset + 1}.db`, + path: `data/backups/event-示例-${offset + 1}.db`, + size: 1_482_752 - offset * 2048, + reason: offset === 0 ? 'manual' : 'auto', + createdAt: new Date(now - offset * 5 * 60_000).toISOString(), + })) +} + +export const backupsApi = { + async list(): Promise> { + return withMockInfo( + async () => { + const body = await requestJson('/export/backups') + if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body) + return body.map(toBackupItem).filter((row): row is BackupItem => row !== null) + }, + () => mockBackupList(), + ) + }, + + async create(): Promise> { + return withMockInfo( + async () => { + const body = await requestJson('/backup', { method: 'POST', json: {} }) + const created = toBackupItem(body) + if (created === null) throw new ApiError('PARSE_FAILED', '备份响应格式不符', 0, body) + return created + }, + () => { + const createdAt = new Date().toISOString() + return { + name: `event-${createdAt.replace(/[:.]/g, '-')}.db`, + path: `data/backups/event-${createdAt.replace(/[:.]/g, '-')}.db`, + size: 1_486_848, + reason: 'manual', + createdAt, + } + }, + ) + }, +} + +// --------------------------------------------------------------------------- +// 结果导出(xlsx) +// --------------------------------------------------------------------------- + +export type ResultExportKind = 'standings' | 'all' | 'event' | 'team' + +export interface ResultExportRequest { + kind: ResultExportKind + eventId?: number + teamId?: number +} + +export interface DownloadFile { + blob: Blob + filename: string +} + +const RESULT_EXPORT_FILENAME: Record = { + standings: '总积分榜.xlsx', + all: '赛事成绩总表.xlsx', + event: '分项成绩表.xlsx', + team: '单位积分明细.xlsx', +} + +function mockDownload(request: ResultExportRequest, context: string): DownloadFile { + const lines = [ + '示例数据 · 后端未就绪,这里不会生成真正的 xlsx', + '', + `导出项:${context}`, + request.eventId === undefined ? '' : `小项 ID:${request.eventId}`, + request.teamId === undefined ? '' : `单位 ID:${request.teamId}`, + '', + '服务端接口恢复后,本文件将替换为按预设模板生成的 Excel 工作簿。', + ] + return { + blob: new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/plain;charset=utf-8' }), + filename: RESULT_EXPORT_FILENAME[request.kind].replace(/\.xlsx$/, '(示例数据).txt'), + } +} + +export const resultsExportApi = { + async download(request: ResultExportRequest): Promise> { + return withMockInfo( + async () => { + let path = '/export/standings.xlsx' + if (request.kind === 'all') path = '/export/all.xlsx' + if (request.kind === 'event') { + if (request.eventId === undefined) { + throw new ApiError('VALIDATION_FAILED', '请先选择要导出的小项', 0) + } + path = `/export/events/${request.eventId}` + } + if (request.kind === 'team') { + if (request.teamId === undefined) { + throw new ApiError('VALIDATION_FAILED', '请先选择要导出的单位', 0) + } + path = `/export/teams/${request.teamId}` + } + const blob = await requestBlob(path, { timeoutMs: 60_000 }) + return { blob, filename: RESULT_EXPORT_FILENAME[request.kind] } + }, + () => mockDownload(request, RESULT_EXPORT_FILENAME[request.kind]), + ) + }, +} + +// --------------------------------------------------------------------------- +// 赛事项目包(zip) +// --------------------------------------------------------------------------- + +export interface ProjectImportResult { + name: string + dir: string + eventDB: string + meta: unknown +} + +export interface ProjectImportOptions { + overwrite: boolean + rename: string +} + +export const projectApi = { + async exportZip(): Promise> { + return withMockInfo( + async () => ({ + blob: await requestBlob('/export/project.zip', { timeoutMs: 120_000 }), + filename: '赛事项目包.zip', + }), + () => ({ + blob: new Blob( + [ + '\uFEFF示例数据 · 后端未就绪,这里不会生成真正的 zip。\n\n' + + '真实包内含 meta.yaml + event.db + uploads/ + rules.yaml,整包拷走即可在另一台机器复现。', + ], + { type: 'text/plain;charset=utf-8' }, + ), + filename: '赛事项目包(示例数据).txt', + }), + ) + }, + + async importZip(file: File, options: ProjectImportOptions): Promise> { + return withMockInfo( + async () => { + const form = new FormData() + form.append('file', file) + form.append('overwrite', options.overwrite ? '1' : '0') + const rename = options.rename.trim() + if (rename !== '') form.append('rename', rename) + const body = await requestJson('/export/project/import', { + method: 'POST', + body: form, + timeoutMs: UPLOAD_TIMEOUT_MS, + }) + if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '导入响应格式不符', 0, body) + return { + name: readString(body, 'name') ?? (rename === '' ? file.name : rename), + dir: readString(body, 'dir') ?? '', + eventDB: readString(body, 'eventDB') ?? '', + meta: body.meta, + } + }, + () => ({ + name: options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim(), + dir: `data/event_projs/${options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim()}`, + eventDB: 'event.db', + meta: { note: '示例数据:未真正解压落盘' }, + }), + ) + }, +} + +// --------------------------------------------------------------------------- +// 规则包(YAML) +// --------------------------------------------------------------------------- + +export interface RuleDiffRow { + kind: string + target: string + change: string + detail: string +} + +export interface RulePreview { + compatible: boolean + affectedEventCount: number + rows: RuleDiffRow[] + unknownEvents: string[] +} + +export interface RuleApplyResult extends RulePreview { + recalculatedLedgerRows: number +} + +const MOCK_RULE_YAML = `# 示例数据 · 后端未就绪时导出的占位规则包 +format: scoreboard-rules +formatVersion: 1 +meta: + code: demo + name: 示例规则包 + version: 1.0.0 + updatedAt: "2026-09-09" +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] + medalCountsTeamEvents: true + showIndividualBoard: true + showIndividualMedals: true +profiles: [] +recordBonus: + enabled: false + mode: none + levels: [] +manualAdjustment: + enabled: false + reasonRequired: true +channelTemplates: [] +eventOverrides: [] +` + +function collectChanges( + raw: unknown, + kind: string, + keyField: string, + out: RuleDiffRow[], +): void { + if (!Array.isArray(raw)) return + for (const item of raw) { + if (!isRecord(item)) continue + const target = readString(item, keyField) ?? '—' + const change = readString(item, 'kind') ?? 'modified' + const fields = readStringArray(item.fields) + let detail = '整体替换' + if (fields.length > 0) detail = `字段:${fields.join('、')}` + else if (typeof item.before === 'string' && typeof item.after === 'string') { + detail = `${item.before} → ${item.after}` + } + out.push({ kind, target, change, detail }) + } +} + +/** + * 兼容两种预览响应: + * 1. 服务端 rules.ChangeSet(profiles / channels / settings); + * 2. shared/types 里定义的 RulePackImportPreview(diffs 平铺数组)。 + * 后端与共享类型的字段名目前并不一致,这里做一次归一,页面只认 RuleDiffRow。 + */ +function toRulePreview(value: unknown): RulePreview { + if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '规则预览响应格式不符', 0, value) + const rows: RuleDiffRow[] = [] + if (Array.isArray(value.diffs)) { + for (const item of value.diffs) { + if (!isRecord(item)) continue + rows.push({ + kind: readString(item, 'kind') ?? '其他', + target: readString(item, 'target') ?? '—', + change: readString(item, 'change') ?? 'modified', + detail: readString(item, 'detail') ?? '', + }) + } + } + collectChanges(value.profiles, '积分模板', 'code', rows) + collectChanges(value.channels, '计分通道', 'eventCode', rows) + collectChanges(value.settings, '赛事设置', 'key', rows) + + return { + compatible: value.compatible !== false, + affectedEventCount: readNumber(value, 'affectedEventCount') ?? 0, + rows, + unknownEvents: readStringArray(value.unknownEvents), + } +} + +function toRuleApplyResult(value: unknown): RuleApplyResult { + const preview = toRulePreview(value) + const recalculated = isRecord(value) ? (readNumber(value, 'recalculatedLedgerRows') ?? 0) : 0 + return { ...preview, recalculatedLedgerRows: recalculated } +} + +function mockPreview(): RulePreview { + return { + compatible: true, + affectedEventCount: 3, + rows: [ + { kind: '积分模板', target: 'individual_top6', change: 'modified', detail: '字段:rules、awardRanks' }, + { kind: '积分模板', target: 'team_top6', change: 'removed', detail: '整体替换' }, + { kind: '计分通道', target: 'M100', change: 'added', detail: '整体替换' }, + { kind: '赛事设置', target: 'showIndividualBoard', change: 'modified', detail: 'true → false' }, + ], + unknownEvents: ['EVT_UNKNOWN'], + } +} + +export const rulesApi = { + async exportYaml(): Promise> { + return withMockInfo( + async () => ({ + blob: new Blob([await requestText('/rules/export', { timeoutMs: 30_000 })], { + type: 'application/yaml;charset=utf-8', + }), + filename: 'rules.yaml', + }), + () => ({ + blob: new Blob([MOCK_RULE_YAML], { type: 'application/yaml;charset=utf-8' }), + filename: 'rules(示例数据).yaml', + }), + ) + }, + + async preview(yaml: string): Promise> { + return withMockInfo( + () => + requestJson('/rules/import', { + method: 'POST', + query: { preview: 1 }, + body: yaml, + contentType: 'application/yaml; charset=utf-8', + timeoutMs: 60_000, + }).then(toRulePreview), + () => mockPreview(), + ) + }, + + async apply(yaml: string): Promise> { + return withMockInfo( + () => + requestJson('/rules/import', { + method: 'POST', + body: yaml, + contentType: 'application/yaml; charset=utf-8', + timeoutMs: 60_000, + }).then(toRuleApplyResult), + () => ({ ...mockPreview(), recalculatedLedgerRows: 128 }), + ) + }, +} + +// --------------------------------------------------------------------------- +// 赛事设置 +// --------------------------------------------------------------------------- + +export type EventSettingsPatch = Partial + +export const settingsApi = { + async save(patch: EventSettingsPatch, current: EventSettings): Promise> { + return withMockInfo( + async () => { + const body = await requestJson('/settings', { method: 'PUT', json: patch }) + if (!isRecord(body)) throw new ApiError('PARSE_FAILED', '设置响应格式不符', 0, body) + // 服务端返回的字段与 shared/types 的 EventSettings 一一对应,这里只做结构校验后透传。 + return body as unknown as EventSettings + }, + // 示例模式下把改动合并进当前设置,让表单交互能完整走通。 + () => ({ ...current, ...patch }), + ) + }, +} diff --git a/web/src/admin/pages/AboutPage.vue b/web/src/admin/pages/AboutPage.vue new file mode 100644 index 0000000..eb05232 --- /dev/null +++ b/web/src/admin/pages/AboutPage.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/web/src/admin/pages/AdjustmentsPage.vue b/web/src/admin/pages/AdjustmentsPage.vue new file mode 100644 index 0000000..8767f24 --- /dev/null +++ b/web/src/admin/pages/AdjustmentsPage.vue @@ -0,0 +1,605 @@ + + + + + diff --git a/web/src/admin/pages/ExportPage.vue b/web/src/admin/pages/ExportPage.vue new file mode 100644 index 0000000..e326f6b --- /dev/null +++ b/web/src/admin/pages/ExportPage.vue @@ -0,0 +1,787 @@ + + + + + diff --git a/web/src/admin/pages/LogsPage.vue b/web/src/admin/pages/LogsPage.vue new file mode 100644 index 0000000..5762fcc --- /dev/null +++ b/web/src/admin/pages/LogsPage.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/web/src/admin/pages/RecordsPage.vue b/web/src/admin/pages/RecordsPage.vue new file mode 100644 index 0000000..2fc458a --- /dev/null +++ b/web/src/admin/pages/RecordsPage.vue @@ -0,0 +1,649 @@ + + + + + diff --git a/web/src/admin/pages/ReviewPage.vue b/web/src/admin/pages/ReviewPage.vue new file mode 100644 index 0000000..edfb5a6 --- /dev/null +++ b/web/src/admin/pages/ReviewPage.vue @@ -0,0 +1,729 @@ + + + + + diff --git a/web/src/admin/pages/SystemPage.vue b/web/src/admin/pages/SystemPage.vue new file mode 100644 index 0000000..993093d --- /dev/null +++ b/web/src/admin/pages/SystemPage.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/web/src/display/App.vue b/web/src/display/App.vue index 023fed5..d57cc26 100644 --- a/web/src/display/App.vue +++ b/web/src/display/App.vue @@ -5,9 +5,11 @@ import { mix, parseHexColor, readableForeground, toCss } from './color' import type { DisplayPageKey } from './types' import { useScoreboard } from './useScoreboard' import { useStageScale } from './useStageScale' +import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed' import DetailOverlay from './components/DetailOverlay.vue' import TickerBar from './components/TickerBar.vue' import TopBar from './components/TopBar.vue' +import WindowedTitleBar from './components/WindowedTitleBar.vue' import AnnouncementPage from './components/pages/AnnouncementPage.vue' import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue' import EventResultsPage from './components/pages/EventResultsPage.vue' @@ -23,10 +25,19 @@ const PAGES: Record = { } const { bootstrap, dispose, page, settings } = useScoreboard() -const { scale, offsetX, offsetY } = useStageScale() + +// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效 +const windowed = isWindowed() +const { scale, offsetX, offsetY } = useStageScale({ + topInset: () => (windowed ? WINDOWED_TITLEBAR_HEIGHT : 0), +}) const currentPage = computed(() => PAGES[page.value]) +const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` } +const barTitle = computed(() => settings.value?.display.title.trim() || '赛事标题待配置') +const barLogo = computed(() => settings.value?.display.logo?.trim() ?? '') + const stageStyle = computed(() => ({ transform: `scale(${scale.value})`, left: `${offsetX.value}px`, @@ -72,30 +83,43 @@ onUnmounted(() => { diff --git a/web/src/display/useStageScale.ts b/web/src/display/useStageScale.ts index 886d5ec..2e5860e 100644 --- a/web/src/display/useStageScale.ts +++ b/web/src/display/useStageScale.ts @@ -3,20 +3,30 @@ import { onUnmounted, ref } from 'vue' export const DESIGN_WIDTH = 1920 export const DESIGN_HEIGHT = 1080 +export interface StageScaleOptions { + /** + * 窗口顶部被标题栏占掉的高度(kiosk 为 0)。 + * 传 getter 而不是数值:缩放回调在 resize 时重新取值,避免外部状态变化后拿到旧值。 + */ + topInset?: () => number +} + /** * 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。 * * 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸; * 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。 */ -export function useStageScale() { +export function useStageScale(options: StageScaleOptions = {}) { + const topInset = options.topInset ?? ((): number => 0) const scale = ref(1) const offsetX = ref(0) const offsetY = ref(0) function fit(): void { const width = window.innerWidth - const height = window.innerHeight + // 可用高度要扣掉窗口化标题栏,否则 1920×1080 舞台会被标题栏遮住顶部 + const height = Math.max(0, window.innerHeight - topInset()) const next = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT) scale.value = next offsetX.value = (width - DESIGN_WIDTH * next) / 2 diff --git a/web/src/display/windowed.ts b/web/src/display/windowed.ts new file mode 100644 index 0000000..86c6ea9 --- /dev/null +++ b/web/src/display/windowed.ts @@ -0,0 +1,14 @@ +/** + * 窗口化模式标记。 + * + * 单屏调试时 Console 会给 URL 追加 `windowed=1`(06 第 6.3 节),页面据此画标题栏; + * 多屏 kiosk 不追加,页面必须保持零占用的全屏布局。 + */ +export const WINDOWED_PARAM = 'windowed' + +/** 标题栏高度:与 Console 自绘标题栏一致,缩放计算要减掉这一段。 */ +export const WINDOWED_TITLEBAR_HEIGHT = 50 + +export function isWindowed(): boolean { + return new URLSearchParams(window.location.search).get(WINDOWED_PARAM) === '1' +}