feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入
This commit is contained in:
1 parent
f3813171d3
commit
72462792ec
39 files changed
+10001
-28
No files matched your search
@@ -92,7 +92,7 @@ class DisplayManager {
|
|||||||
this.applyGeometry(existing.win, display, single)
|
this.applyGeometry(existing.win, display, single)
|
||||||
if (existing.page !== page) {
|
if (existing.page !== page) {
|
||||||
existing.page = page
|
existing.page = page
|
||||||
this.loadPage(existing, display, page)
|
this.loadPage(existing, display, page, single)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,11 @@ class DisplayManager {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
...this.geometryFor(display, single),
|
...this.geometryFor(display, single),
|
||||||
show: false,
|
show: false,
|
||||||
frame: single,
|
// 窗口化模式由页面自绘标题栏(与应用其它窗口同风格),保留原生边框会多出一条标题栏;
|
||||||
|
// 多屏 kiosk 本来就不要边框
|
||||||
|
frame: false,
|
||||||
|
// 与 Console 主窗口同一套兜底:个别平台会忽略 frame:false
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
kiosk: !single && this.display.kiosk !== false,
|
kiosk: !single && this.display.kiosk !== false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
backgroundColor: this.display.backgroundColor || '#000000',
|
backgroundColor: this.display.backgroundColor || '#000000',
|
||||||
@@ -156,7 +160,7 @@ class DisplayManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.applyGeometry(win, display, single)
|
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}`)
|
this.log.push('console', `打开大屏窗口 display=${display.id} bounds=${bounds.width}x${bounds.height}@${bounds.x},${bounds.y} page=${page}`)
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -186,9 +190,9 @@ class DisplayManager {
|
|||||||
win.setBounds(this.geometryFor(display, single))
|
win.setBounds(this.geometryFor(display, single))
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPage(entry, display, page) {
|
loadPage(entry, display, page, single) {
|
||||||
if (!entry || !entry.win || entry.win.isDestroyed()) return
|
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}`)
|
this.log.push('console', `大屏加载 ${url}`)
|
||||||
entry.win.loadURL(url).catch((err) => {
|
entry.win.loadURL(url).catch((err) => {
|
||||||
// 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载
|
// 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载
|
||||||
@@ -196,13 +200,15 @@ class DisplayManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
urlFor(display, page) {
|
urlFor(display, page, single) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set(this.display.pageParam || 'page', page)
|
params.set(this.display.pageParam || 'page', page)
|
||||||
// lock=1 告知页面锁死单页,不参与轮播
|
// lock=1 告知页面锁死单页,不参与轮播
|
||||||
params.set('lock', '1')
|
params.set('lock', '1')
|
||||||
params.set('screen', String(display.id))
|
params.set('screen', String(display.id))
|
||||||
params.set('instance', `${os.hostname()}-${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()}`
|
return `http://127.0.0.1:${this.server.port}/display?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,80 @@ type eventSettingsStandings struct {
|
|||||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
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 ----------
|
// ---------- domain → DTO ----------
|
||||||
|
|
||||||
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
|
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
|
||||||
|
|||||||
@@ -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), ",")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -106,3 +106,27 @@ type categoriesPayload struct {
|
|||||||
type channelsPayload struct {
|
type channelsPayload struct {
|
||||||
Channels []channelDTO `json:"channels"`
|
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"`
|
||||||
|
}
|
||||||
@@ -584,10 +584,16 @@ func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return internal(err)
|
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,
|
_, err = tx.ExecContext(ctx,
|
||||||
`INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message)
|
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
|
||||||
VALUES (?, ?, ?, '[]', ?, ?)`,
|
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
|
||||||
eventID, action, string(snapshot), nullID(actorID), message)
|
parentID, eventID, action, string(snapshot), nullID(actorID), message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return internal(err)
|
return internal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
|
||||||
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
|
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/export/", s.notWired("导出"))
|
||||||
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
|
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础数据的分区表单弹窗。
|
||||||
|
*
|
||||||
|
* 只负责外壳与提交态:字段校验、错误文案由各分区自己算好后传进来,
|
||||||
|
* 弹窗不感知业务,避免七个分区各写一份弹窗逻辑。
|
||||||
|
*/
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
submitText?: string
|
||||||
|
pending?: boolean
|
||||||
|
error?: string | null
|
||||||
|
wide?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
subtitle: '',
|
||||||
|
submitText: '保存',
|
||||||
|
pending: false,
|
||||||
|
error: null,
|
||||||
|
wide: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (!props.open) return
|
||||||
|
if (event.key === 'Escape' && !props.pending) emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-modal-overlay" :class="{ open }" @click.self="pending ? null : emit('cancel')">
|
||||||
|
<div class="mx-modal" :class="{ 'mx-modal-lg': wide }" role="dialog" aria-modal="true">
|
||||||
|
<div class="mx-modal-head">
|
||||||
|
<div>
|
||||||
|
<span class="mx-modal-title">{{ title }}</span>
|
||||||
|
<p v-if="subtitle" class="mx-modal-sub">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="mx-modal-close" type="button" :disabled="pending" @click="emit('cancel')">
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-body">
|
||||||
|
<div v-if="error" class="mx-badge mx-badge-error form-error">{{ error }}</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-foot">
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="emit('cancel')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" :disabled="pending" @click="emit('submit')">
|
||||||
|
{{ pending ? '保存中…' : submitText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mx-modal-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 表单字段外壳:标签 + 控件 + 校验错误。错误文案由分区组件算好后传入。 */
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
error?: string | null
|
||||||
|
required?: boolean
|
||||||
|
wide?: boolean
|
||||||
|
}>(),
|
||||||
|
{ hint: '', error: null, required: false, wide: false },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="field" :class="{ 'span-2': wide }">
|
||||||
|
<span class="mx-label">
|
||||||
|
{{ label }}
|
||||||
|
<span v-if="required" class="required">*</span>
|
||||||
|
</span>
|
||||||
|
<slot />
|
||||||
|
<span v-if="error" class="field-error">{{ error }}</span>
|
||||||
|
<span v-else-if="hint" class="field-hint">{{ hint }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.required {
|
||||||
|
color: var(--mx-red);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type {
|
||||||
|
CompareMode,
|
||||||
|
Event,
|
||||||
|
EventInput,
|
||||||
|
EventStatus,
|
||||||
|
EventType,
|
||||||
|
ResultMode,
|
||||||
|
ResultType,
|
||||||
|
Sport,
|
||||||
|
SportInput,
|
||||||
|
} from '@/shared'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import FormDialog from './FormDialog.vue'
|
||||||
|
import FormField from './FormField.vue'
|
||||||
|
import { useBaseData } from './useBaseData'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
import type { BaseDataTabKey } from './tabs'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
|
||||||
|
|
||||||
|
const data = useBaseData()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,24}$/
|
||||||
|
|
||||||
|
const EVENT_TYPE_OPTIONS: Array<{ value: EventType; label: string }> = [
|
||||||
|
{ value: 'individual', label: '个人' },
|
||||||
|
{ value: 'team', label: '团体' },
|
||||||
|
{ value: 'match', label: '对抗 / 比赛' },
|
||||||
|
]
|
||||||
|
const RESULT_MODE_OPTIONS: Array<{ value: ResultMode; label: string }> = [
|
||||||
|
{ value: 'auto', label: '自动排名(录成绩)' },
|
||||||
|
{ value: 'manual', label: '手动名次(直接录名次)' },
|
||||||
|
]
|
||||||
|
const RESULT_TYPE_OPTIONS: Array<{ value: ResultType; label: string }> = [
|
||||||
|
{ value: 'time', label: '时间' },
|
||||||
|
{ value: 'distance', label: '距离' },
|
||||||
|
{ value: 'count', label: '计数' },
|
||||||
|
{ value: 'score', label: '得分' },
|
||||||
|
{ value: 'none', label: '无成绩' },
|
||||||
|
]
|
||||||
|
const COMPARE_OPTIONS: Array<{ value: CompareMode; label: string }> = [
|
||||||
|
{ value: 'asc', label: '越小越好(asc)' },
|
||||||
|
{ value: 'desc', label: '越大越好(desc)' },
|
||||||
|
{ value: 'none', label: '不比较(none)' },
|
||||||
|
]
|
||||||
|
const STATUS_OPTIONS: Array<{ value: EventStatus; label: string }> = [
|
||||||
|
{ value: 'pending', label: '未开始' },
|
||||||
|
{ value: 'ongoing', label: '进行中' },
|
||||||
|
{ value: 'finished', label: '已结束' },
|
||||||
|
{ value: 'published', label: '已发布' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function labelOf<T extends string>(options: Array<{ value: T; label: string }>, value: T): string {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSportId = ref<number | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => data.sports.value,
|
||||||
|
(list) => {
|
||||||
|
if (selectedSportId.value === null || !list.some((sport) => sport.id === selectedSportId.value)) {
|
||||||
|
selectedSportId.value = list[0]?.id ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => data.selectedEventId.value,
|
||||||
|
(id) => {
|
||||||
|
const event = data.events.value.find((item) => item.id === id)
|
||||||
|
if (event !== undefined) selectedSportId.value = event.sportId
|
||||||
|
if (id !== null) void data.loadEventDetail(id)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedSport = computed(() => data.sportById(selectedSportId.value) ?? null)
|
||||||
|
const sportEvents = computed(() => data.events.value.filter((event) => event.sportId === selectedSportId.value))
|
||||||
|
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
|
||||||
|
const selectedChannels = computed(() => data.channelsOf(data.selectedEventId.value))
|
||||||
|
const selectedCategories = computed(() => data.categoriesOf(data.selectedEventId.value))
|
||||||
|
|
||||||
|
const awardSummary = computed(() => {
|
||||||
|
const ranks = selectedChannels.value
|
||||||
|
.map((channel) => data.profileById(channel.profileId)?.awardRanks ?? 0)
|
||||||
|
.filter((rank) => rank > 0)
|
||||||
|
if (ranks.length === 0) return '未配置'
|
||||||
|
return `前 ${Math.max(...ranks)} 名`
|
||||||
|
})
|
||||||
|
|
||||||
|
const channelSummary = computed(() =>
|
||||||
|
selectedChannels.value.map((channel) => {
|
||||||
|
const profile = data.profileById(channel.profileId)
|
||||||
|
return {
|
||||||
|
id: channel.id,
|
||||||
|
scope: channel.scope === 'athlete' ? '个人名次' : '团体名次',
|
||||||
|
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '团体汇总',
|
||||||
|
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||||
|
award: profile?.awardRanks ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function pickSport(id: number): void {
|
||||||
|
selectedSportId.value = id
|
||||||
|
const first = data.events.value.find((event) => event.sportId === id)
|
||||||
|
if (first !== undefined && first.id !== data.selectedEventId.value) {
|
||||||
|
void data.selectEvent(first.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 大项 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SportForm {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
shortName: string
|
||||||
|
defaultProfileId: string
|
||||||
|
sortOrder: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type SportField = keyof SportForm
|
||||||
|
|
||||||
|
const sportDialog = ref(false)
|
||||||
|
const sportEditingId = ref<number | null>(null)
|
||||||
|
const sportForm = ref<SportForm>(emptySportForm())
|
||||||
|
const sportErrors = ref<Partial<Record<SportField, string>>>({})
|
||||||
|
const sportFormError = ref<string | null>(null)
|
||||||
|
const sportPending = ref(false)
|
||||||
|
const sportConfirmOpen = ref(false)
|
||||||
|
const sportConfirmPending = ref(false)
|
||||||
|
|
||||||
|
function emptySportForm(): SportForm {
|
||||||
|
return { code: '', name: '', shortName: '', defaultProfileId: '', sortOrder: '0', active: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateSport(): void {
|
||||||
|
sportEditingId.value = null
|
||||||
|
sportForm.value = emptySportForm()
|
||||||
|
sportForm.value.sortOrder = String(data.sports.value.length + 1)
|
||||||
|
sportErrors.value = {}
|
||||||
|
sportFormError.value = null
|
||||||
|
sportDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditSport(sport: Sport): void {
|
||||||
|
sportEditingId.value = sport.id
|
||||||
|
sportForm.value = {
|
||||||
|
code: sport.code,
|
||||||
|
name: sport.name,
|
||||||
|
shortName: sport.shortName ?? '',
|
||||||
|
defaultProfileId: sport.defaultProfileId === null ? '' : String(sport.defaultProfileId),
|
||||||
|
sortOrder: String(sport.sortOrder),
|
||||||
|
active: sport.active,
|
||||||
|
}
|
||||||
|
sportErrors.value = {}
|
||||||
|
sportFormError.value = null
|
||||||
|
sportDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSport(): boolean {
|
||||||
|
const next: Partial<Record<SportField, string>> = {}
|
||||||
|
const code = sportForm.value.code.trim()
|
||||||
|
if (code === '') next.code = '代码必填'
|
||||||
|
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
|
||||||
|
else if (data.sports.value.some((sport) => sport.id !== sportEditingId.value && sport.code.toLowerCase() === code.toLowerCase())) {
|
||||||
|
next.code = '代码已存在,必须唯一'
|
||||||
|
}
|
||||||
|
if (sportForm.value.name.trim() === '') next.name = '名称必填'
|
||||||
|
const sortOrder = Number(sportForm.value.sortOrder)
|
||||||
|
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
|
||||||
|
sportErrors.value = next
|
||||||
|
return Object.keys(next).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSport(): Promise<void> {
|
||||||
|
if (!validateSport()) return
|
||||||
|
sportPending.value = true
|
||||||
|
sportFormError.value = null
|
||||||
|
const input: SportInput = {
|
||||||
|
code: sportForm.value.code.trim(),
|
||||||
|
name: sportForm.value.name.trim(),
|
||||||
|
shortName: sportForm.value.shortName.trim() === '' ? null : sportForm.value.shortName.trim(),
|
||||||
|
defaultProfileId: sportForm.value.defaultProfileId === '' ? null : Number(sportForm.value.defaultProfileId),
|
||||||
|
sortOrder: Number(sportForm.value.sortOrder),
|
||||||
|
active: sportForm.value.active,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = await data.saveSport(input, sportEditingId.value)
|
||||||
|
toast.success(sportEditingId.value === null ? '大项已新增' : '大项已保存')
|
||||||
|
sportDialog.value = false
|
||||||
|
if (sportEditingId.value === null) selectedSportId.value = saved.id
|
||||||
|
} catch (caught) {
|
||||||
|
sportFormError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
sportPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDeleteSport(): void {
|
||||||
|
if (selectedSport.value === null) return
|
||||||
|
if (sportEvents.value.length > 0) {
|
||||||
|
toast.warning('该大项下还有小项', `请先删除 ${sportEvents.value.length} 个小项,再删除大项`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sportConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteSport(): Promise<void> {
|
||||||
|
if (selectedSport.value === null) return
|
||||||
|
sportConfirmPending.value = true
|
||||||
|
try {
|
||||||
|
await data.removeSport(selectedSport.value.id)
|
||||||
|
toast.success('大项已删除')
|
||||||
|
sportConfirmOpen.value = false
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('删除失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
sportConfirmPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 小项 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EventForm {
|
||||||
|
sportId: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
eventType: EventType
|
||||||
|
resultMode: ResultMode
|
||||||
|
resultType: ResultType
|
||||||
|
compare: CompareMode
|
||||||
|
scored: boolean
|
||||||
|
recordEligible: boolean
|
||||||
|
multiplier: string
|
||||||
|
status: EventStatus
|
||||||
|
scheduledAt: string
|
||||||
|
venue: string
|
||||||
|
sortOrder: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventField = keyof EventForm
|
||||||
|
|
||||||
|
const eventDialog = ref(false)
|
||||||
|
const eventEditingId = ref<number | null>(null)
|
||||||
|
const eventForm = ref<EventForm>(emptyEventForm())
|
||||||
|
const eventErrors = ref<Partial<Record<EventField, string>>>({})
|
||||||
|
const eventFormError = ref<string | null>(null)
|
||||||
|
const eventPending = ref(false)
|
||||||
|
const eventConfirmOpen = ref(false)
|
||||||
|
const eventConfirmTarget = ref<Event | null>(null)
|
||||||
|
const eventConfirmPending = ref(false)
|
||||||
|
|
||||||
|
function emptyEventForm(): EventForm {
|
||||||
|
return {
|
||||||
|
sportId: '',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
eventType: 'individual',
|
||||||
|
resultMode: 'auto',
|
||||||
|
resultType: 'time',
|
||||||
|
compare: 'asc',
|
||||||
|
scored: true,
|
||||||
|
recordEligible: true,
|
||||||
|
multiplier: '1',
|
||||||
|
status: 'pending',
|
||||||
|
scheduledAt: '',
|
||||||
|
venue: '',
|
||||||
|
sortOrder: '0',
|
||||||
|
remark: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalInput(iso: string | null): string {
|
||||||
|
if (iso === null || iso === '') return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLocalInput(value: string): string | null {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed === '') return null
|
||||||
|
const date = new Date(trimmed)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateEvent(): void {
|
||||||
|
eventEditingId.value = null
|
||||||
|
eventForm.value = emptyEventForm()
|
||||||
|
eventForm.value.sportId = selectedSportId.value === null ? '' : String(selectedSportId.value)
|
||||||
|
eventForm.value.sortOrder = String(sportEvents.value.length + 1)
|
||||||
|
eventErrors.value = {}
|
||||||
|
eventFormError.value = null
|
||||||
|
eventDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditEvent(event: Event): void {
|
||||||
|
eventEditingId.value = event.id
|
||||||
|
eventForm.value = {
|
||||||
|
sportId: String(event.sportId),
|
||||||
|
code: event.code,
|
||||||
|
name: event.name,
|
||||||
|
eventType: event.eventType,
|
||||||
|
resultMode: event.resultMode,
|
||||||
|
resultType: event.resultType,
|
||||||
|
compare: event.compare,
|
||||||
|
scored: event.scored,
|
||||||
|
recordEligible: event.recordEligible,
|
||||||
|
multiplier: String(event.multiplier),
|
||||||
|
status: event.status,
|
||||||
|
scheduledAt: toLocalInput(event.scheduledAt),
|
||||||
|
venue: event.venue ?? '',
|
||||||
|
sortOrder: String(event.sortOrder),
|
||||||
|
remark: event.remark ?? '',
|
||||||
|
}
|
||||||
|
eventErrors.value = {}
|
||||||
|
eventFormError.value = null
|
||||||
|
eventDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEvent(): boolean {
|
||||||
|
const next: Partial<Record<EventField, string>> = {}
|
||||||
|
if (eventForm.value.sportId === '') next.sportId = '必须归属一个大项'
|
||||||
|
const code = eventForm.value.code.trim()
|
||||||
|
if (code === '') next.code = '代码必填'
|
||||||
|
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
|
||||||
|
else if (data.events.value.some((event) => event.id !== eventEditingId.value && event.code.toLowerCase() === code.toLowerCase())) {
|
||||||
|
next.code = '代码已存在,必须唯一'
|
||||||
|
}
|
||||||
|
if (eventForm.value.name.trim() === '') next.name = '名称必填'
|
||||||
|
const multiplier = Number(eventForm.value.multiplier)
|
||||||
|
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) next.multiplier = '权重为 0–100 的正数'
|
||||||
|
const sortOrder = Number(eventForm.value.sortOrder)
|
||||||
|
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
|
||||||
|
eventErrors.value = next
|
||||||
|
return Object.keys(next).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEvent(): Promise<void> {
|
||||||
|
if (!validateEvent()) return
|
||||||
|
eventPending.value = true
|
||||||
|
eventFormError.value = null
|
||||||
|
const input: EventInput = {
|
||||||
|
sportId: Number(eventForm.value.sportId),
|
||||||
|
code: eventForm.value.code.trim(),
|
||||||
|
name: eventForm.value.name.trim(),
|
||||||
|
eventType: eventForm.value.eventType,
|
||||||
|
resultMode: eventForm.value.resultMode,
|
||||||
|
resultType: eventForm.value.resultType,
|
||||||
|
compare: eventForm.value.compare,
|
||||||
|
recordEligible: eventForm.value.recordEligible,
|
||||||
|
scored: eventForm.value.scored,
|
||||||
|
multiplier: Number(eventForm.value.multiplier),
|
||||||
|
status: eventForm.value.status,
|
||||||
|
scheduledAt: fromLocalInput(eventForm.value.scheduledAt),
|
||||||
|
venue: eventForm.value.venue.trim() === '' ? null : eventForm.value.venue.trim(),
|
||||||
|
sortOrder: Number(eventForm.value.sortOrder),
|
||||||
|
remark: eventForm.value.remark.trim() === '' ? null : eventForm.value.remark.trim(),
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = await data.saveEvent(input, eventEditingId.value)
|
||||||
|
toast.success(eventEditingId.value === null ? '小项已新增' : '小项已保存')
|
||||||
|
eventDialog.value = false
|
||||||
|
selectedSportId.value = saved.sportId
|
||||||
|
} catch (caught) {
|
||||||
|
eventFormError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
eventPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDeleteEvent(event: Event): void {
|
||||||
|
eventConfirmTarget.value = event
|
||||||
|
eventConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteEvent(): Promise<void> {
|
||||||
|
const target = eventConfirmTarget.value
|
||||||
|
if (target === null) return
|
||||||
|
eventConfirmPending.value = true
|
||||||
|
try {
|
||||||
|
await data.removeEvent(target.id)
|
||||||
|
toast.success(`已删除「${target.name}」`)
|
||||||
|
eventConfirmOpen.value = false
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('删除失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
eventConfirmPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bd-split">
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">大项</span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openCreateSport">
|
||||||
|
<MxIcon name="entry" :size="14" /> 新增
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div v-if="data.loading.value && data.sports.value.length === 0" class="mx-empty bd-empty">
|
||||||
|
<span class="mx-spinner" />
|
||||||
|
<div class="mx-empty-title">加载中…</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data.sports.value.length === 0" class="mx-empty bd-empty">
|
||||||
|
<div class="mx-empty-title">还没有大项</div>
|
||||||
|
<div class="mx-empty-desc">大项是项目分组,例如田径 / 球类 / 趣味</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bd-list">
|
||||||
|
<button
|
||||||
|
v-for="sport in data.sports.value"
|
||||||
|
:key="sport.id"
|
||||||
|
class="bd-list-item"
|
||||||
|
:class="{ active: sport.id === selectedSportId }"
|
||||||
|
type="button"
|
||||||
|
@click="pickSport(sport.id)"
|
||||||
|
>
|
||||||
|
<span class="bd-list-main">
|
||||||
|
<span>{{ sport.name }}</span>
|
||||||
|
<small>{{ sport.code }} · {{ data.events.value.filter((event) => event.sportId === sport.id).length }} 个小项</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="!sport.active" class="mx-badge mx-badge-neutral">停用</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">
|
||||||
|
小项{{ selectedSport === null ? '' : ` · ${selectedSport.name}` }}
|
||||||
|
</span>
|
||||||
|
<div class="bd-toolbar-right">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || selectedSport === null"
|
||||||
|
@click="selectedSport && openEditSport(selectedSport)"
|
||||||
|
>
|
||||||
|
编辑大项
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || selectedSport === null"
|
||||||
|
@click="askDeleteSport"
|
||||||
|
>
|
||||||
|
删除大项
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || selectedSportId === null"
|
||||||
|
@click="openCreateEvent"
|
||||||
|
>
|
||||||
|
<MxIcon name="entry" :size="14" /> 新增小项
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div v-if="selectedEvent !== null" class="bd-detail-strip">
|
||||||
|
<span>当前小项:<strong>{{ selectedEvent.name }}</strong>({{ selectedEvent.code }})</span>
|
||||||
|
<span>类型:<strong>{{ labelOf(EVENT_TYPE_OPTIONS, selectedEvent.eventType) }}</strong></span>
|
||||||
|
<span>录入:<strong>{{ labelOf(RESULT_MODE_OPTIONS, selectedEvent.resultMode) }}</strong></span>
|
||||||
|
<span>成绩:<strong>{{ labelOf(RESULT_TYPE_OPTIONS, selectedEvent.resultType) }}</strong></span>
|
||||||
|
<span>比较:<strong>{{ labelOf(COMPARE_OPTIONS, selectedEvent.compare) }}</strong></span>
|
||||||
|
<span>计分:<strong>{{ selectedEvent.scored ? `是(权重 ${selectedEvent.multiplier})` : '否(只排名)' }}</strong></span>
|
||||||
|
<span>组别:<strong>{{ selectedCategories.length }}</strong></span>
|
||||||
|
<span>通道:<strong>{{ selectedChannels.length }}</strong> · 录取名次:<strong>{{ awardSummary }}</strong></span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="emit('goTab', 'categories')">配置组别</button>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="emit('goTab', 'channels')">配置计分通道</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="bd-note">在下方表格中点击一行,或在上方选择小项,即可查看组别与计分通道。</p>
|
||||||
|
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>代码</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>录入方式</th>
|
||||||
|
<th>成绩</th>
|
||||||
|
<th>比较</th>
|
||||||
|
<th class="center">计分</th>
|
||||||
|
<th class="right">权重</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th class="right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="event in sportEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:class="{ selected: event.id === data.selectedEventId.value }"
|
||||||
|
class="clickable"
|
||||||
|
@click="data.selectEvent(event.id)"
|
||||||
|
>
|
||||||
|
<td class="bd-mono">{{ event.code }}</td>
|
||||||
|
<td>{{ event.name }}</td>
|
||||||
|
<td>{{ labelOf(EVENT_TYPE_OPTIONS, event.eventType) }}</td>
|
||||||
|
<td>{{ labelOf(RESULT_MODE_OPTIONS, event.resultMode) }}</td>
|
||||||
|
<td>{{ labelOf(RESULT_TYPE_OPTIONS, event.resultType) }}</td>
|
||||||
|
<td>{{ labelOf(COMPARE_OPTIONS, event.compare) }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<span class="mx-badge" :class="event.scored ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||||
|
{{ event.scored ? '计分' : '不计分' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="right bd-mono">{{ event.multiplier }}</td>
|
||||||
|
<td>{{ labelOf(STATUS_OPTIONS, event.status) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="bd-actions">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click.stop="openEditEvent(event)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm danger-text"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value"
|
||||||
|
@click.stop="askDeleteEvent(event)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="sportEvents.length === 0">
|
||||||
|
<td colspan="10">
|
||||||
|
<div class="mx-empty bd-empty">
|
||||||
|
<div class="mx-empty-title">
|
||||||
|
{{ selectedSportId === null ? '请先选择或新增大项' : '该大项下还没有小项' }}
|
||||||
|
</div>
|
||||||
|
<div class="mx-empty-desc">小项是最小计分单位,组别与计分通道都挂在它下面</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="channelSummary.length > 0" class="channel-list">
|
||||||
|
<p class="bd-note">当前小项的计分通道(名次来源 → 积分模板 → 录取名次):</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="channel in channelSummary" :key="channel.id">
|
||||||
|
<span class="mx-badge mx-badge-info">{{ channel.scope }}</span>
|
||||||
|
<span>{{ channel.basis }}</span>
|
||||||
|
<span class="bd-mono">{{ channel.profile }}</span>
|
||||||
|
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
:open="sportDialog"
|
||||||
|
:title="sportEditingId === null ? '新增大项' : '编辑大项'"
|
||||||
|
subtitle="大项只做分组与默认积分模板,具体计分规则配在小项的通道上"
|
||||||
|
:pending="sportPending"
|
||||||
|
:error="sportFormError"
|
||||||
|
@submit="submitSport"
|
||||||
|
@cancel="sportDialog = false"
|
||||||
|
>
|
||||||
|
<div class="bd-form-grid">
|
||||||
|
<FormField label="代码" required :error="sportErrors.code">
|
||||||
|
<input v-model="sportForm.code" class="mx-cell-input" placeholder="例如 TJ" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="名称" required :error="sportErrors.name">
|
||||||
|
<input v-model="sportForm.name" class="mx-cell-input" placeholder="例如 田径" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="简称">
|
||||||
|
<input v-model="sportForm.shortName" class="mx-cell-input" placeholder="例如 田径" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="默认积分模板" hint="新建小项时作为参考,可留空">
|
||||||
|
<select v-model="sportForm.defaultProfileId" class="mx-cell-input">
|
||||||
|
<option value="">不指定</option>
|
||||||
|
<option v-for="profile in data.profiles.value" :key="profile.id" :value="String(profile.id)">
|
||||||
|
{{ profile.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="排序" :error="sportErrors.sortOrder">
|
||||||
|
<input v-model="sportForm.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="状态">
|
||||||
|
<label class="bd-check"><input v-model="sportForm.active" type="checkbox" /> 启用</label>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormDialog>
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
:open="eventDialog"
|
||||||
|
:title="eventEditingId === null ? '新增小项' : '编辑小项'"
|
||||||
|
subtitle="录入方式与成绩类型决定成绩页的输入与自动排名行为"
|
||||||
|
:pending="eventPending"
|
||||||
|
:error="eventFormError"
|
||||||
|
wide
|
||||||
|
@submit="submitEvent"
|
||||||
|
@cancel="eventDialog = false"
|
||||||
|
>
|
||||||
|
<div class="bd-form-grid">
|
||||||
|
<FormField label="所属大项" required :error="eventErrors.sportId">
|
||||||
|
<select v-model="eventForm.sportId" class="mx-cell-input">
|
||||||
|
<option value="" disabled>请选择大项</option>
|
||||||
|
<option v-for="sport in data.sports.value" :key="sport.id" :value="String(sport.id)">{{ sport.name }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="代码" required :error="eventErrors.code" hint="报名导入按此列匹配">
|
||||||
|
<input v-model="eventForm.code" class="mx-cell-input" placeholder="例如 M100" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="名称" required :error="eventErrors.name" wide>
|
||||||
|
<input v-model="eventForm.name" class="mx-cell-input" placeholder="例如 男子 100 米" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="项目类型" hint="仅用于展示归类">
|
||||||
|
<select v-model="eventForm.eventType" class="mx-cell-input">
|
||||||
|
<option v-for="option in EVENT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="录入方式" hint="manual 时直接录名次,不按成绩排序">
|
||||||
|
<select v-model="eventForm.resultMode" class="mx-cell-input">
|
||||||
|
<option v-for="option in RESULT_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="成绩类型">
|
||||||
|
<select v-model="eventForm.resultType" class="mx-cell-input">
|
||||||
|
<option v-for="option in RESULT_TYPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="比较方式" hint="asc 越小越好 / desc 越大越好">
|
||||||
|
<select v-model="eventForm.compare" class="mx-cell-input">
|
||||||
|
<option v-for="option in COMPARE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="权重" :error="eventErrors.multiplier" hint="最终得分 = 模板分值 × 权重">
|
||||||
|
<input v-model="eventForm.multiplier" class="mx-cell-input" type="number" min="0.1" step="0.1" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="排序" :error="eventErrors.sortOrder">
|
||||||
|
<input v-model="eventForm.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="状态">
|
||||||
|
<select v-model="eventForm.status" class="mx-cell-input">
|
||||||
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="计划时间">
|
||||||
|
<input v-model="eventForm.scheduledAt" class="mx-cell-input" type="datetime-local" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="场地">
|
||||||
|
<input v-model="eventForm.venue" class="mx-cell-input" placeholder="例如 田径场" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="备注" wide>
|
||||||
|
<input v-model="eventForm.remark" class="mx-cell-input" placeholder="可留空" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="选项" wide>
|
||||||
|
<div class="check-row">
|
||||||
|
<label class="bd-check"><input v-model="eventForm.scored" type="checkbox" /> 计分(取消则只排名不计分)</label>
|
||||||
|
<label class="bd-check"><input v-model="eventForm.recordEligible" type="checkbox" /> 参与纪录榜</label>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="sportConfirmOpen"
|
||||||
|
tone="danger"
|
||||||
|
title="删除该大项?"
|
||||||
|
:message="`将删除大项「${selectedSport?.name ?? ''}」,此操作不可恢复。`"
|
||||||
|
confirm-text="删除大项"
|
||||||
|
:pending="sportConfirmPending"
|
||||||
|
@confirm="confirmDeleteSport"
|
||||||
|
@cancel="sportConfirmOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="eventConfirmOpen"
|
||||||
|
tone="danger"
|
||||||
|
title="删除该小项?"
|
||||||
|
:message="`将删除小项「${eventConfirmTarget?.name ?? ''}」及其组别、计分通道配置。`"
|
||||||
|
:details="['若该项目已有报名数据,服务端会拒绝删除', '删除后需要重新配置组别与计分通道']"
|
||||||
|
confirm-text="删除小项"
|
||||||
|
:pending="eventConfirmPending"
|
||||||
|
@confirm="confirmDeleteEvent"
|
||||||
|
@cancel="eventConfirmOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danger-text {
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
tr.selected {
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
}
|
||||||
|
tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.channel-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.channel-list ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.channel-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Team, TeamInput } from '@/shared'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import FormDialog from './FormDialog.vue'
|
||||||
|
import FormField from './FormField.vue'
|
||||||
|
import { useBaseData } from './useBaseData'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
|
const data = useBaseData()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,16}$/
|
||||||
|
const COLOR_PATTERN = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||||
|
|
||||||
|
interface TeamForm {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
shortName: string
|
||||||
|
logoPath: string
|
||||||
|
color: string
|
||||||
|
sortOrder: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamField = keyof TeamForm
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
|
||||||
|
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const form = ref<TeamForm>(emptyForm())
|
||||||
|
const errors = ref<Partial<Record<TeamField, string>>>({})
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
const pending = ref(false)
|
||||||
|
|
||||||
|
const confirmOpen = ref(false)
|
||||||
|
const confirmTarget = ref<Team | null>(null)
|
||||||
|
const confirmPending = ref(false)
|
||||||
|
|
||||||
|
function emptyForm(): TeamForm {
|
||||||
|
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const keyword = search.value.trim().toLowerCase()
|
||||||
|
return data.teams.value.filter((team) => {
|
||||||
|
if (statusFilter.value === 'active' && !team.active) return false
|
||||||
|
if (statusFilter.value === 'inactive' && team.active) return false
|
||||||
|
if (keyword === '') return true
|
||||||
|
return (
|
||||||
|
team.code.toLowerCase().includes(keyword) ||
|
||||||
|
team.name.toLowerCase().includes(keyword) ||
|
||||||
|
(team.shortName ?? '').toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeCount = computed(() => data.teams.value.filter((team) => team.active).length)
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = emptyForm()
|
||||||
|
form.value.sortOrder = String(data.teams.value.length + 1)
|
||||||
|
errors.value = {}
|
||||||
|
formError.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(team: Team): void {
|
||||||
|
editingId.value = team.id
|
||||||
|
form.value = {
|
||||||
|
code: team.code,
|
||||||
|
name: team.name,
|
||||||
|
shortName: team.shortName ?? '',
|
||||||
|
logoPath: team.logoPath ?? '',
|
||||||
|
color: team.color ?? '',
|
||||||
|
sortOrder: String(team.sortOrder),
|
||||||
|
active: team.active,
|
||||||
|
}
|
||||||
|
errors.value = {}
|
||||||
|
formError.value = null
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
const next: Partial<Record<TeamField, string>> = {}
|
||||||
|
const code = form.value.code.trim()
|
||||||
|
if (code === '') next.code = '代码必填'
|
||||||
|
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线,最长 16 位'
|
||||||
|
else if (data.teams.value.some((team) => team.id !== editingId.value && team.code.toLowerCase() === code.toLowerCase())) {
|
||||||
|
next.code = '代码已存在,必须唯一'
|
||||||
|
}
|
||||||
|
if (form.value.name.trim() === '') next.name = '全称必填'
|
||||||
|
const color = form.value.color.trim()
|
||||||
|
if (color !== '' && !COLOR_PATTERN.test(color)) next.color = '颜色格式应为 #RGB 或 #RRGGBB'
|
||||||
|
const sortOrder = Number(form.value.sortOrder)
|
||||||
|
if (!Number.isInteger(sortOrder) || sortOrder < 0 || sortOrder > 9999) next.sortOrder = '排序为 0–9999 的整数'
|
||||||
|
errors.value = next
|
||||||
|
return Object.keys(next).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
if (!validate()) return
|
||||||
|
pending.value = true
|
||||||
|
formError.value = null
|
||||||
|
const input: TeamInput = {
|
||||||
|
code: form.value.code.trim(),
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
shortName: form.value.shortName.trim() === '' ? null : form.value.shortName.trim(),
|
||||||
|
logoPath: form.value.logoPath.trim() === '' ? null : form.value.logoPath.trim(),
|
||||||
|
color: form.value.color.trim() === '' ? null : form.value.color.trim(),
|
||||||
|
sortOrder: Number(form.value.sortOrder),
|
||||||
|
active: form.value.active,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await data.saveTeam(input, editingId.value)
|
||||||
|
toast.success(editingId.value === null ? '单位已新增' : '单位已保存')
|
||||||
|
dialogOpen.value = false
|
||||||
|
} catch (caught) {
|
||||||
|
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askToggle(team: Team): void {
|
||||||
|
if (team.active) {
|
||||||
|
confirmTarget.value = team
|
||||||
|
confirmOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void toggleActive(team)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(team: Team): Promise<void> {
|
||||||
|
confirmPending.value = true
|
||||||
|
try {
|
||||||
|
await data.saveTeam(
|
||||||
|
{
|
||||||
|
code: team.code,
|
||||||
|
name: team.name,
|
||||||
|
shortName: team.shortName,
|
||||||
|
logoPath: team.logoPath,
|
||||||
|
color: team.color,
|
||||||
|
sortOrder: team.sortOrder,
|
||||||
|
active: !team.active,
|
||||||
|
},
|
||||||
|
team.id,
|
||||||
|
)
|
||||||
|
toast.success(team.active ? `已停用「${team.name}」` : `已启用「${team.name}」`)
|
||||||
|
confirmOpen.value = false
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('操作失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
confirmPending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">参赛单位</span>
|
||||||
|
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.teams.value.length }} 启用</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="bd-toolbar">
|
||||||
|
<div class="bd-toolbar-left">
|
||||||
|
<div class="mx-input-wrap bd-search">
|
||||||
|
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||||
|
<input v-model="search" class="mx-input" placeholder="搜索代码 / 全称 / 简称" />
|
||||||
|
</div>
|
||||||
|
<select v-model="statusFilter" class="mx-cell-input bd-select">
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="active">仅启用</option>
|
||||||
|
<option value="inactive">仅停用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="bd-toolbar-right">
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value" @click="openCreate">
|
||||||
|
<MxIcon name="entry" :size="15" /> 新增单位
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>代码</th>
|
||||||
|
<th>全称</th>
|
||||||
|
<th>简称</th>
|
||||||
|
<th>主题色</th>
|
||||||
|
<th class="right">排序</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th class="right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="data.loading.value && data.teams.value.length === 0">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="mx-empty bd-empty">
|
||||||
|
<span class="mx-spinner" />
|
||||||
|
<div class="mx-empty-title">加载中…</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<template v-else>
|
||||||
|
<tr v-for="team in filtered" :key="team.id">
|
||||||
|
<td class="bd-mono">{{ team.code }}</td>
|
||||||
|
<td>{{ team.name }}</td>
|
||||||
|
<td>{{ team.shortName ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="team.color" class="bd-color" :style="{ background: team.color }" :title="team.color" />
|
||||||
|
<span v-else class="bd-note">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="right bd-mono">{{ team.sortOrder }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="team.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
|
{{ team.active ? '启用' : '已停用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="bd-actions">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(team)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
:class="{ 'danger-text': team.active }"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value"
|
||||||
|
@click="askToggle(team)"
|
||||||
|
>
|
||||||
|
{{ team.active ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filtered.length === 0">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="mx-empty bd-empty">
|
||||||
|
<div class="mx-empty-title">{{ search === '' ? '还没有参赛单位' : '没有匹配的单位' }}</div>
|
||||||
|
<div class="mx-empty-desc">单位代码用于导出与排序,新增后不可重复</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="bd-note">停用为软删除:历史成绩与积分流水保留,新报名不再可选该单位。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDialog
|
||||||
|
:open="dialogOpen"
|
||||||
|
:title="editingId === null ? '新增参赛单位' : '编辑参赛单位'"
|
||||||
|
subtitle="代码唯一且用于导出;简称优先用于大屏显示"
|
||||||
|
:pending="pending"
|
||||||
|
:error="formError"
|
||||||
|
@submit="submit"
|
||||||
|
@cancel="dialogOpen = false"
|
||||||
|
>
|
||||||
|
<div class="bd-form-grid">
|
||||||
|
<FormField label="代码" required :error="errors.code" hint="字母 / 数字 / _ / -,最长 16 位">
|
||||||
|
<input v-model="form.code" class="mx-cell-input" placeholder="例如 F1" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="全称" required :error="errors.name">
|
||||||
|
<input v-model="form.name" class="mx-cell-input" placeholder="例如 第一分公司" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="简称" hint="大屏空间有限时优先显示">
|
||||||
|
<input v-model="form.shortName" class="mx-cell-input" placeholder="例如 一分公司" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="主题色" :error="errors.color" hint="用于图表与积分榜标识">
|
||||||
|
<div class="color-row">
|
||||||
|
<input v-model="form.color" class="mx-cell-input" placeholder="#3482FF" />
|
||||||
|
<span v-if="form.color" class="bd-color" :style="{ background: form.color }" />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Logo 路径" hint="相对 uploads/,可留空">
|
||||||
|
<input v-model="form.logoPath" class="mx-cell-input" placeholder="logos/team1.png" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="排序" :error="errors.sortOrder" hint="数字越小越靠前">
|
||||||
|
<input v-model="form.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="状态" wide>
|
||||||
|
<label class="bd-check">
|
||||||
|
<input v-model="form.active" type="checkbox" />
|
||||||
|
启用(取消勾选即停用)
|
||||||
|
</label>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</FormDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmOpen"
|
||||||
|
tone="warning"
|
||||||
|
title="停用该参赛单位?"
|
||||||
|
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该单位;历史成绩与积分流水保留,可随时重新启用。`"
|
||||||
|
confirm-text="停用"
|
||||||
|
:pending="confirmPending"
|
||||||
|
@confirm="confirmTarget && toggleActive(confirmTarget)"
|
||||||
|
@cancel="confirmOpen = false"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.danger-text {
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
|
||||||
|
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<Team[]> => call(() => adminApi.client.teams.list(), () => mockBaseData.listTeams()),
|
||||||
|
create: (input: TeamInput): Promise<Team> =>
|
||||||
|
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
|
||||||
|
update: (id: number, input: TeamInput): Promise<Team> =>
|
||||||
|
call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)),
|
||||||
|
},
|
||||||
|
|
||||||
|
sports: {
|
||||||
|
list: (): Promise<Sport[]> => call(() => adminApi.client.sports.list(), () => mockBaseData.listSports()),
|
||||||
|
create: (input: SportInput): Promise<Sport> =>
|
||||||
|
call(() => adminApi.client.sports.create(input), () => mockBaseData.createSport(input)),
|
||||||
|
update: (id: number, input: SportInput): Promise<Sport> =>
|
||||||
|
call(() => adminApi.client.sports.update(id, input), () => mockBaseData.updateSport(id, input)),
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
call(() => adminApi.client.sports.remove(id), () => mockBaseData.removeSport(id)),
|
||||||
|
},
|
||||||
|
|
||||||
|
events: {
|
||||||
|
list: (): Promise<Event[]> => call(() => adminApi.client.events.list(), () => mockBaseData.listEvents()),
|
||||||
|
get: (id: number): Promise<Event> => call(() => adminApi.client.events.get(id), () => mockBaseData.getEvent(id)),
|
||||||
|
create: (input: EventInput): Promise<Event> =>
|
||||||
|
call(() => adminApi.client.events.create(input), () => mockBaseData.createEvent(input)),
|
||||||
|
update: (id: number, input: EventInput): Promise<Event> =>
|
||||||
|
call(() => adminApi.client.events.update(id, input), () => mockBaseData.updateEvent(id, input)),
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
call(() => adminApi.client.events.remove(id), () => mockBaseData.removeEvent(id)),
|
||||||
|
saveCategories: (eventId: number, inputs: EventCategoryInput[]): Promise<EventCategory[]> =>
|
||||||
|
call(
|
||||||
|
() => adminApi.client.events.saveCategories(eventId, inputs),
|
||||||
|
() => mockBaseData.saveCategories(eventId, inputs),
|
||||||
|
),
|
||||||
|
saveChannels: (eventId: number, inputs: ScoreChannelInput[]): Promise<ScoreChannel[]> =>
|
||||||
|
call(
|
||||||
|
() => adminApi.client.events.saveChannels(eventId, inputs),
|
||||||
|
() => mockBaseData.saveChannels(eventId, inputs),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
profiles: {
|
||||||
|
list: (): Promise<ScoringProfile[]> =>
|
||||||
|
call(() => adminApi.client.scoringProfiles.list(), () => mockBaseData.listProfiles()),
|
||||||
|
create: (input: ScoringProfileInput): Promise<ScoringProfile> =>
|
||||||
|
call(() => adminApi.client.scoringProfiles.create(input), () => mockBaseData.createProfile(input)),
|
||||||
|
update: (id: number, input: ScoringProfileInput): Promise<ScoringProfile> =>
|
||||||
|
call(() => adminApi.client.scoringProfiles.update(id, input), () => mockBaseData.updateProfile(id, input)),
|
||||||
|
},
|
||||||
|
|
||||||
|
athletes: {
|
||||||
|
list: (): Promise<Athlete[]> => call(() => adminApi.client.athletes.list(), () => mockBaseData.listAthletes()),
|
||||||
|
create: (input: AthleteInput): Promise<Athlete> =>
|
||||||
|
call(() => adminApi.client.athletes.create(input), () => mockBaseData.createAthlete(input)),
|
||||||
|
update: (id: number, input: AthleteInput): Promise<Athlete> =>
|
||||||
|
call(() => adminApi.client.athletes.update(id, input), () => mockBaseData.updateAthlete(id, input)),
|
||||||
|
remove: (id: number): Promise<void> =>
|
||||||
|
call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)),
|
||||||
|
},
|
||||||
|
|
||||||
|
entries: {
|
||||||
|
importFile: (file: File): Promise<EntryImportResult> =>
|
||||||
|
call(
|
||||||
|
() => adminApi.client.entries.importFile(file),
|
||||||
|
() => mockBaseData.importEntries(file),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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<Event, 'id' | 'categories' | 'channels'>
|
||||||
|
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<number, number>()
|
||||||
|
|
||||||
|
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<string>()
|
||||||
|
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<string>()
|
||||||
|
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<EntryImportResult> {
|
||||||
|
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()
|
||||||
@@ -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: '报名导入' },
|
||||||
|
]
|
||||||
@@ -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<Team[]>([])
|
||||||
|
const sports = ref<Sport[]>([])
|
||||||
|
const events = ref<Event[]>([])
|
||||||
|
const profiles = ref<ScoringProfile[]>([])
|
||||||
|
const athletes = ref<Athlete[]>([])
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const selectedEventId = ref<number | null>(null)
|
||||||
|
/** 小项详情(含组别 / 通道)按需拉取并缓存,保存后原地更新。 */
|
||||||
|
const details = ref<Record<number, Event>>({})
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
teams.value = sortTeams(await baseDataApi.teams.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadSports(): Promise<void> {
|
||||||
|
sports.value = sortSports(await baseDataApi.sports.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadEvents(): Promise<void> {
|
||||||
|
events.value = sortEvents(await baseDataApi.events.list())
|
||||||
|
// 项目列表是共享 store 的一部分,改动后同步过去,成绩录入页不必等整页刷新。
|
||||||
|
void useEventStore().refreshEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadProfiles(): Promise<void> {
|
||||||
|
profiles.value = await baseDataApi.profiles.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAthletes(): Promise<void> {
|
||||||
|
athletes.value = sortAthletes(await baseDataApi.athletes.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEventDetail(id: number, force = false): Promise<Event | null> {
|
||||||
|
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<void> {
|
||||||
|
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<Team> {
|
||||||
|
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<Sport> {
|
||||||
|
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<void> {
|
||||||
|
await baseDataApi.sports.remove(id)
|
||||||
|
await reloadSports()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEvent(input: EventInput, id: number | null): Promise<Event> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ScoringProfile> {
|
||||||
|
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<Athlete> {
|
||||||
|
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<void> {
|
||||||
|
await baseDataApi.athletes.remove(id)
|
||||||
|
await reloadAthletes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function importEntries(file: File): Promise<EntryImportResult> {
|
||||||
|
return baseDataApi.entries.importFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseDataController {
|
||||||
|
teams: Ref<Team[]>
|
||||||
|
sports: Ref<Sport[]>
|
||||||
|
events: Ref<Event[]>
|
||||||
|
profiles: Ref<ScoringProfile[]>
|
||||||
|
athletes: Ref<Athlete[]>
|
||||||
|
loading: Ref<boolean>
|
||||||
|
error: Ref<string | null>
|
||||||
|
selectedEventId: Ref<number | null>
|
||||||
|
isMock: ComputedRef<boolean>
|
||||||
|
mockLabel: ComputedRef<string | null>
|
||||||
|
canWrite: ComputedRef<boolean>
|
||||||
|
load: (force?: boolean) => Promise<void>
|
||||||
|
loadEventDetail: (id: number, force?: boolean) => Promise<Event | null>
|
||||||
|
selectEvent: (id: number) => Promise<void>
|
||||||
|
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<Team>
|
||||||
|
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
|
||||||
|
removeSport: (id: number) => Promise<void>
|
||||||
|
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
|
||||||
|
removeEvent: (id: number) => Promise<void>
|
||||||
|
saveCategories: (eventId: number, inputs: EventCategoryInput[]) => Promise<void>
|
||||||
|
saveChannels: (eventId: number, inputs: ScoreChannelInput[]) => Promise<void>
|
||||||
|
saveProfile: (input: ScoringProfileInput, id: number | null) => Promise<ScoringProfile>
|
||||||
|
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
|
||||||
|
removeAthlete: (id: number) => Promise<void>
|
||||||
|
importEntries: (file: File) => Promise<EntryImportResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CENTER_Y,
|
||||||
|
ROW_HEIGHT,
|
||||||
|
curvePath,
|
||||||
|
laneColor,
|
||||||
|
laneX,
|
||||||
|
type GraphRow,
|
||||||
|
} from './graph'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左侧时间轴:一条竖线代表一条泳道,圆点是修订节点。
|
||||||
|
* 只负责画线与选中态,行内容由父级通过默认插槽渲染。
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
rows: GraphRow[]
|
||||||
|
width: number
|
||||||
|
selectedId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ select: [id: number] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="revision-graph">
|
||||||
|
<button
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.revision.id"
|
||||||
|
class="graph-row"
|
||||||
|
:class="{ selected: row.revision.id === selectedId, branch: row.isBranch }"
|
||||||
|
type="button"
|
||||||
|
@click="emit('select', row.revision.id)"
|
||||||
|
>
|
||||||
|
<svg class="graph-svg" :width="width" :height="ROW_HEIGHT" :viewBox="`0 0 ${width} ${ROW_HEIGHT}`">
|
||||||
|
<line
|
||||||
|
v-for="(segment, index) in row.segments"
|
||||||
|
:key="`s-${index}`"
|
||||||
|
:x1="laneX(segment.lane)"
|
||||||
|
:x2="laneX(segment.lane)"
|
||||||
|
:y1="segment.from"
|
||||||
|
:y2="segment.to"
|
||||||
|
:stroke="laneColor(segment.lane)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-for="(curve, index) in row.curves"
|
||||||
|
:key="`c-${index}`"
|
||||||
|
:d="curvePath(curve.fromLane, curve.toLane)"
|
||||||
|
:stroke="laneColor(curve.fromLane)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
:cx="laneX(row.lane)"
|
||||||
|
:cy="CENTER_Y"
|
||||||
|
r="5.5"
|
||||||
|
:fill="laneColor(row.lane)"
|
||||||
|
class="graph-dot"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="graph-content">
|
||||||
|
<slot :row="row" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.revision-graph {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.graph-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
height: 64px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
color: var(--mx-t2);
|
||||||
|
transition: background var(--mx-d1) var(--mx-ease);
|
||||||
|
}
|
||||||
|
.graph-row:hover {
|
||||||
|
background: var(--mx-s-hover);
|
||||||
|
}
|
||||||
|
.graph-row.selected {
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
}
|
||||||
|
.graph-svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.graph-svg line,
|
||||||
|
.graph-svg path {
|
||||||
|
stroke-width: 2;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
.graph-dot {
|
||||||
|
stroke: var(--mx-bg);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.graph-row.branch .graph-dot {
|
||||||
|
stroke: var(--mx-amber);
|
||||||
|
}
|
||||||
|
.graph-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<number, number>()
|
||||||
|
const tips: Array<number | null> = []
|
||||||
|
|
||||||
|
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<number, number>()
|
||||||
|
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<number>([...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(' · ')
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<Revision[]> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<RevisionAction, string> = {
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(source: Record<string, unknown>, 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<string, unknown>, 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { formatDateTime } from '@/shared'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import MockBadge from './MockBadge.vue'
|
||||||
|
import { backupsApi, type BackupItem } from './systemApi'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备份(devdocs/08 第 6、8 节)。
|
||||||
|
*
|
||||||
|
* 自动备份每 5 分钟一次、发布前再备一次,滚动保留 20 份;
|
||||||
|
* 但服务端目前只提供「列出 + 立即备份」,没有恢复接口,
|
||||||
|
* 所以恢复只能按流程手工执行,页面把它写成可照做的步骤而不是假装有按钮。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const rows = ref<BackupItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
const usingMock = ref(false)
|
||||||
|
const backingUp = ref(false)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
const latest = computed<BackupItem | null>(() => rows.value[0] ?? null)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
const result = await backupsApi.list()
|
||||||
|
rows.value = result.data
|
||||||
|
usingMock.value = result.mock
|
||||||
|
} catch (caught) {
|
||||||
|
loadError.value = caught instanceof Error ? caught.message : '加载备份列表失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackup(): Promise<void> {
|
||||||
|
backingUp.value = true
|
||||||
|
try {
|
||||||
|
const result = await backupsApi.create()
|
||||||
|
usingMock.value = usingMock.value || result.mock
|
||||||
|
toast.success('已生成备份', result.data.name)
|
||||||
|
await load()
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('备份失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
backingUp.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="backup-grid">
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">备份列表</span>
|
||||||
|
<div class="head-actions">
|
||||||
|
<MockBadge v-if="usingMock" />
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
|
||||||
|
<MxIcon name="refresh" :size="14" /> 刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || backingUp"
|
||||||
|
@click="createBackup"
|
||||||
|
>
|
||||||
|
<MxIcon name="database" :size="14" /> {{ backingUp ? '备份中…' : '立即备份' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">只有管理员能查看与创建备份。</p>
|
||||||
|
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||||
|
<p v-if="latest !== null" class="latest">
|
||||||
|
最近一次备份:{{ formatDateTime(latest.createdAt) }}
|
||||||
|
<span class="muted">({{ latest.reason === 'manual' ? '手动' : '自动' }})</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在读取备份目录…</div>
|
||||||
|
|
||||||
|
<div v-else-if="rows.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">还没有备份</div>
|
||||||
|
<div class="mx-empty-desc">服务端每 5 分钟自动备份一次,也可以点「立即备份」马上生成一份</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>文件名</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th class="right">大小</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.name">
|
||||||
|
<td class="mono name">{{ row.name }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="row.reason === 'manual' ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||||
|
{{ row.reason === 'manual' ? '手动' : '自动' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="right mono">{{ formatSize(row.size) }}</td>
|
||||||
|
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head"><span class="mx-card-title">恢复说明</span></div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="notice">
|
||||||
|
备份文件存放在 <code class="mono">data/backups/</code>,滚动保留最近 20 份,不随 git 提交。
|
||||||
|
服务端暂未提供一键恢复接口,恢复需在停机状态下手工执行:
|
||||||
|
</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>
|
||||||
|
<strong>停服务</strong>
|
||||||
|
<span>先停掉后端进程(systemd:<code class="mono">systemctl stop scoreboard</code>),避免恢复过程中继续写入。</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>留存现场</strong>
|
||||||
|
<span>把当前的 <code class="mono">event.db</code> 改名备份,确认恢复失败还能退回。</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>替换数据库</strong>
|
||||||
|
<span>选一份备份文件复制为 <code class="mono">event.db</code>,同时删除 <code class="mono">event.db-wal</code> 与 <code class="mono">event.db-shm</code>。</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>重启并核对</strong>
|
||||||
|
<span>启动服务后检查积分榜与最近一次发布记录,确认数据时间点符合预期。</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p class="notice warn">
|
||||||
|
恢复会丢弃备份时间点之后的所有改动(自动备份间隔 5 分钟)。现场先改 IP 切换备机、再考虑恢复,
|
||||||
|
通常比原地恢复更快。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.latest {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.notice.warn {
|
||||||
|
color: var(--mx-amber);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.steps li {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
.steps strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--mx-text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.steps span {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.state-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.backup-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { EventSettings } from '@/shared'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import MockBadge from './MockBadge.vue'
|
||||||
|
import { settingsApi, type EventSettingsPatch } from './systemApi'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useEventStore } from '../../stores/event'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 赛事信息(GET/PUT /api/settings)。
|
||||||
|
*
|
||||||
|
* 数据源直接用 event store 的 bootstrap 结果:它已经带降级逻辑,
|
||||||
|
* 这里再单独拉一次只会让「示例数据」的标记前后不一致。
|
||||||
|
* 保存时提交完整子对象而不是字段级 patch,避免服务端把「没传」误判成「清空」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const data = useEventStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const form = ref<EventSettings | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const saveError = ref<string | null>(null)
|
||||||
|
const usingMock = ref(false)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
const dirty = computed(() => {
|
||||||
|
if (form.value === null || data.settings === null) return false
|
||||||
|
return JSON.stringify(form.value) !== JSON.stringify(data.settings)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => data.settings,
|
||||||
|
(value) => {
|
||||||
|
// 只在服务端数据变化时重置表单;本地编辑不被覆盖。
|
||||||
|
if (value !== null && (form.value === null || !dirty.value)) form.value = clone(value)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function clone(value: EventSettings): EventSettings {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as EventSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可选文本:空串在接口里等于「没有」,必须转成 null,否则大屏会显示空白标题。 */
|
||||||
|
function optional(value: string | null): string | null {
|
||||||
|
const trimmed = (value ?? '').trim()
|
||||||
|
return trimmed === '' ? null : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
if (data.settings !== null) form.value = clone(data.settings)
|
||||||
|
saveError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
const current = data.settings
|
||||||
|
const next = form.value
|
||||||
|
if (current === null || next === null) return
|
||||||
|
if (next.event.name.trim() === '') {
|
||||||
|
saveError.value = '赛事名称不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: EventSettingsPatch = {
|
||||||
|
event: {
|
||||||
|
...next.event,
|
||||||
|
name: next.event.name.trim(),
|
||||||
|
subtitle: optional(next.event.subtitle),
|
||||||
|
host: optional(next.event.host),
|
||||||
|
},
|
||||||
|
display: { ...next.display, title: next.display.title.trim(), subtitle: optional(next.display.subtitle) },
|
||||||
|
standings: { ...next.standings },
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
saveError.value = null
|
||||||
|
try {
|
||||||
|
const result = await settingsApi.save(patch, current)
|
||||||
|
usingMock.value = result.mock
|
||||||
|
data.settings = result.data
|
||||||
|
form.value = clone(result.data)
|
||||||
|
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
|
||||||
|
} catch (caught) {
|
||||||
|
saveError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">赛事信息</span>
|
||||||
|
<div class="head-actions">
|
||||||
|
<MockBadge v-if="usingMock || data.isMock" />
|
||||||
|
<span v-if="dirty" class="mx-badge mx-badge-warning">未保存</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="!dirty || saving" @click="reset">
|
||||||
|
放弃修改
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || !dirty || saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
<MxIcon name="check" :size="14" /> {{ saving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">只有管理员能修改赛事信息,当前账号为只读。</p>
|
||||||
|
<p v-if="saveError !== null" class="notice error">{{ saveError }}</p>
|
||||||
|
|
||||||
|
<div v-if="form === null" class="state-block"><span class="mx-activity-dot"></span> 正在加载赛事信息…</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<h3 class="group-title">基本信息</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="event-name">赛事名称</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="event-name" v-model="form.event.name" class="mx-input" placeholder="如:2026 年职工运动会" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="event-subtitle">副标题</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="event-subtitle" v-model="form.event.subtitle" class="mx-input" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="event-host">主办方</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="event-host" v-model="form.event.host" class="mx-input" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="group-title">大屏配置</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="display-title">大屏标题</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="display-title" v-model="form.display.title" class="mx-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="display-subtitle">大屏副标题</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="display-subtitle" v-model="form.display.subtitle" class="mx-input" placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="display-rotate">轮播间隔(秒)</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input
|
||||||
|
id="display-rotate"
|
||||||
|
v-model.number="form.display.rotateSeconds"
|
||||||
|
class="mx-input"
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
max="120"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="display-color">主题色</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input id="display-color" v-model="form.display.primaryColor" class="mx-input mono" placeholder="#3482FF" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field switch-field">
|
||||||
|
<span class="mx-label">个人积分榜上屏</span>
|
||||||
|
<span class="switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="form.standings.showIndividualBoard" type="checkbox" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="switch-text">{{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
修改大屏标题、轮播间隔或主题色会立即影响所有在线大屏,保存前请确认现场没有正在进行的颁奖或播报。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.switch-field {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.switch-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.state-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志的 before / after 对比。
|
||||||
|
*
|
||||||
|
* 库里存的是 JSON 文本,字段顺序和缩进都不稳定,逐行 diff 会满屏噪声;
|
||||||
|
* 所以先解析成「路径 → 值」再按路径比对,一行一个字段,改了什么一眼可见。
|
||||||
|
* 解析失败(历史脏数据)时退回原文对照,不能因为一条坏记录就整页报错。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ before: string | null; after: string | null }>()
|
||||||
|
|
||||||
|
type ChangeKind = 'added' | 'removed' | 'modified' | 'unchanged'
|
||||||
|
|
||||||
|
interface DiffRow {
|
||||||
|
path: string
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
change: ChangeKind
|
||||||
|
}
|
||||||
|
|
||||||
|
const showUnchanged = ref(false)
|
||||||
|
|
||||||
|
interface Parsed {
|
||||||
|
ok: boolean
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空文本按 null 处理(新增/删除操作的另一侧就是空的),只有非法 JSON 才算解析失败。 */
|
||||||
|
function parseJson(text: string | null): Parsed {
|
||||||
|
if (text === null || text.trim() === '') return { ok: true, value: null }
|
||||||
|
try {
|
||||||
|
return { ok: true, value: JSON.parse(text) as unknown }
|
||||||
|
} catch {
|
||||||
|
return { ok: false, value: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = computed(() => ({
|
||||||
|
before: parseJson(props.before),
|
||||||
|
after: parseJson(props.after),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const parseFailed = computed(() => !parsed.value.before.ok || !parsed.value.after.ok)
|
||||||
|
|
||||||
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLeaf(value: unknown): string {
|
||||||
|
if (value === null) return 'null'
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(value: unknown, prefix: string, out: Map<string, string>): void {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) {
|
||||||
|
out.set(prefix === '' ? '(根)' : prefix, '[]')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value.forEach((item, index) => flatten(item, `${prefix}[${index}]`, out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isPlainRecord(value)) {
|
||||||
|
const keys = Object.keys(value)
|
||||||
|
if (keys.length === 0) {
|
||||||
|
out.set(prefix === '' ? '(根)' : prefix, '{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
flatten(value[key], prefix === '' ? key : `${prefix}.${key}`, out)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.set(prefix === '' ? '(根)' : prefix, formatLeaf(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed<DiffRow[]>(() => {
|
||||||
|
const { before, after } = parsed.value
|
||||||
|
if (!before.ok || !after.ok) return []
|
||||||
|
|
||||||
|
const beforeMap = new Map<string, string>()
|
||||||
|
const afterMap = new Map<string, string>()
|
||||||
|
flatten(before.value, '', beforeMap)
|
||||||
|
flatten(after.value, '', afterMap)
|
||||||
|
|
||||||
|
const paths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((a, b) =>
|
||||||
|
a.localeCompare(b),
|
||||||
|
)
|
||||||
|
|
||||||
|
return paths.map((path) => {
|
||||||
|
const hasBefore = beforeMap.has(path)
|
||||||
|
const hasAfter = afterMap.has(path)
|
||||||
|
const beforeText = beforeMap.get(path) ?? '—'
|
||||||
|
const afterText = afterMap.get(path) ?? '—'
|
||||||
|
let change: ChangeKind = 'unchanged'
|
||||||
|
if (!hasBefore) change = 'added'
|
||||||
|
else if (!hasAfter) change = 'removed'
|
||||||
|
else if (beforeText !== afterText) change = 'modified'
|
||||||
|
return { path, before: beforeText, after: afterText, change }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleRows = computed(() =>
|
||||||
|
showUnchanged.value ? rows.value : rows.value.filter((row) => row.change !== 'unchanged'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const changedCount = computed(() => rows.value.filter((row) => row.change !== 'unchanged').length)
|
||||||
|
|
||||||
|
const CHANGE_LABEL: Record<ChangeKind, string> = {
|
||||||
|
added: '新增',
|
||||||
|
removed: '删除',
|
||||||
|
modified: '修改',
|
||||||
|
unchanged: '未变',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_TONE: Record<ChangeKind, string> = {
|
||||||
|
added: 'mx-badge-success',
|
||||||
|
removed: 'mx-badge-error',
|
||||||
|
modified: 'mx-badge-warning',
|
||||||
|
unchanged: 'mx-badge-neutral',
|
||||||
|
}
|
||||||
|
|
||||||
|
function pretty(text: string | null): string {
|
||||||
|
const result = parseJson(text)
|
||||||
|
if (!result.ok) return text ?? '—'
|
||||||
|
return JSON.stringify(result.value, null, 2)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="json-diff">
|
||||||
|
<div class="diff-head">
|
||||||
|
<span class="mx-badge" :class="changedCount > 0 ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||||
|
{{ changedCount > 0 ? `${changedCount} 处变化` : '无变化' }}
|
||||||
|
</span>
|
||||||
|
<label class="toggle">
|
||||||
|
<input v-model="showUnchanged" type="checkbox" />
|
||||||
|
显示未变化字段
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="parseFailed" class="fallback-note">
|
||||||
|
before / after 不是合法 JSON,下面按原文对照显示。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="parseFailed" class="raw-grid">
|
||||||
|
<div class="raw-col">
|
||||||
|
<div class="raw-title">变更前</div>
|
||||||
|
<pre class="raw mono">{{ before ?? '—' }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="raw-col">
|
||||||
|
<div class="raw-title">变更后</div>
|
||||||
|
<pre class="raw mono">{{ after ?? '—' }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="visibleRows.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">字段无变化</div>
|
||||||
|
<div class="mx-empty-desc">该操作只影响记录状态,未修改任何字段值</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>字段</th>
|
||||||
|
<th>变更前</th>
|
||||||
|
<th>变更后</th>
|
||||||
|
<th>类型</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in visibleRows" :key="row.path">
|
||||||
|
<td class="path mono">{{ row.path }}</td>
|
||||||
|
<td class="value mono before">{{ row.before }}</td>
|
||||||
|
<td class="value mono after">{{ row.after }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="CHANGE_TONE[row.change]">
|
||||||
|
{{ CHANGE_LABEL[row.change] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="raw-details">
|
||||||
|
<summary>查看原始 JSON</summary>
|
||||||
|
<div class="raw-grid">
|
||||||
|
<div class="raw-col">
|
||||||
|
<div class="raw-title">变更前</div>
|
||||||
|
<pre class="raw mono">{{ pretty(before) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="raw-col">
|
||||||
|
<div class="raw-title">变更后</div>
|
||||||
|
<pre class="raw mono">{{ pretty(after) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-diff {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--mx-s2);
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
}
|
||||||
|
.diff-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.before {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.after {
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
.fallback-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-amber);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.raw-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.raw-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.raw {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--mx-s3);
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.raw-details summary {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.raw-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 局部「示例数据」标记。
|
||||||
|
*
|
||||||
|
* 全局降级标记在 AdminLayout 的横幅里,但三个页面各面板的降级时机不同,
|
||||||
|
* 面板自带的标记才能说清「哪一块是假的」。
|
||||||
|
*/
|
||||||
|
withDefaults(defineProps<{ text?: string }>(), { text: '示例数据' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="mx-badge mx-badge-warning mock-badge" title="后端未就绪,本区域内容仅用于界面演示">
|
||||||
|
{{ text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mock-badge {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { formatDateTime } from '@/shared'
|
||||||
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import MockBadge from './MockBadge.vue'
|
||||||
|
import { USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理(devdocs/08 第 2 节)。
|
||||||
|
*
|
||||||
|
* 用户接口尚未进共享 ApiClient,走后端就绪时走 /api/users,未就绪时降级示例数据。
|
||||||
|
* 角色只提供文档里定义的三档,避免现场出现「看着是管理员、其实没权限」的自造角色。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const rows = ref<AdminUser[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
const usingMock = ref(false)
|
||||||
|
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const form = ref<AdminUserInput>({ username: '', displayName: '', role: 'recorder', active: true, password: '' })
|
||||||
|
const saving = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
const confirmDisable = ref(false)
|
||||||
|
|
||||||
|
const resetTarget = ref<AdminUser | null>(null)
|
||||||
|
const resetting = ref(false)
|
||||||
|
const revealed = ref<{ username: string; password: string } | null>(null)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
const editorTitle = computed(() => (editingId.value === null ? '新建用户' : `编辑 ${form.value.username}`))
|
||||||
|
const roleLabel = (role: string): string => USER_ROLES.find((item) => item.value === role)?.label ?? role
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
const result = await usersApi.list()
|
||||||
|
rows.value = result.data
|
||||||
|
usingMock.value = result.mock
|
||||||
|
} catch (caught) {
|
||||||
|
loadError.value = caught instanceof Error ? caught.message : '加载用户列表失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { username: '', displayName: '', role: 'recorder', active: true, password: '' }
|
||||||
|
formError.value = null
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminUser): void {
|
||||||
|
editingId.value = row.id
|
||||||
|
form.value = {
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.displayName,
|
||||||
|
role: row.role,
|
||||||
|
active: row.active,
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
formError.value = null
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (form.value.username.trim() === '') return '账号不能为空'
|
||||||
|
if (form.value.displayName.trim() === '') return '显示名不能为空'
|
||||||
|
if (editingId.value === null && form.value.password.length < 8) return '新建用户必须设置至少 8 位的初始密码'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
const message = validate()
|
||||||
|
if (message !== null) {
|
||||||
|
formError.value = message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 停用是危险操作:现有会话可能还在现场跑,必须二次确认。
|
||||||
|
const current = rows.value.find((row) => row.id === editingId.value)
|
||||||
|
if (current !== undefined && current.active && !form.value.active) {
|
||||||
|
confirmDisable.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await commitSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitSave(): Promise<void> {
|
||||||
|
confirmDisable.value = false
|
||||||
|
saving.value = true
|
||||||
|
formError.value = null
|
||||||
|
try {
|
||||||
|
if (editingId.value === null) {
|
||||||
|
const created = await usersApi.create({ ...form.value, username: form.value.username.trim() })
|
||||||
|
usingMock.value = usingMock.value || created.mock
|
||||||
|
toast.success(`已新建用户 ${created.data.username}`, usingMock.value ? '示例数据' : null)
|
||||||
|
} else {
|
||||||
|
const updated = await usersApi.update(editingId.value, {
|
||||||
|
displayName: form.value.displayName.trim(),
|
||||||
|
role: form.value.role,
|
||||||
|
active: form.value.active,
|
||||||
|
})
|
||||||
|
usingMock.value = usingMock.value || updated.mock
|
||||||
|
toast.success(`已更新 ${updated.data.username}`, usingMock.value ? '示例数据' : null)
|
||||||
|
}
|
||||||
|
editorOpen.value = false
|
||||||
|
await load()
|
||||||
|
} catch (caught) {
|
||||||
|
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(): Promise<void> {
|
||||||
|
const target = resetTarget.value
|
||||||
|
if (target === null) return
|
||||||
|
resetting.value = true
|
||||||
|
try {
|
||||||
|
const result = await usersApi.resetPassword(target.id)
|
||||||
|
usingMock.value = usingMock.value || result.mock
|
||||||
|
resetTarget.value = null
|
||||||
|
// 明文只在这一次响应里存在,关闭弹窗即丢弃,服务端也不会再返回。
|
||||||
|
revealed.value = { username: target.username, password: result.data.password }
|
||||||
|
await load()
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('重置密码失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
resetting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword(): Promise<void> {
|
||||||
|
const value = revealed.value?.password
|
||||||
|
if (value === undefined) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
toast.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
toast.warning('浏览器拒绝访问剪贴板', '请手动选中复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">用户管理</span>
|
||||||
|
<div class="head-actions">
|
||||||
|
<MockBadge v-if="usingMock" />
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="loading" @click="load">
|
||||||
|
<MxIcon name="refresh" :size="14" /> 刷新
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-primary mx-btn-sm" type="button" :disabled="!isAdmin" @click="openCreate">
|
||||||
|
<MxIcon name="user" :size="14" /> 新建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">只有管理员能管理用户,当前账号为只读。</p>
|
||||||
|
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-block">
|
||||||
|
<span class="mx-activity-dot"></span> 正在加载用户列表…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="rows.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">还没有用户</div>
|
||||||
|
<div class="mx-empty-desc">首次启动会自动生成管理员账号,也可以在这里新建录入员</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>账号</th>
|
||||||
|
<th>显示名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>最近登录</th>
|
||||||
|
<th class="right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td class="mono">
|
||||||
|
{{ row.username }}
|
||||||
|
<span v-if="row.mustChangePassword" class="mx-tag mx-tag-warning mx-tag-sm">待改密</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.displayName }}</td>
|
||||||
|
<td>{{ roleLabel(row.role) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="row.active ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
|
{{ row.active ? '启用' : '停用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="muted mono">{{ row.lastLoginAt === null ? '从未登录' : formatDateTime(row.lastLoginAt) }}</td>
|
||||||
|
<td class="right actions">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!isAdmin" @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin"
|
||||||
|
@click="resetTarget = row"
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editorOpen" class="editor">
|
||||||
|
<div class="editor-head">
|
||||||
|
<span class="mx-card-title">{{ editorTitle }}</span>
|
||||||
|
<button class="mx-modal-close" type="button" @click="editorOpen = false">
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="user-username">账号</label>
|
||||||
|
<input
|
||||||
|
id="user-username"
|
||||||
|
v-model="form.username"
|
||||||
|
class="mx-input"
|
||||||
|
:disabled="editingId !== null"
|
||||||
|
placeholder="登录名,创建后不可改"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="user-display">显示名</label>
|
||||||
|
<input id="user-display" v-model="form.displayName" class="mx-input" placeholder="界面与审计日志里显示的名字" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="user-role">角色</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select id="user-role" v-model="form.role" class="mx-input">
|
||||||
|
<option v-for="role in USER_ROLES" :key="role.value" :value="role.value">{{ role.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="editingId === null" class="field">
|
||||||
|
<label class="mx-label" for="user-password">初始密码</label>
|
||||||
|
<input
|
||||||
|
id="user-password"
|
||||||
|
v-model="form.password"
|
||||||
|
class="mx-input"
|
||||||
|
type="text"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少 8 位,首次登录强制修改"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field switch-field">
|
||||||
|
<span class="mx-label">启用</span>
|
||||||
|
<span class="switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="form.active" type="checkbox" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="switch-text">{{ form.active ? '可登录' : '停用后立即失效' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
|
||||||
|
|
||||||
|
<div class="editor-foot">
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="saving" @click="editorOpen = false">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="resetTarget !== null"
|
||||||
|
tone="danger"
|
||||||
|
title="重置该用户密码?"
|
||||||
|
:message="`将为 ${resetTarget?.username ?? ''} 生成一个新的随机密码,旧密码立即失效,该用户的所有登录会话同时作废。`"
|
||||||
|
confirm-text="重置密码"
|
||||||
|
:pending="resetting"
|
||||||
|
@confirm="resetPassword"
|
||||||
|
@cancel="resetTarget = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmDisable"
|
||||||
|
tone="danger"
|
||||||
|
title="停用该用户?"
|
||||||
|
:message="`停用后 ${form.username} 将无法登录,正在使用的会话也会失效。`"
|
||||||
|
confirm-text="停用"
|
||||||
|
:pending="saving"
|
||||||
|
@confirm="commitSave"
|
||||||
|
@cancel="confirmDisable = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mx-modal-overlay" :class="{ open: revealed !== null }" @click.self="revealed = null">
|
||||||
|
<div class="mx-modal mx-modal-sm" role="dialog" aria-modal="true">
|
||||||
|
<div class="mx-modal-head">
|
||||||
|
<span class="mx-modal-title">新密码(只显示一次)</span>
|
||||||
|
<button class="mx-modal-close" type="button" @click="revealed = null">
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-body">
|
||||||
|
<p class="reveal-hint">请立即抄送给 {{ revealed?.username }},关闭后无法再次查看。</p>
|
||||||
|
<div class="reveal-value mono">{{ revealed?.password }}</div>
|
||||||
|
<button class="mx-btn mx-btn-secondary full" type="button" @click="copyPassword">复制密码</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-foot">
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" @click="revealed = null">我已记下</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.state-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
.editor {
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--mx-s2);
|
||||||
|
}
|
||||||
|
.editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.switch-field {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.switch-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.editor-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.reveal-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.reveal-value {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<T> {
|
||||||
|
data: T
|
||||||
|
/** 本次调用是否真的降级到了示例数据,供各面板显示局部标记。 */
|
||||||
|
mock: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMock(reason: string): void {
|
||||||
|
mockMode.value = true
|
||||||
|
mockReason.value = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。 */
|
||||||
|
export async function withMockInfo<T>(
|
||||||
|
real: () => Promise<T>,
|
||||||
|
mock: () => T | Promise<T>,
|
||||||
|
): Promise<MockAware<T>> {
|
||||||
|
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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
|
||||||
|
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<string, QueryValue>
|
||||||
|
json?: unknown
|
||||||
|
body?: BodyInit
|
||||||
|
contentType?: string
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(source: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = source[key]
|
||||||
|
return typeof value === 'string' && value !== '' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(source: Record<string, unknown>, 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, QueryValue>): 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<Response> {
|
||||||
|
const method = options.method ?? 'GET'
|
||||||
|
const headers: Record<string, string> = { 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<unknown> {
|
||||||
|
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<T>(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<T>(path: string, options: RawOptions = {}): Promise<T> {
|
||||||
|
const response = await send(path, options)
|
||||||
|
const body = await readBody(response)
|
||||||
|
if (!response.ok) throw toApiError(body, response.status)
|
||||||
|
return unwrap<T>(body, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestVoid(path: string, options: RawOptions = {}): Promise<void> {
|
||||||
|
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<unknown>(body, response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestBlob(path: string, options: RawOptions = {}): Promise<Blob> {
|
||||||
|
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<string> {
|
||||||
|
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<AdminUserInput>): 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<MockAware<AdminUser[]>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>('/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<MockAware<AdminUser>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>('/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<AdminUserInput>): Promise<MockAware<AdminUser>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>(`/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<MockAware<{ password: string }>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>(`/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<MockAware<BackupItem[]>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>('/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<MockAware<BackupItem>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>('/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<ResultExportKind, string> = {
|
||||||
|
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<MockAware<DownloadFile>> {
|
||||||
|
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<MockAware<DownloadFile>> {
|
||||||
|
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<MockAware<ProjectImportResult>> {
|
||||||
|
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<unknown>('/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<MockAware<DownloadFile>> {
|
||||||
|
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<MockAware<RulePreview>> {
|
||||||
|
return withMockInfo(
|
||||||
|
() =>
|
||||||
|
requestJson<unknown>('/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<MockAware<RuleApplyResult>> {
|
||||||
|
return withMockInfo(
|
||||||
|
() =>
|
||||||
|
requestJson<unknown>('/rules/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: yaml,
|
||||||
|
contentType: 'application/yaml; charset=utf-8',
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
}).then(toRuleApplyResult),
|
||||||
|
() => ({ ...mockPreview(), recalculatedLedgerRows: 128 }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 赛事设置
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type EventSettingsPatch = Partial<EventSettings>
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
async save(patch: EventSettingsPatch, current: EventSettings): Promise<MockAware<EventSettings>> {
|
||||||
|
return withMockInfo(
|
||||||
|
async () => {
|
||||||
|
const body = await requestJson<unknown>('/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 }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
|
||||||
|
const SYSTEM_NAME = '运动会积分榜系统'
|
||||||
|
const SYSTEM_EN = 'Scoreboard'
|
||||||
|
// /api/health 只回 version,没有构建时间字段;构建期常量要改 vite.config,本页按约定显示 dev
|
||||||
|
const BUILD_TIME = 'dev'
|
||||||
|
|
||||||
|
const version = ref<string | null>(null)
|
||||||
|
const versionChecked = ref(false)
|
||||||
|
|
||||||
|
const versionText = computed(() => {
|
||||||
|
if (version.value !== null) return version.value
|
||||||
|
return versionChecked.value ? '未知(/api/health 不可达)' : '读取中…'
|
||||||
|
})
|
||||||
|
|
||||||
|
interface InfoItem {
|
||||||
|
name: string
|
||||||
|
usage: string
|
||||||
|
license: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack: InfoItem[] = [
|
||||||
|
{ name: 'Go 1.26', usage: '服务端、实时协议、静态资源内嵌', license: 'MIT(本项目)' },
|
||||||
|
{ name: 'Vue 3 + TypeScript', usage: '管理端与大屏前端', license: 'MIT' },
|
||||||
|
{ name: 'Vite', usage: '前端构建与开发服务', license: 'MIT' },
|
||||||
|
{ name: 'Pinia + Vue Router', usage: '管理端状态与路由', license: 'MIT' },
|
||||||
|
{ name: 'Electron', usage: 'Scoreboard Console 桌面宿主', license: 'MIT' },
|
||||||
|
{ name: 'MIUIx', usage: '管理端界面风格(vendor 内置,不走 CDN)', license: '随仓库分发的样式参考实现' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const fonts: InfoItem[] = [
|
||||||
|
{ name: 'Noto Sans SC', usage: '默认界面字体', license: 'OFL-1.1' },
|
||||||
|
{ name: 'Noto Serif SC', usage: '标题衬线字体', license: 'OFL-1.1' },
|
||||||
|
{ name: 'Source Han Sans CN(思源黑体)', usage: '备用无衬线字体', license: 'OFL-1.1' },
|
||||||
|
{
|
||||||
|
name: 'MiSans',
|
||||||
|
usage: '可选界面字体',
|
||||||
|
license: 'MiSans Font License',
|
||||||
|
note: 'MiSans 仅限内部赛事使用,不得对外分发',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ShortcutItem {
|
||||||
|
keys: string[]
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryShortcuts: ShortcutItem[] = [
|
||||||
|
{ keys: ['Tab'], desc: '跳到下一个单元格(浏览器原生焦点顺序)' },
|
||||||
|
{ keys: ['Enter'], desc: '在成绩格之间移动' },
|
||||||
|
{ keys: ['Ctrl', 'Enter'], desc: '保存草稿' },
|
||||||
|
{ keys: ['Alt', 'Enter'], desc: '发布当前项目成绩' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const displayShortcuts: ShortcutItem[] = [
|
||||||
|
{ keys: ['F11'], desc: '单屏窗口化模式下切换全屏;多屏 kiosk 不响应' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const eventName = computed(() => data.settings?.event.name.trim() || '未配置')
|
||||||
|
|
||||||
|
const port = computed(() => {
|
||||||
|
if (window.location.port !== '') return window.location.port
|
||||||
|
return window.location.protocol === 'https:' ? '443' : '80'
|
||||||
|
})
|
||||||
|
|
||||||
|
const origin = computed(() => window.location.origin)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本
|
||||||
|
try {
|
||||||
|
const info = await adminApi.client.health()
|
||||||
|
version.value = info.version
|
||||||
|
} catch {
|
||||||
|
version.value = null
|
||||||
|
} finally {
|
||||||
|
versionChecked.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.loaded) void data.load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="about-page">
|
||||||
|
<PageHeader title="关于" subtitle="系统信息、技术栈与部署参数" />
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">基本信息</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<dl class="kv">
|
||||||
|
<div>
|
||||||
|
<dt>系统名称</dt>
|
||||||
|
<dd>
|
||||||
|
{{ SYSTEM_NAME }}
|
||||||
|
<span class="en">{{ SYSTEM_EN }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>版本号</dt>
|
||||||
|
<dd class="mono">{{ versionText }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>构建时间</dt>
|
||||||
|
<dd class="mono">{{ BUILD_TIME }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>运行模式</dt>
|
||||||
|
<dd>{{ data.isMock ? '示例数据' : '真实服务端' }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">技术栈与开源许可</span>
|
||||||
|
<span class="mx-badge mx-badge-neutral">MIT · OFL-1.1</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body no-pad">
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>组件</th>
|
||||||
|
<th>用途</th>
|
||||||
|
<th>许可</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in stack" :key="item.name">
|
||||||
|
<td class="name">{{ item.name }}</td>
|
||||||
|
<td class="muted">{{ item.usage }}</td>
|
||||||
|
<td>{{ item.license }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">字体清单</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body no-pad">
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>字体</th>
|
||||||
|
<th>用途</th>
|
||||||
|
<th>授权</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="font in fonts" :key="font.name">
|
||||||
|
<td class="name">{{ font.name }}</td>
|
||||||
|
<td class="muted">{{ font.usage }}</td>
|
||||||
|
<td>
|
||||||
|
{{ font.license }}
|
||||||
|
<span v-if="font.note" class="warn-note">{{ font.note }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">键盘快捷键</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body no-pad">
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>位置</th>
|
||||||
|
<th>按键</th>
|
||||||
|
<th>作用</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in entryShortcuts" :key="`entry-${item.desc}`">
|
||||||
|
<td class="name">成绩录入</td>
|
||||||
|
<td class="keys">
|
||||||
|
<template v-for="(key, index) in item.keys" :key="key">
|
||||||
|
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ item.desc }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in displayShortcuts" :key="`display-${item.desc}`">
|
||||||
|
<td class="name">大屏</td>
|
||||||
|
<td class="keys">
|
||||||
|
<template v-for="(key, index) in item.keys" :key="key">
|
||||||
|
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ item.desc }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">部署信息</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<dl class="kv">
|
||||||
|
<div>
|
||||||
|
<dt>数据目录</dt>
|
||||||
|
<dd class="mono">— <span class="muted">(服务端未暴露该接口)</span></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>服务端口</dt>
|
||||||
|
<dd class="mono">{{ port }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>访问地址</dt>
|
||||||
|
<dd class="mono">{{ origin }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>赛事项目名</dt>
|
||||||
|
<dd>{{ eventName }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="foot-hint">
|
||||||
|
本系统为内部赛事使用;MiSans 字体授权仅限内部赛事,对外分发前请替换为 OFL-1.1 字体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px 24px;
|
||||||
|
}
|
||||||
|
.kv > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.kv dt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.kv dd {
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.en {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.no-pad {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.warn-note {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-amber, #fbbf24);
|
||||||
|
}
|
||||||
|
.keys {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plus {
|
||||||
|
margin: 0 3px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--mx-s3);
|
||||||
|
font-family: var(--mx-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.foot-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
formatDateTime,
|
||||||
|
formatPoints,
|
||||||
|
type AdjustmentCategory,
|
||||||
|
type PointAdjustment,
|
||||||
|
type RulePack,
|
||||||
|
} from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
const CATEGORIES: Array<{ value: AdjustmentCategory; label: string }> = [
|
||||||
|
{ value: 'penalty', label: '处罚' },
|
||||||
|
{ value: 'bonus', label: '补分' },
|
||||||
|
{ value: 'correction', label: '更正' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const serverItems = ref<PointAdjustment[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const size = 20
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const rulePack = ref<RulePack | null>(null)
|
||||||
|
const ruleError = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const categoryFilter = ref<AdjustmentCategory | 'all'>('all')
|
||||||
|
|
||||||
|
const createOpen = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const formTeamId = ref<number | null>(null)
|
||||||
|
const formDelta = ref('')
|
||||||
|
const formCategory = ref<AdjustmentCategory>('penalty')
|
||||||
|
const formReason = ref('')
|
||||||
|
const formEventId = ref<number | null>(null)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const voidTarget = ref<PointAdjustment | null>(null)
|
||||||
|
const voidReason = ref('')
|
||||||
|
const voiding = ref(false)
|
||||||
|
|
||||||
|
/** 服务端未就绪时的本地记录:只用于演示交互,界面必须标注。 */
|
||||||
|
const localItems = ref<PointAdjustment[]>([])
|
||||||
|
let nextLocalId = -1
|
||||||
|
|
||||||
|
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||||
|
const adjustmentEnabled = computed(() => rulePack.value?.manualAdjustment.enabled ?? false)
|
||||||
|
|
||||||
|
const items = computed(() => [...localItems.value, ...serverItems.value])
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const keyword = search.value.trim()
|
||||||
|
return items.value.filter((item) => {
|
||||||
|
if (categoryFilter.value !== 'all' && item.category !== categoryFilter.value) return false
|
||||||
|
if (keyword === '') return true
|
||||||
|
return teamLabel(item.teamId).includes(keyword) || item.reason.includes(keyword)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
|
||||||
|
const rangeText = computed(() => {
|
||||||
|
if (total.value === 0) return '共 0 条'
|
||||||
|
const from = (page.value - 1) * size + 1
|
||||||
|
const to = Math.min(page.value * size, total.value)
|
||||||
|
return `第 ${from}–${to} 条,共 ${total.value} 条`
|
||||||
|
})
|
||||||
|
|
||||||
|
function teamLabel(teamId: number): string {
|
||||||
|
const team = data.teamById(teamId)
|
||||||
|
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLabel(eventId: number | null): string {
|
||||||
|
if (eventId === null) return '—'
|
||||||
|
return data.eventById(eventId)?.name ?? `项目 ${eventId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabel(category: AdjustmentCategory): string {
|
||||||
|
return CATEGORIES.find((item) => item.value === category)?.label ?? category
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryTone(category: AdjustmentCategory): string {
|
||||||
|
if (category === 'penalty') return 'mx-tag-error'
|
||||||
|
if (category === 'bonus') return 'mx-tag-success'
|
||||||
|
return 'mx-tag-info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocal(item: PointAdjustment): boolean {
|
||||||
|
return item.id < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFallback(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules(): Promise<void> {
|
||||||
|
try {
|
||||||
|
rulePack.value = await adminApi.client.rules.get()
|
||||||
|
ruleError.value = null
|
||||||
|
} catch (caught) {
|
||||||
|
rulePack.value = null
|
||||||
|
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(pageNumber = page.value): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const result = await adminApi.adjustments({ page: pageNumber, size })
|
||||||
|
serverItems.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
page.value = result.page
|
||||||
|
} catch (caught) {
|
||||||
|
error.value = caught instanceof Error ? caught.message : '加载调整记录失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
if (!adjustmentEnabled.value) {
|
||||||
|
toast.error('本期人工调整已关闭', '规则包 manualAdjustment.enabled = false')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formTeamId.value = data.teams[0]?.id ?? null
|
||||||
|
formDelta.value = ''
|
||||||
|
formCategory.value = 'penalty'
|
||||||
|
formReason.value = ''
|
||||||
|
formEventId.value = null
|
||||||
|
formError.value = null
|
||||||
|
createOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(): string | null {
|
||||||
|
if (formTeamId.value === null) return '请选择单位'
|
||||||
|
const delta = Number(formDelta.value)
|
||||||
|
if (formDelta.value.trim() === '' || !Number.isFinite(delta)) return '请填写分值'
|
||||||
|
if (delta === 0) return '分值不能为 0'
|
||||||
|
if (formReason.value.trim() === '') return '理由必填'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate(): Promise<void> {
|
||||||
|
const invalid = validate()
|
||||||
|
if (invalid !== null) {
|
||||||
|
formError.value = invalid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const teamId = formTeamId.value
|
||||||
|
if (teamId === null) return
|
||||||
|
creating.value = true
|
||||||
|
formError.value = null
|
||||||
|
const input = {
|
||||||
|
teamId,
|
||||||
|
delta: Number(formDelta.value),
|
||||||
|
category: formCategory.value,
|
||||||
|
reason: formReason.value.trim(),
|
||||||
|
eventId: formEventId.value,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adminApi.client.adjustments.create(input)
|
||||||
|
createOpen.value = false
|
||||||
|
toast.success('已记录人工调整', `${teamLabel(teamId)} ${input.delta > 0 ? '+' : ''}${formatPoints(input.delta)} 分`)
|
||||||
|
await Promise.all([load(1), data.refreshStandings()])
|
||||||
|
} catch (caught) {
|
||||||
|
if (isFallback(caught)) {
|
||||||
|
localItems.value = [
|
||||||
|
{
|
||||||
|
id: (nextLocalId -= 1),
|
||||||
|
teamId,
|
||||||
|
delta: input.delta,
|
||||||
|
category: input.category,
|
||||||
|
reason: input.reason,
|
||||||
|
eventId: input.eventId,
|
||||||
|
voided: false,
|
||||||
|
createdBy: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
...localItems.value,
|
||||||
|
]
|
||||||
|
createOpen.value = false
|
||||||
|
toast.warning('已本地记录(示例数据)', '服务端未就绪,调整未真正写入')
|
||||||
|
} else {
|
||||||
|
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmVoid(): Promise<void> {
|
||||||
|
const item = voidTarget.value
|
||||||
|
if (item === null) return
|
||||||
|
const reason = voidReason.value.trim()
|
||||||
|
if (reason === '') {
|
||||||
|
toast.error('请填写作废理由')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
voiding.value = true
|
||||||
|
try {
|
||||||
|
if (isLocal(item)) {
|
||||||
|
localItems.value = localItems.value.map((entry) =>
|
||||||
|
entry.id === item.id ? { ...entry, voided: true } : entry,
|
||||||
|
)
|
||||||
|
toast.info('已本地作废', '示例数据模式,未写入服务端')
|
||||||
|
} else {
|
||||||
|
await adminApi.client.adjustments.void(item.id, reason)
|
||||||
|
toast.warning('已作废该条调整', '对应积分流水一并作废,全程留审计')
|
||||||
|
await Promise.all([load(), data.refreshStandings()])
|
||||||
|
}
|
||||||
|
voidTarget.value = null
|
||||||
|
voidReason.value = ''
|
||||||
|
} catch (caught) {
|
||||||
|
if (isFallback(caught)) {
|
||||||
|
toast.error('服务端未就绪', '无法作废已保存的调整,请稍后重试')
|
||||||
|
} else {
|
||||||
|
toast.error('作废失败', caught instanceof Error ? caught.message : null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
voiding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(delta: number): void {
|
||||||
|
const next = page.value + delta
|
||||||
|
if (next < 1 || next > pageCount.value) return
|
||||||
|
void load(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadRules()
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="adjustments-page">
|
||||||
|
<PageHeader title="积分调整" subtitle="人工加减分,分值可正可负、理由必填、可作废、全程审计">
|
||||||
|
<template #actions>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load()">
|
||||||
|
<MxIcon name="refresh" :size="15" /> 刷新
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" :disabled="!adjustmentEnabled || !canWrite" @click="openCreate">
|
||||||
|
<MxIcon name="adjust" :size="15" /> 新增调整
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="mx-card notice-card">
|
||||||
|
<div class="mx-card-body notice-body">
|
||||||
|
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="notice-title">
|
||||||
|
{{ adjustmentEnabled ? '人工调整已启用' : '本期人工调整已关闭' }}
|
||||||
|
<span class="mx-badge" :class="adjustmentEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||||
|
manualAdjustment.enabled = {{ adjustmentEnabled ? 'true' : 'false' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="notice-desc">
|
||||||
|
<template v-if="adjustmentEnabled">
|
||||||
|
分值可正可负,类别为处罚 / 补分 / 更正,理由必填;作废会同时作废对应积分流水。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
本次运动会扣分 / 处罚完全在系统外处理,系统只负责成绩分(02 第 4 节)。
|
||||||
|
功能与数据表已保留,以后赛事开一个开关即可启用。
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p v-if="ruleError !== null" class="notice-error">
|
||||||
|
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mx-card mx-section">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">调整记录</span>
|
||||||
|
<div class="head-tools">
|
||||||
|
<div class="mx-tabs">
|
||||||
|
<button class="mx-tab" :class="{ active: categoryFilter === 'all' }" type="button" @click="categoryFilter = 'all'">全部</button>
|
||||||
|
<button
|
||||||
|
v-for="item in CATEGORIES"
|
||||||
|
:key="item.value"
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: categoryFilter === item.value }"
|
||||||
|
type="button"
|
||||||
|
@click="categoryFilter = item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-input-wrap search">
|
||||||
|
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||||
|
<input v-model="search" class="mx-input" placeholder="搜索单位 / 理由" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div v-if="loading" class="mx-empty">
|
||||||
|
<div class="mx-spinner mx-spinner-sm"></div>
|
||||||
|
<div class="mx-empty-title">正在加载调整记录…</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error !== null" class="error-banner">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load()">重试</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filteredItems.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="adjust" :size="26" /></div>
|
||||||
|
<div class="mx-empty-title">没有调整记录</div>
|
||||||
|
<div class="mx-empty-desc">
|
||||||
|
{{ adjustmentEnabled ? '点击「新增调整」录入第一条' : '本期人工调整关闭,故为空' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>单位</th>
|
||||||
|
<th class="right">分值</th>
|
||||||
|
<th>类别</th>
|
||||||
|
<th>理由</th>
|
||||||
|
<th>关联项目</th>
|
||||||
|
<th class="center">状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th class="right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in filteredItems" :key="item.id" :class="{ voided: item.voided }">
|
||||||
|
<td>
|
||||||
|
<span class="team-name">{{ teamLabel(item.teamId) }}</span>
|
||||||
|
<span v-if="isLocal(item)" class="mx-tag mx-tag-warning local-tag">本地</span>
|
||||||
|
</td>
|
||||||
|
<td class="right mono delta" :class="item.delta > 0 ? 'up' : 'down'">
|
||||||
|
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
|
||||||
|
</td>
|
||||||
|
<td><span class="mx-tag" :class="categoryTone(item.category)">{{ categoryLabel(item.category) }}</span></td>
|
||||||
|
<td class="reason">{{ item.reason }}</td>
|
||||||
|
<td>{{ eventLabel(item.eventId) }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<span v-if="item.voided" class="mx-badge mx-badge-neutral">已作废</span>
|
||||||
|
<span v-else class="mx-badge mx-badge-success">有效</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono time">{{ formatDateTime(item.createdAt) }}</td>
|
||||||
|
<td class="right">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="item.voided || !canWrite"
|
||||||
|
@click="voidTarget = item; voidReason = ''"
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<span class="pager-info">{{ rangeText }}</span>
|
||||||
|
<div class="pager-actions">
|
||||||
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="page <= 1" @click="changePage(-1)">上一页</button>
|
||||||
|
<span class="pager-page mono">{{ page }} / {{ pageCount }}</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="page >= pageCount" @click="changePage(1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 新增调整 -->
|
||||||
|
<div class="mx-modal-overlay" :class="{ open: createOpen }" @click.self="createOpen = false">
|
||||||
|
<div class="mx-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="mx-modal-head">
|
||||||
|
<span class="mx-modal-title">新增人工调整</span>
|
||||||
|
<button class="mx-modal-close" type="button" @click="createOpen = false">
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-body">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span class="mx-label">单位 *</span>
|
||||||
|
<select v-model.number="formTeamId" class="mx-cell-input">
|
||||||
|
<option :value="null" disabled>请选择单位</option>
|
||||||
|
<option v-for="team in data.teams" :key="team.id" :value="team.id">{{ team.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="mx-label">分值 *</span>
|
||||||
|
<input v-model="formDelta" class="mx-cell-input mono" type="number" step="0.5" placeholder="正数加分 / 负数扣分" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="mx-label">类别 *</span>
|
||||||
|
<select v-model="formCategory" class="mx-cell-input">
|
||||||
|
<option v-for="item in CATEGORIES" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="mx-label">关联项目(可选)</span>
|
||||||
|
<select v-model.number="formEventId" class="mx-cell-input">
|
||||||
|
<option :value="null">不关联</option>
|
||||||
|
<option v-for="event in data.events" :key="event.id" :value="event.id">{{ event.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field field-full">
|
||||||
|
<span class="mx-label">理由 *</span>
|
||||||
|
<textarea v-model="formReason" class="mx-textarea" rows="3" placeholder="必填,会写入审计日志"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="formError !== null" class="form-error">{{ formError }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-foot">
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="creating" @click="createOpen = false">取消</button>
|
||||||
|
<button class="mx-btn mx-btn-primary" type="button" :disabled="creating" @click="submitCreate">
|
||||||
|
{{ creating ? '保存中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 作废 -->
|
||||||
|
<div class="mx-modal-overlay" :class="{ open: voidTarget !== null }" @click.self="voidTarget = null">
|
||||||
|
<div class="mx-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="mx-modal-head">
|
||||||
|
<span class="mx-modal-title">作废该条调整?</span>
|
||||||
|
<button class="mx-modal-close" type="button" @click="voidTarget = null">
|
||||||
|
<MxIcon name="close" :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-body">
|
||||||
|
<p class="mx-modal-confirm-text">
|
||||||
|
将作废 {{ voidTarget === null ? '' : teamLabel(voidTarget.teamId) }} 的
|
||||||
|
{{ voidTarget === null ? '' : formatPoints(voidTarget.delta) }} 分调整,对应积分流水一并作废。
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span class="mx-label">作废理由 *</span>
|
||||||
|
<textarea v-model="voidReason" class="mx-textarea" rows="3" placeholder="必填,会写入审计日志"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mx-modal-foot">
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="voiding" @click="voidTarget = null">取消</button>
|
||||||
|
<button class="mx-btn mx-btn-danger" type="button" :disabled="voiding" @click="confirmVoid">
|
||||||
|
{{ voiding ? '处理中…' : '确认作废' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notice-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.notice-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.notice-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
color: var(--mx-amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notice-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.notice-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.notice-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.team-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.local-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.delta {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.delta.up {
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
.delta.down {
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
max-width: 260px;
|
||||||
|
white-space: normal;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
tr.voided td {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pager-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.pager-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pager-page {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
color: var(--mx-red);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatDateTime } from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import MockBadge from '../components/system/MockBadge.vue'
|
||||||
|
import type { IconName } from '../components/icons'
|
||||||
|
import {
|
||||||
|
projectApi,
|
||||||
|
resultsExportApi,
|
||||||
|
rulesApi,
|
||||||
|
type ProjectImportResult,
|
||||||
|
type ResultExportKind,
|
||||||
|
type RuleApplyResult,
|
||||||
|
type RulePreview,
|
||||||
|
} from '../components/system/systemApi'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出与赛事包(devdocs/07 第 5 节)。
|
||||||
|
*
|
||||||
|
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
|
||||||
|
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TabKey = 'results' | 'project' | 'rules'
|
||||||
|
|
||||||
|
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||||
|
{ key: 'results', label: '结果导出' },
|
||||||
|
{ key: 'project', label: '赛事项目包' },
|
||||||
|
{ key: 'rules', label: '规则包' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ExportOption {
|
||||||
|
key: ResultExportKind
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
icon: IconName
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
|
||||||
|
{
|
||||||
|
key: 'standings',
|
||||||
|
title: '总表',
|
||||||
|
desc: '总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿',
|
||||||
|
icon: 'standings',
|
||||||
|
},
|
||||||
|
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
|
||||||
|
{ key: 'event', title: '分项成绩表', desc: '按小项导出成绩与积分,可单独给项目裁判', icon: 'export' },
|
||||||
|
{ key: 'team', title: '单位积分明细', desc: '按单位逐项列出得分来源,可解释每一分', icon: 'user' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const activeTab = ref<TabKey>('results')
|
||||||
|
|
||||||
|
// ── 结果导出 ────────────────────────────────────────────────────────────────
|
||||||
|
const pendingExport = ref<ResultExportKind | null>(null)
|
||||||
|
const resultsMock = ref(false)
|
||||||
|
const selectedEventId = ref<number | null>(null)
|
||||||
|
const selectedTeamId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const eventOptions = computed(() =>
|
||||||
|
data.events.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
label: `${data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'} / ${event.name}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── 赛事项目包 ──────────────────────────────────────────────────────────────
|
||||||
|
const projectFile = ref<File | null>(null)
|
||||||
|
const projectOverwrite = ref(false)
|
||||||
|
const projectRename = ref('')
|
||||||
|
const projectFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const projectImporting = ref(false)
|
||||||
|
const projectExporting = ref(false)
|
||||||
|
const projectMock = ref(false)
|
||||||
|
const confirmProjectImport = ref(false)
|
||||||
|
const projectResult = ref<ProjectImportResult | null>(null)
|
||||||
|
const projectError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── 规则包 ──────────────────────────────────────────────────────────────────
|
||||||
|
const rulesFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const rulesFileName = ref('')
|
||||||
|
const rulesYaml = ref('')
|
||||||
|
const rulesExporting = ref(false)
|
||||||
|
const rulesPreviewing = ref(false)
|
||||||
|
const rulesApplying = ref(false)
|
||||||
|
const rulesMock = ref(false)
|
||||||
|
const rulesError = ref<string | null>(null)
|
||||||
|
const rulesPreview = ref<RulePreview | null>(null)
|
||||||
|
const rulesApplyResult = ref<RuleApplyResult | null>(null)
|
||||||
|
const confirmRulesApply = ref(false)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
|
||||||
|
const CHANGE_LABEL: Record<string, string> = {
|
||||||
|
added: '新增',
|
||||||
|
removed: '删除',
|
||||||
|
modified: '修改',
|
||||||
|
unchanged: '未变',
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTone(change: string): string {
|
||||||
|
if (change === 'added') return 'mx-badge-success'
|
||||||
|
if (change === 'removed') return 'mx-badge-error'
|
||||||
|
if (change === 'modified') return 'mx-badge-warning'
|
||||||
|
return 'mx-badge-neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(kind: ResultExportKind): Promise<void> {
|
||||||
|
const request =
|
||||||
|
kind === 'event'
|
||||||
|
? { kind, eventId: selectedEventId.value ?? undefined }
|
||||||
|
: kind === 'team'
|
||||||
|
? { kind, teamId: selectedTeamId.value ?? undefined }
|
||||||
|
: { kind }
|
||||||
|
if (kind === 'event' && selectedEventId.value === null) {
|
||||||
|
toast.warning('请先选择要导出的小项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (kind === 'team' && selectedTeamId.value === null) {
|
||||||
|
toast.warning('请先选择要导出的单位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingExport.value = kind
|
||||||
|
try {
|
||||||
|
const result = await resultsExportApi.download(request)
|
||||||
|
resultsMock.value = resultsMock.value || result.mock
|
||||||
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
|
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('导出失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
pendingExport.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportProject(): Promise<void> {
|
||||||
|
projectExporting.value = true
|
||||||
|
projectError.value = null
|
||||||
|
try {
|
||||||
|
const result = await projectApi.exportZip()
|
||||||
|
projectMock.value = projectMock.value || result.mock
|
||||||
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
|
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
|
||||||
|
} catch (caught) {
|
||||||
|
projectError.value = caught instanceof Error ? caught.message : '导出失败'
|
||||||
|
} finally {
|
||||||
|
projectExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickProjectFile(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0] ?? null
|
||||||
|
projectResult.value = null
|
||||||
|
projectError.value = null
|
||||||
|
if (file === null) {
|
||||||
|
projectFile.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/\.zip$/i.test(file.name)) {
|
||||||
|
projectError.value = '请选择 .zip 格式的赛事项目包'
|
||||||
|
projectFile.value = null
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projectFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestProjectImport(): void {
|
||||||
|
if (projectFile.value === null) {
|
||||||
|
projectError.value = '请先选择要导入的 .zip 包'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 覆盖会替换掉同名赛事目录,属于不可逆操作,必须先确认。
|
||||||
|
if (projectOverwrite.value) {
|
||||||
|
confirmProjectImport.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void runProjectImport()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProjectImport(): Promise<void> {
|
||||||
|
const file = projectFile.value
|
||||||
|
if (file === null) return
|
||||||
|
confirmProjectImport.value = false
|
||||||
|
projectImporting.value = true
|
||||||
|
projectError.value = null
|
||||||
|
try {
|
||||||
|
const result = await projectApi.importZip(file, {
|
||||||
|
overwrite: projectOverwrite.value,
|
||||||
|
rename: projectRename.value,
|
||||||
|
})
|
||||||
|
projectMock.value = projectMock.value || result.mock
|
||||||
|
projectResult.value = result.data
|
||||||
|
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
|
||||||
|
projectFile.value = null
|
||||||
|
if (projectFileInput.value !== null) projectFileInput.value.value = ''
|
||||||
|
} catch (caught) {
|
||||||
|
projectError.value = caught instanceof Error ? caught.message : '导入失败'
|
||||||
|
} finally {
|
||||||
|
projectImporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRules(): Promise<void> {
|
||||||
|
rulesExporting.value = true
|
||||||
|
rulesError.value = null
|
||||||
|
try {
|
||||||
|
const result = await rulesApi.exportYaml()
|
||||||
|
rulesMock.value = rulesMock.value || result.mock
|
||||||
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
|
toast.success('规则包已导出', result.data.filename)
|
||||||
|
} catch (caught) {
|
||||||
|
rulesError.value = caught instanceof Error ? caught.message : '导出失败'
|
||||||
|
} finally {
|
||||||
|
rulesExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickRulesFile(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0] ?? null
|
||||||
|
rulesPreview.value = null
|
||||||
|
rulesApplyResult.value = null
|
||||||
|
rulesError.value = null
|
||||||
|
if (file === null) {
|
||||||
|
rulesFileName.value = ''
|
||||||
|
rulesYaml.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/\.(ya?ml)$/i.test(file.name)) {
|
||||||
|
rulesError.value = '规则包必须是 .yaml / .yml 文件'
|
||||||
|
rulesFileName.value = ''
|
||||||
|
rulesYaml.value = ''
|
||||||
|
input.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rulesFileName.value = file.name
|
||||||
|
try {
|
||||||
|
rulesYaml.value = await file.text()
|
||||||
|
} catch {
|
||||||
|
rulesError.value = '读取文件失败,请重试'
|
||||||
|
rulesYaml.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewRules(): Promise<void> {
|
||||||
|
if (rulesYaml.value.trim() === '') {
|
||||||
|
rulesError.value = '请先选择规则包文件'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rulesPreviewing.value = true
|
||||||
|
rulesError.value = null
|
||||||
|
rulesApplyResult.value = null
|
||||||
|
try {
|
||||||
|
const result = await rulesApi.preview(rulesYaml.value)
|
||||||
|
rulesMock.value = rulesMock.value || result.mock
|
||||||
|
rulesPreview.value = result.data
|
||||||
|
} catch (caught) {
|
||||||
|
rulesPreview.value = null
|
||||||
|
rulesError.value = caught instanceof Error ? caught.message : '预览失败'
|
||||||
|
} finally {
|
||||||
|
rulesPreviewing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRulesApply(): void {
|
||||||
|
if (rulesPreview.value === null) return
|
||||||
|
confirmRulesApply.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRules(): Promise<void> {
|
||||||
|
confirmRulesApply.value = false
|
||||||
|
rulesApplying.value = true
|
||||||
|
rulesError.value = null
|
||||||
|
try {
|
||||||
|
const result = await rulesApi.apply(rulesYaml.value)
|
||||||
|
rulesMock.value = rulesMock.value || result.mock
|
||||||
|
rulesApplyResult.value = result.data
|
||||||
|
rulesPreview.value = result.data
|
||||||
|
toast.success(
|
||||||
|
'规则包已导入',
|
||||||
|
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
|
||||||
|
)
|
||||||
|
} catch (caught) {
|
||||||
|
rulesError.value = caught instanceof Error ? caught.message : '导入失败'
|
||||||
|
} finally {
|
||||||
|
rulesApplying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader title="导出" subtitle="结果导出 / 赛事项目包 / 规则包(devdocs/07 第 5 节)">
|
||||||
|
<template #actions>
|
||||||
|
<MockBadge v-if="data.isMock" />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="mx-tabs export-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.key"
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
type="button"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果导出 -->
|
||||||
|
<section v-if="activeTab === 'results'" class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">结果导出(xlsx)</span>
|
||||||
|
<MockBadge v-if="resultsMock" />
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="hint">
|
||||||
|
表头、合并单元格与字体都已按打印模板预设,导出即可直接打印。总表与全部导出由服务端合成一个多 sheet 工作簿。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="export-grid">
|
||||||
|
<article v-for="option in EXPORT_OPTIONS" :key="option.key" class="export-item">
|
||||||
|
<div class="export-item-head">
|
||||||
|
<span class="export-icon"><MxIcon :name="option.icon" :size="18" /></span>
|
||||||
|
<strong>{{ option.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<p class="export-desc">{{ option.desc }}</p>
|
||||||
|
|
||||||
|
<div v-if="option.key === 'event'" class="mx-input-wrap">
|
||||||
|
<select v-model="selectedEventId" class="mx-input">
|
||||||
|
<option :value="null" disabled>选择小项…</option>
|
||||||
|
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="option.key === 'team'" class="mx-input-wrap">
|
||||||
|
<select v-model="selectedTeamId" class="mx-input">
|
||||||
|
<option :value="null" disabled>选择单位…</option>
|
||||||
|
<option v-for="team in data.teams" :key="team.id" :value="team.id">
|
||||||
|
{{ team.name }}({{ team.code }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="pendingExport !== null"
|
||||||
|
@click="download(option.key)"
|
||||||
|
>
|
||||||
|
<MxIcon name="export" :size="15" />
|
||||||
|
{{ pendingExport === option.key ? '导出中…' : '下载' }}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.events.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">还没有小项</div>
|
||||||
|
<div class="mx-empty-desc">先在基础数据里建立大项 / 小项,分项成绩表才有内容可导</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 赛事项目包 -->
|
||||||
|
<div v-else-if="activeTab === 'project'" class="project-grid">
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">导出项目包</span>
|
||||||
|
<MockBadge v-if="projectMock" />
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="hint">
|
||||||
|
包内含 <code class="mono">meta.yaml</code> + <code class="mono">event.db</code> +
|
||||||
|
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。
|
||||||
|
整包拷走即可在另一台机器复现整场赛事。
|
||||||
|
</p>
|
||||||
|
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @click="exportProject">
|
||||||
|
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head"><span class="mx-card-title">导入项目包</span></div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">导入赛事包会替换整库数据,只有管理员可操作。</p>
|
||||||
|
|
||||||
|
<div class="file-row">
|
||||||
|
<input
|
||||||
|
ref="projectFileInput"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
@change="pickProjectFile"
|
||||||
|
/>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" @click="projectFileInput?.click()">
|
||||||
|
<MxIcon name="database" :size="15" /> 选择 .zip
|
||||||
|
</button>
|
||||||
|
<span class="file-name mono">{{ projectFile?.name ?? '未选择文件' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label class="mx-label" for="project-rename">重命名(可选)</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<input
|
||||||
|
id="project-rename"
|
||||||
|
v-model="projectRename"
|
||||||
|
class="mx-input"
|
||||||
|
placeholder="留空则用包内项目名"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field switch-field">
|
||||||
|
<span class="mx-label">覆盖同名项目</span>
|
||||||
|
<span class="switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="projectOverwrite" type="checkbox" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="switch-text">{{ projectOverwrite ? '覆盖(危险)' : '不覆盖,重名时失败' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="projectError !== null" class="notice error">{{ projectError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || projectImporting || projectFile === null"
|
||||||
|
@click="requestProjectImport"
|
||||||
|
>
|
||||||
|
{{ projectImporting ? '导入中…' : '导入' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="projectResult !== null" class="result-box">
|
||||||
|
<div class="result-title"><MxIcon name="check" :size="15" /> 导入完成</div>
|
||||||
|
<dl class="result-list">
|
||||||
|
<div><dt>项目名</dt><dd>{{ projectResult.name }}</dd></div>
|
||||||
|
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
|
||||||
|
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<p class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 规则包 -->
|
||||||
|
<div v-else class="rules-grid">
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">导出规则包</span>
|
||||||
|
<MockBadge v-if="rulesMock" />
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p class="hint">
|
||||||
|
导出为 YAML,含积分模板、并列规则、破纪录加分、计分通道与项目覆盖,可直接给另一场赛事复用。
|
||||||
|
</p>
|
||||||
|
<button class="mx-btn mx-btn-primary full" type="button" :disabled="rulesExporting" @click="exportRules">
|
||||||
|
<MxIcon name="export" :size="15" /> {{ rulesExporting ? '导出中…' : '导出 rules.yaml' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head"><span class="mx-card-title">导入规则包</span></div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<p v-if="!isAdmin" class="notice">导入规则包会全量重算积分,只有管理员可操作。</p>
|
||||||
|
|
||||||
|
<div class="file-row">
|
||||||
|
<input
|
||||||
|
ref="rulesFileInput"
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml,text/yaml"
|
||||||
|
@change="pickRulesFile"
|
||||||
|
/>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" @click="rulesFileInput?.click()">
|
||||||
|
<MxIcon name="database" :size="15" /> 选择 YAML
|
||||||
|
</button>
|
||||||
|
<span class="file-name mono">{{ rulesFileName === '' ? '未选择文件' : rulesFileName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">导入分两步:先 <code class="mono">?preview=1</code> 预览差异,确认后再落地。</p>
|
||||||
|
|
||||||
|
<p v-if="rulesError !== null" class="notice error">{{ rulesError }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary full"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || rulesPreviewing || rulesYaml === ''"
|
||||||
|
@click="previewRules"
|
||||||
|
>
|
||||||
|
{{ rulesPreviewing ? '比对中…' : '预览差异' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="rulesPreview !== null" class="preview-box">
|
||||||
|
<div class="preview-head">
|
||||||
|
<span class="mx-badge" :class="rulesPreview.compatible ? 'mx-badge-success' : 'mx-badge-error'">
|
||||||
|
{{ rulesPreview.compatible ? '格式兼容' : '格式不兼容,禁止导入' }}
|
||||||
|
</span>
|
||||||
|
<span class="preview-meta">影响 {{ rulesPreview.affectedEventCount }} 个小项</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rulesPreview.rows.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">没有差异</div>
|
||||||
|
<div class="mx-empty-desc">当前规则与该规则包一致,无需导入</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>对象</th>
|
||||||
|
<th>变化</th>
|
||||||
|
<th>说明</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in rulesPreview.rows" :key="`${row.kind}-${row.target}-${index}`">
|
||||||
|
<td>{{ row.kind }}</td>
|
||||||
|
<td class="mono">{{ row.target }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="mx-badge" :class="changeTone(row.change)">
|
||||||
|
{{ CHANGE_LABEL[row.change] ?? row.change }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="detail">{{ row.detail }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="rulesPreview.unknownEvents.length > 0" class="notice warn">
|
||||||
|
以下项目编码在当前赛事库中不存在,导入时会被跳过:{{ rulesPreview.unknownEvents.join('、') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="rulesApplyResult !== null" class="result-box">
|
||||||
|
<div class="result-title"><MxIcon name="check" :size="15" /> 已全量重算</div>
|
||||||
|
<p class="hint">
|
||||||
|
规则已落库,服务端重算了
|
||||||
|
<strong class="mono">{{ rulesApplyResult.recalculatedLedgerRows }}</strong>
|
||||||
|
条积分流水;大屏会随实时推送自动刷新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-danger full"
|
||||||
|
type="button"
|
||||||
|
:disabled="!isAdmin || !rulesPreview.compatible || rulesApplying"
|
||||||
|
@click="requestRulesApply"
|
||||||
|
>
|
||||||
|
{{ rulesApplying ? '导入中…' : '确认导入并全量重算' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmProjectImport"
|
||||||
|
tone="danger"
|
||||||
|
title="覆盖同名赛事项目?"
|
||||||
|
:message="`将用「${projectFile?.name ?? ''}」覆盖同名赛事目录,旧目录会先改名保留但不会出现在列表里。`"
|
||||||
|
:details="['覆盖后如需回退,需要手工把旧目录改回来', '导入期间请勿停止服务端进程']"
|
||||||
|
confirm-text="覆盖导入"
|
||||||
|
:pending="projectImporting"
|
||||||
|
@confirm="runProjectImport"
|
||||||
|
@cancel="confirmProjectImport = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="confirmRulesApply"
|
||||||
|
tone="danger"
|
||||||
|
title="导入规则包并全量重算积分?"
|
||||||
|
:message="'规则一旦落地,全部已发布成绩会按新分值重算,积分榜与大屏会立即变化。'"
|
||||||
|
:details="[
|
||||||
|
`影响 ${rulesPreview?.affectedEventCount ?? 0} 个小项`,
|
||||||
|
`将产生 ${rulesPreview?.rows.length ?? 0} 处规则改动`,
|
||||||
|
'重算完成后不可一键撤销,纠错需重新导入旧规则包',
|
||||||
|
]"
|
||||||
|
confirm-text="导入并重算"
|
||||||
|
:pending="rulesApplying"
|
||||||
|
@confirm="applyRules"
|
||||||
|
@cancel="confirmRulesApply = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.export-tabs {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.export-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.export-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s2);
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.export-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.export-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
color: var(--mx-primary);
|
||||||
|
}
|
||||||
|
.export-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.project-grid,
|
||||||
|
.rules-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.switch-field {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.switch-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.notice.warn {
|
||||||
|
color: var(--mx-amber);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
}
|
||||||
|
.result-box,
|
||||||
|
.preview-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: var(--mx-s2);
|
||||||
|
border: 1px solid var(--mx-border);
|
||||||
|
}
|
||||||
|
.result-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
.result-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.result-list div {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.result-list dt {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
.result-list dd {
|
||||||
|
color: var(--mx-t2);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
.footer-note {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.project-grid,
|
||||||
|
.rules-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.export-grid,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { formatDateTime, type AuditLog } from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import MockBadge from '../components/system/MockBadge.vue'
|
||||||
|
import JsonDiff from '../components/system/JsonDiff.vue'
|
||||||
|
import { adminApi, mockMode } from '../api/client'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志(devdocs/08 第 3 节)。
|
||||||
|
*
|
||||||
|
* 审计日志只读、不可删;所有写操作都记录操作人、时间、IP、request_id 与 before/after。
|
||||||
|
* 服务端的 /api/audit-logs 只支持 entityType / userId 过滤,没有 action 参数,
|
||||||
|
* 所以这里一次取最近 200 条再在前端按动作与实体筛选——宁可把窗口写清楚,也不假装能全库筛。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
const WINDOW_SIZE = 200
|
||||||
|
|
||||||
|
/** 08 第 3 节点名的关键动作,单独标出来便于复盘。 */
|
||||||
|
const CRITICAL_ACTIONS = ['publish', 'unpublish', 'void', 'adjustment', 'user']
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const rows = ref<AuditLog[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
const usingMock = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const actionFilter = ref('')
|
||||||
|
const entityFilter = ref('')
|
||||||
|
const expandedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const actionOptions = computed(() => {
|
||||||
|
const set = new Set(rows.value.map((row) => row.action))
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
const entityOptions = computed(() => {
|
||||||
|
const set = new Set(rows.value.map((row) => row.entityType))
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() =>
|
||||||
|
rows.value.filter(
|
||||||
|
(row) =>
|
||||||
|
(actionFilter.value === '' || row.action === actionFilter.value) &&
|
||||||
|
(entityFilter.value === '' || row.entityType === entityFilter.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
|
||||||
|
|
||||||
|
const pageRows = computed(() => {
|
||||||
|
const start = (page.value - 1) * PAGE_SIZE
|
||||||
|
return filtered.value.slice(start, start + PAGE_SIZE)
|
||||||
|
})
|
||||||
|
|
||||||
|
const operatorLabel = (row: AuditLog): string => {
|
||||||
|
if (row.userId === null) return '系统'
|
||||||
|
if (auth.user !== null && auth.user.id === row.userId) return `${auth.user.displayName}(我)`
|
||||||
|
return `用户 #${row.userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCritical = (action: string): boolean =>
|
||||||
|
CRITICAL_ACTIONS.some((keyword) => action.toLowerCase().includes(keyword))
|
||||||
|
|
||||||
|
function resetFilters(): void {
|
||||||
|
actionFilter.value = ''
|
||||||
|
entityFilter.value = ''
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(): void {
|
||||||
|
// 筛选变化后停留在旧页码会看到空白页,直接回到第一页。
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
const result = await adminApi.auditLogs({ page: 1, size: WINDOW_SIZE })
|
||||||
|
rows.value = result.items
|
||||||
|
// adminApi 内部静默降级,只能从全局标记反推这批数据是不是示例。
|
||||||
|
usingMock.value = mockMode.value
|
||||||
|
if (page.value > pageCount.value) page.value = 1
|
||||||
|
} catch (caught) {
|
||||||
|
loadError.value = caught instanceof Error ? caught.message : '加载审计日志失败'
|
||||||
|
toast.error('加载审计日志失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader title="日志" subtitle="审计日志:谁在什么时候改了什么(只读,不可删除)">
|
||||||
|
<template #actions>
|
||||||
|
<MockBadge v-if="usingMock" />
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
|
||||||
|
<MxIcon name="refresh" :size="15" /> 刷新
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">审计日志</span>
|
||||||
|
<span class="mx-badge mx-badge-neutral">{{ filtered.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="filter">
|
||||||
|
<label class="mx-label" for="log-action">动作</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select id="log-action" v-model="actionFilter" class="mx-input" @change="onFilterChange">
|
||||||
|
<option value="">全部动作</option>
|
||||||
|
<option v-for="action in actionOptions" :key="action" :value="action">{{ action }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter">
|
||||||
|
<label class="mx-label" for="log-entity">实体</label>
|
||||||
|
<div class="mx-input-wrap">
|
||||||
|
<select id="log-entity" v-model="entityFilter" class="mx-input" @change="onFilterChange">
|
||||||
|
<option value="">全部实体</option>
|
||||||
|
<option v-for="entity in entityOptions" :key="entity" :value="entity">{{ entity }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="mx-btn mx-btn-ghost clear" type="button" @click="resetFilters">清除筛选</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
服务端 <code class="mono">/api/audit-logs</code> 只支持按实体与操作人过滤,暂不支持 action 参数,
|
||||||
|
因此筛选与分页作用于最近 {{ WINDOW_SIZE }} 条记录。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在加载审计日志…</div>
|
||||||
|
|
||||||
|
<div v-else-if="rows.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">还没有审计记录</div>
|
||||||
|
<div class="mx-empty-desc">发布成绩、修改设置、管理用户等写操作都会留痕</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filtered.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-title">没有匹配的记录</div>
|
||||||
|
<div class="mx-empty-desc">换个动作或实体试试,或点「清除筛选」</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>操作人</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>动作</th>
|
||||||
|
<th>实体</th>
|
||||||
|
<th>请求 ID</th>
|
||||||
|
<th class="right">详情</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="row in pageRows" :key="row.id">
|
||||||
|
<tr
|
||||||
|
class="log-row"
|
||||||
|
:class="{ expanded: expandedId === row.id }"
|
||||||
|
@click="expandedId = expandedId === row.id ? null : row.id"
|
||||||
|
>
|
||||||
|
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ operatorLabel(row) }}</td>
|
||||||
|
<td class="mono muted">{{ row.ip ?? '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="action mono">{{ row.action }}</span>
|
||||||
|
<span v-if="isCritical(row.action)" class="mx-tag mx-tag-warning mx-tag-sm">关键</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
{{ row.entityType }}<span v-if="row.entityId !== null"> #{{ row.entityId }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="mono muted req">{{ row.requestId ?? '—' }}</td>
|
||||||
|
<td class="right">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button">
|
||||||
|
{{ expandedId === row.id ? '收起' : '对比' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expandedId === row.id" class="detail-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<JsonDiff :before="row.beforeJson" :after="row.afterJson" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<span class="mx-page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||||
|
<button
|
||||||
|
class="mx-page-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
@click="page -= 1"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-page-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="page >= pageCount"
|
||||||
|
@click="page += 1"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mx-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.clear {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.notice.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
.state-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
.log-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-row.expanded {
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.req {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.detail-row td {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
formatDateTime,
|
||||||
|
formatPoints,
|
||||||
|
type Event,
|
||||||
|
type RecordEntry,
|
||||||
|
type RecordLevel,
|
||||||
|
type RulePack,
|
||||||
|
} from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
/** 疑似破纪录:由「已发布成绩 vs 当前纪录」实时推导,接口不返回该列表。 */
|
||||||
|
interface SuspectedBreak {
|
||||||
|
key: string
|
||||||
|
eventId: number
|
||||||
|
eventName: string
|
||||||
|
eventCode: string
|
||||||
|
categoryName: string
|
||||||
|
entryId: number
|
||||||
|
teamName: string
|
||||||
|
athleteName: string
|
||||||
|
resultText: string
|
||||||
|
resultValue: number
|
||||||
|
level: RecordLevel
|
||||||
|
recordText: string
|
||||||
|
bonusPoints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordGroup {
|
||||||
|
eventId: number
|
||||||
|
eventName: string
|
||||||
|
eventCode: string
|
||||||
|
categoryLabel: string
|
||||||
|
records: RecordEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_LABELS: Record<RecordLevel, string> = {
|
||||||
|
meet: '赛会',
|
||||||
|
city: '市级',
|
||||||
|
province: '省级',
|
||||||
|
national: '全国',
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const records = ref<RecordEntry[]>([])
|
||||||
|
const recordsLoading = ref(false)
|
||||||
|
const recordsError = ref<string | null>(null)
|
||||||
|
const categoryLabels = ref<Record<number, string>>({})
|
||||||
|
const suspected = ref<SuspectedBreak[]>([])
|
||||||
|
const suspectedLoading = ref(false)
|
||||||
|
const suspectedError = ref<string | null>(null)
|
||||||
|
const rulePack = ref<RulePack | null>(null)
|
||||||
|
const ruleError = ref<string | null>(null)
|
||||||
|
const rulesLoading = ref(false)
|
||||||
|
const search = ref('')
|
||||||
|
const levelFilter = ref<RecordLevel | 'all'>('all')
|
||||||
|
const approveTarget = ref<SuspectedBreak | null>(null)
|
||||||
|
const approving = ref(false)
|
||||||
|
const rejectTarget = ref<SuspectedBreak | null>(null)
|
||||||
|
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */
|
||||||
|
const rejectedKeys = ref<string[]>([])
|
||||||
|
const approvedKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||||
|
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
|
||||||
|
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
|
||||||
|
|
||||||
|
const filteredRecords = computed(() => {
|
||||||
|
const keyword = search.value.trim()
|
||||||
|
return records.value.filter((record) => {
|
||||||
|
if (levelFilter.value !== 'all' && record.level !== levelFilter.value) return false
|
||||||
|
if (keyword === '') return true
|
||||||
|
const event = data.eventById(record.eventId)
|
||||||
|
const label = categoryLabels.value[record.eventId] ?? ''
|
||||||
|
return (
|
||||||
|
(event?.name ?? '').includes(keyword) ||
|
||||||
|
(event?.code ?? '').includes(keyword) ||
|
||||||
|
label.includes(keyword) ||
|
||||||
|
(record.holderName ?? '').includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const recordGroups = computed<RecordGroup[]>(() => {
|
||||||
|
const map = new Map<number, RecordEntry[]>()
|
||||||
|
for (const record of filteredRecords.value) {
|
||||||
|
const list = map.get(record.eventId)
|
||||||
|
if (list === undefined) map.set(record.eventId, [record])
|
||||||
|
else list.push(record)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([eventId, list]) => {
|
||||||
|
const event = data.eventById(eventId)
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
eventName: event?.name ?? `项目 ${eventId}`,
|
||||||
|
eventCode: event?.code ?? '',
|
||||||
|
categoryLabel: categoryLabels.value[eventId] ?? '未绑定组别',
|
||||||
|
records: [...list].sort((a, b) => levelRank(a.level) - levelRank(b.level)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.eventName.localeCompare(b.eventName))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingBreaks = computed(() =>
|
||||||
|
suspected.value.filter(
|
||||||
|
(item) => !rejectedKeys.value.includes(item.key) && !approvedKeys.value.includes(item.key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function levelRank(level: RecordLevel): number {
|
||||||
|
const index = LEVEL_ORDER.indexOf(level)
|
||||||
|
return index < 0 ? 0 : index
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFallback(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamLabel(teamId: number): string {
|
||||||
|
const team = data.teamById(teamId)
|
||||||
|
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */
|
||||||
|
function beats(compare: Event['compare'], value: number, recordValue: number): boolean {
|
||||||
|
if (compare === 'asc') return value < recordValue
|
||||||
|
if (compare === 'desc') return value > recordValue
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules(): Promise<void> {
|
||||||
|
rulesLoading.value = true
|
||||||
|
try {
|
||||||
|
rulePack.value = await adminApi.client.rules.get()
|
||||||
|
ruleError.value = null
|
||||||
|
} catch (caught) {
|
||||||
|
rulePack.value = null
|
||||||
|
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
|
||||||
|
} finally {
|
||||||
|
rulesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
|
||||||
|
const labels: Record<number, string> = {}
|
||||||
|
await Promise.all(
|
||||||
|
eventIds.map(async (eventId) => {
|
||||||
|
const cached = data.eventById(eventId)?.categories
|
||||||
|
if (cached !== undefined && cached.length > 0) {
|
||||||
|
labels[eventId] = cached.map((category) => category.name).join('、')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const detail = await adminApi.eventDetail(eventId)
|
||||||
|
labels[eventId] = (detail.categories ?? []).map((category) => category.name).join('、')
|
||||||
|
} catch {
|
||||||
|
labels[eventId] = ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
categoryLabels.value = labels
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(): Promise<void> {
|
||||||
|
recordsLoading.value = true
|
||||||
|
recordsError.value = null
|
||||||
|
try {
|
||||||
|
records.value = await adminApi.records()
|
||||||
|
const eventIds = [...new Set(records.value.map((record) => record.eventId))]
|
||||||
|
await loadCategoryLabels(eventIds)
|
||||||
|
} catch (caught) {
|
||||||
|
recordsError.value = caught instanceof Error ? caught.message : '加载纪录库失败'
|
||||||
|
} finally {
|
||||||
|
recordsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSuspected(): Promise<void> {
|
||||||
|
if (!data.loaded) return
|
||||||
|
suspectedLoading.value = true
|
||||||
|
suspectedError.value = null
|
||||||
|
try {
|
||||||
|
const candidates = data.events.filter(
|
||||||
|
(event) => event.recordEligible && records.value.some((record) => record.eventId === event.id),
|
||||||
|
)
|
||||||
|
const found = await Promise.all(
|
||||||
|
candidates.map(async (event) => {
|
||||||
|
const entries = await adminApi.entries(event.id)
|
||||||
|
const eventRecords = records.value.filter((record) => record.eventId === event.id)
|
||||||
|
const list: SuspectedBreak[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const result = entry.result
|
||||||
|
if (result === null || result.state !== 'published' || result.status !== 'ok') continue
|
||||||
|
const value = result.resultValue
|
||||||
|
if (value === null) continue
|
||||||
|
let best: RecordEntry | null = null
|
||||||
|
for (const record of eventRecords) {
|
||||||
|
if (!beats(event.compare, value, record.resultValue)) continue
|
||||||
|
if (best === null || levelRank(record.level) > levelRank(best.level)) best = record
|
||||||
|
}
|
||||||
|
if (best === null) continue
|
||||||
|
list.push({
|
||||||
|
key: `${event.id}-${entry.id}-${best.level}`,
|
||||||
|
eventId: event.id,
|
||||||
|
eventName: event.name,
|
||||||
|
eventCode: event.code,
|
||||||
|
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
|
||||||
|
entryId: entry.id,
|
||||||
|
teamName: teamLabel(entry.teamId),
|
||||||
|
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—',
|
||||||
|
resultText: result.resultText,
|
||||||
|
resultValue: value,
|
||||||
|
level: best.level,
|
||||||
|
recordText: best.resultText,
|
||||||
|
bonusPoints: bonusFor(best.level),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
suspected.value = found.flat().sort((a, b) => levelRank(b.level) - levelRank(a.level))
|
||||||
|
} catch (caught) {
|
||||||
|
suspectedError.value = caught instanceof Error ? caught.message : '推导疑似破纪录失败'
|
||||||
|
} finally {
|
||||||
|
suspectedLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bonusFor(level: RecordLevel): number {
|
||||||
|
const definition = rulePack.value?.recordBonus.levels.find((item) => item.code === level)
|
||||||
|
return definition?.points ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestApprove(item: SuspectedBreak): void {
|
||||||
|
if (!recordBonusEnabled.value) {
|
||||||
|
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canWrite.value) {
|
||||||
|
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
approveTarget.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmApprove(): Promise<void> {
|
||||||
|
const item = approveTarget.value
|
||||||
|
if (item === null) return
|
||||||
|
approving.value = true
|
||||||
|
try {
|
||||||
|
const created = await adminApi.client.records.createBreak({
|
||||||
|
eventId: item.eventId,
|
||||||
|
entryId: item.entryId,
|
||||||
|
level: item.level,
|
||||||
|
resultValue: item.resultValue,
|
||||||
|
resultText: item.resultText,
|
||||||
|
})
|
||||||
|
await adminApi.client.records.approveBreak(created.id)
|
||||||
|
approvedKeys.value = [...approvedKeys.value, item.key]
|
||||||
|
approveTarget.value = null
|
||||||
|
toast.success('已确认破纪录', `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`)
|
||||||
|
await Promise.all([loadRecords(), data.refreshStandings()])
|
||||||
|
} catch (caught) {
|
||||||
|
if (isFallback(caught)) {
|
||||||
|
// 后端未就绪:只做本地标记,绝不假装写成功。
|
||||||
|
approvedKeys.value = [...approvedKeys.value, item.key]
|
||||||
|
approveTarget.value = null
|
||||||
|
toast.warning('已本地登记(示例数据)', '服务端未就绪,加分流水未真正生成')
|
||||||
|
} else {
|
||||||
|
toast.error('确认失败', caught instanceof Error ? caught.message : null)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
approving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmReject(): void {
|
||||||
|
const item = rejectTarget.value
|
||||||
|
if (item === null) return
|
||||||
|
rejectedKeys.value = [...rejectedKeys.value, item.key]
|
||||||
|
rejectTarget.value = null
|
||||||
|
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll(): Promise<void> {
|
||||||
|
await Promise.all([loadRecords(), loadRules()])
|
||||||
|
await loadSuspected()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 项目列表由布局层异步加载,加载完成后才能推导疑似破纪录。
|
||||||
|
watch(
|
||||||
|
() => data.loaded,
|
||||||
|
(loaded) => {
|
||||||
|
if (loaded) void loadSuspected()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="records-page">
|
||||||
|
<PageHeader title="破纪录" subtitle="纪录库 + 疑似破纪录确认;本期加分功能默认关闭">
|
||||||
|
<template #actions>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="recordsLoading || suspectedLoading" @click="refreshAll">
|
||||||
|
<MxIcon name="refresh" :size="15" /> 刷新
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="mx-card notice-card">
|
||||||
|
<div class="mx-card-body notice-body">
|
||||||
|
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
|
||||||
|
<div>
|
||||||
|
<div class="notice-title">
|
||||||
|
{{ recordBonusEnabled ? '破纪录加分已启用' : '本期破纪录加分已关闭' }}
|
||||||
|
<span class="mx-badge" :class="recordBonusEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||||
|
recordBonus.enabled = {{ recordBonusEnabled ? 'true' : 'false' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="notice-desc">
|
||||||
|
<template v-if="recordBonusEnabled">
|
||||||
|
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
|
||||||
|
以后开一个开关即可启用,档位与分值已预置。
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p v-if="ruleError !== null" class="notice-error">
|
||||||
|
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mx-card mx-section">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">疑似破纪录</span>
|
||||||
|
<span class="mx-badge mx-badge-neutral">{{ pendingBreaks.length }} 条待确认</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div v-if="suspectedLoading" class="mx-empty">
|
||||||
|
<div class="mx-spinner mx-spinner-sm"></div>
|
||||||
|
<div class="mx-empty-title">正在比对已发布成绩…</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="suspectedError !== null" class="error-banner">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>{{ suspectedError }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pendingBreaks.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
|
||||||
|
<div class="mx-empty-title">暂无疑似破纪录</div>
|
||||||
|
<div class="mx-empty-desc">已发布成绩优于当前纪录时会自动出现在这里</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>小项</th>
|
||||||
|
<th>组别</th>
|
||||||
|
<th>单位 / 运动员</th>
|
||||||
|
<th>成绩</th>
|
||||||
|
<th>原纪录</th>
|
||||||
|
<th>档位</th>
|
||||||
|
<th class="right">加分</th>
|
||||||
|
<th class="right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in pendingBreaks" :key="item.key">
|
||||||
|
<td>
|
||||||
|
<span class="event-name">{{ item.eventName }}</span>
|
||||||
|
<small class="mono event-code">{{ item.eventCode }}</small>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.categoryName }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="team-name">{{ item.teamName }}</span>
|
||||||
|
<small class="athlete">{{ item.athleteName }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="mono highlight">{{ item.resultText }}</td>
|
||||||
|
<td class="mono">{{ item.recordText }}</td>
|
||||||
|
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td>
|
||||||
|
<td class="right mono points">+{{ formatPoints(item.bonusPoints) }}</td>
|
||||||
|
<td class="right actions">
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!recordBonusEnabled || !canWrite || approving"
|
||||||
|
@click="requestApprove(item)"
|
||||||
|
>
|
||||||
|
确认加分
|
||||||
|
</button>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="rejectTarget = item">拒绝</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="!recordBonusEnabled && pendingBreaks.length > 0" class="hint">
|
||||||
|
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mx-card mx-section">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">纪录库</span>
|
||||||
|
<div class="head-tools">
|
||||||
|
<div class="mx-tabs">
|
||||||
|
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
|
||||||
|
<button
|
||||||
|
v-for="level in LEVEL_ORDER"
|
||||||
|
:key="level"
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: levelFilter === level }"
|
||||||
|
type="button"
|
||||||
|
@click="levelFilter = level"
|
||||||
|
>
|
||||||
|
{{ LEVEL_LABELS[level] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-input-wrap search">
|
||||||
|
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||||
|
<input v-model="search" class="mx-input" placeholder="搜索小项 / 保持者" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div v-if="recordsLoading" class="mx-empty">
|
||||||
|
<div class="mx-spinner mx-spinner-sm"></div>
|
||||||
|
<div class="mx-empty-title">正在加载纪录库…</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recordsError !== null" class="error-banner">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>{{ recordsError }}</span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadRecords">重试</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recordGroups.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
|
||||||
|
<div class="mx-empty-title">没有匹配的纪录</div>
|
||||||
|
<div class="mx-empty-desc">按小项 + 组别维护纪录库</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="record-groups">
|
||||||
|
<div v-for="group in recordGroups" :key="group.eventId" class="record-group">
|
||||||
|
<div class="record-group-head">
|
||||||
|
<span class="event-name">{{ group.eventName }}</span>
|
||||||
|
<small class="mono event-code">{{ group.eventCode }}</small>
|
||||||
|
<span class="mx-tag">{{ group.categoryLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>档位</th>
|
||||||
|
<th>成绩</th>
|
||||||
|
<th>保持者</th>
|
||||||
|
<th>单位</th>
|
||||||
|
<th>达成时间</th>
|
||||||
|
<th>来源</th>
|
||||||
|
<th class="center">当前</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="record in group.records" :key="record.id">
|
||||||
|
<td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ LEVEL_LABELS[record.level] }}</span></td>
|
||||||
|
<td class="mono highlight">{{ record.resultText }}</td>
|
||||||
|
<td>{{ record.holderName ?? '—' }}</td>
|
||||||
|
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
|
||||||
|
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
|
||||||
|
<td>{{ record.source === 'current' ? '本届' : '历史' }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="approveTarget !== null"
|
||||||
|
tone="warning"
|
||||||
|
title="确认破纪录并加分?"
|
||||||
|
:message="
|
||||||
|
approveTarget === null
|
||||||
|
? ''
|
||||||
|
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||||
|
"
|
||||||
|
:details="[
|
||||||
|
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
|
||||||
|
`加分模式:${bonusMode};破纪录加分与成绩积分相互独立,不随权重翻倍`,
|
||||||
|
]"
|
||||||
|
confirm-text="确认并加分"
|
||||||
|
:pending="approving"
|
||||||
|
@confirm="confirmApprove"
|
||||||
|
@cancel="approveTarget = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="rejectTarget !== null"
|
||||||
|
tone="danger"
|
||||||
|
title="拒绝这条疑似破纪录?"
|
||||||
|
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
|
||||||
|
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
|
||||||
|
confirm-text="确认拒绝"
|
||||||
|
@confirm="confirmReject"
|
||||||
|
@cancel="rejectTarget = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notice-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.notice-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.notice-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
color: var(--mx-amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notice-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.notice-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.notice-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
.event-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.event-code {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.team-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.athlete {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mx-primary);
|
||||||
|
}
|
||||||
|
.points {
|
||||||
|
color: var(--mx-green);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.record-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.record-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
color: var(--mx-red);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
formatPoints,
|
||||||
|
type EntryWithResult,
|
||||||
|
type Event,
|
||||||
|
type EventCategory,
|
||||||
|
type ResultStatus,
|
||||||
|
} from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
/** 一条草稿成绩:展示与对比都只用这里面的字段。 */
|
||||||
|
interface DraftRow {
|
||||||
|
entryId: number
|
||||||
|
teamId: number
|
||||||
|
teamName: string
|
||||||
|
teamShort: string
|
||||||
|
athleteName: string
|
||||||
|
memberNames: string[] | null
|
||||||
|
resultText: string
|
||||||
|
rank: number | null
|
||||||
|
status: ResultStatus
|
||||||
|
isTie: boolean
|
||||||
|
points: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个「小项 + 组别」的草稿组;发布以它为事务边界(07 第 3 节)。 */
|
||||||
|
interface DraftGroup {
|
||||||
|
key: string
|
||||||
|
event: Event
|
||||||
|
eventName: string
|
||||||
|
eventCode: string
|
||||||
|
sportName: string
|
||||||
|
category: EventCategory | null
|
||||||
|
categoryName: string
|
||||||
|
rows: DraftRow[]
|
||||||
|
deltas: TeamDelta[]
|
||||||
|
affectedTeams: number
|
||||||
|
maxDelta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamDelta {
|
||||||
|
teamId: number
|
||||||
|
name: string
|
||||||
|
delta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const groups = ref<DraftGroup[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const selectedKey = ref<string | null>(null)
|
||||||
|
const selectedEntryId = ref<number | null>(null)
|
||||||
|
const compareMode = ref<'group' | 'row'>('group')
|
||||||
|
const publishTarget = ref<DraftGroup | null>(null)
|
||||||
|
const publishing = ref(false)
|
||||||
|
const returnTarget = ref<DraftGroup | null>(null)
|
||||||
|
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
|
||||||
|
const returnedKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||||
|
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||||||
|
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
const keyword = search.value.trim()
|
||||||
|
if (keyword === '') return groups.value
|
||||||
|
return groups.value.filter(
|
||||||
|
(group) =>
|
||||||
|
group.eventName.includes(keyword) ||
|
||||||
|
group.categoryName.includes(keyword) ||
|
||||||
|
group.rows.some((row) => row.teamName.includes(keyword) || row.athleteName.includes(keyword)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedGroup = computed<DraftGroup | null>(
|
||||||
|
() => groups.value.find((group) => group.key === selectedKey.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedRow = computed<DraftRow | null>(() => {
|
||||||
|
const group = selectedGroup.value
|
||||||
|
if (group === null || selectedEntryId.value === null) return null
|
||||||
|
return group.rows.find((row) => row.entryId === selectedEntryId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 逐条对比:这一条自己的得分(个人项目按系数折算到单位)。 */
|
||||||
|
const rowImpact = computed<TeamDelta[]>(() => {
|
||||||
|
const row = selectedRow.value
|
||||||
|
if (row === null) return []
|
||||||
|
const individual = selectedGroup.value?.event.eventType === 'individual'
|
||||||
|
const delta = round(row.points * (individual ? weight.value : 1))
|
||||||
|
if (delta === 0) return []
|
||||||
|
return [{ teamId: row.teamId, name: row.teamShort || row.teamName, delta }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const impactRows = computed<TeamDelta[]>(() => {
|
||||||
|
if (compareMode.value === 'row') return rowImpact.value
|
||||||
|
return selectedGroup.value?.deltas ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const impactAffected = computed(() => impactRows.value.length)
|
||||||
|
const impactMax = computed(() => impactRows.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
|
||||||
|
|
||||||
|
const publishedCount = computed(() => groups.value.reduce((sum, group) => sum + group.rows.length, 0))
|
||||||
|
const returnSummary = computed(() =>
|
||||||
|
returnTarget.value === null
|
||||||
|
? ''
|
||||||
|
: `将退回「${returnTarget.value.eventName} · ${returnTarget.value.categoryName}」的 ${returnTarget.value.rows.length} 条草稿,录入员可修改后重新提交。`,
|
||||||
|
)
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Number(value.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamLabel(teamId: number, fallback: string): string {
|
||||||
|
const team = data.teamById(teamId)
|
||||||
|
return team?.shortName ?? team?.name ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFallback(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof ApiError &&
|
||||||
|
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sportNameOf(event: Event): string {
|
||||||
|
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroup(event: Event, category: EventCategory | null, entries: EntryWithResult[]): DraftGroup | null {
|
||||||
|
const scoped = entries.filter((entry) => category === null || entry.categoryId === category.id)
|
||||||
|
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
|
||||||
|
if (drafts.length === 0) return null
|
||||||
|
|
||||||
|
const scorable: ScorableRow[] = scoped.map((entry) => ({
|
||||||
|
entryId: entry.id,
|
||||||
|
teamId: entry.teamId,
|
||||||
|
resultValue: entry.result?.resultValue ?? null,
|
||||||
|
rank: entry.result?.rank ?? null,
|
||||||
|
status: entry.result?.status ?? 'ok',
|
||||||
|
isTie: entry.result?.isTie ?? false,
|
||||||
|
}))
|
||||||
|
const context: ScoreContext = {
|
||||||
|
event,
|
||||||
|
category,
|
||||||
|
channels: event.channels ?? [],
|
||||||
|
profiles: data.profiles,
|
||||||
|
weight: weight.value,
|
||||||
|
}
|
||||||
|
const score = computeEventScore(scorable, context)
|
||||||
|
const individual = event.eventType === 'individual'
|
||||||
|
|
||||||
|
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||||||
|
const published = new Map<number, number>()
|
||||||
|
for (const entry of scoped) {
|
||||||
|
if (entry.result?.state !== 'published') continue
|
||||||
|
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
|
||||||
|
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
|
||||||
|
const deltas: TeamDelta[] = []
|
||||||
|
for (const teamId of teamIds) {
|
||||||
|
const delta = round((score.teamPoints.get(teamId) ?? 0) - (published.get(teamId) ?? 0))
|
||||||
|
if (delta === 0) continue
|
||||||
|
deltas.push({ teamId, name: teamLabel(teamId, `单位 ${teamId}`), delta })
|
||||||
|
}
|
||||||
|
deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||||
|
|
||||||
|
const rows: DraftRow[] = drafts.map((entry) => ({
|
||||||
|
entryId: entry.id,
|
||||||
|
teamId: entry.teamId,
|
||||||
|
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
|
||||||
|
teamShort: entry.shortName ?? '',
|
||||||
|
athleteName: entry.athleteName ?? '',
|
||||||
|
memberNames: entry.memberNames,
|
||||||
|
resultText: entry.result?.resultText ?? '',
|
||||||
|
rank: entry.result?.rank ?? null,
|
||||||
|
status: entry.result?.status ?? 'ok',
|
||||||
|
isTie: entry.result?.isTie ?? false,
|
||||||
|
points: round(score.rowPoints.get(entry.id) ?? 0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `${event.id}-${category?.id ?? 0}`,
|
||||||
|
event,
|
||||||
|
eventName: event.name,
|
||||||
|
eventCode: event.code,
|
||||||
|
sportName: sportNameOf(event),
|
||||||
|
category,
|
||||||
|
categoryName: category?.name ?? '全部组别',
|
||||||
|
rows,
|
||||||
|
deltas,
|
||||||
|
affectedTeams: deltas.length,
|
||||||
|
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找出有草稿的小项:优先用结果接口的 state 过滤,接口未就绪时退回逐项探测。 */
|
||||||
|
async function draftEventIds(): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
const page = await adminApi.client.results.list({ state: 'draft', size: 500 })
|
||||||
|
return [...new Set(page.items.map((item) => item.eventId))]
|
||||||
|
} catch (caught) {
|
||||||
|
if (!isFallback(caught)) throw caught
|
||||||
|
const found: number[] = []
|
||||||
|
await Promise.all(
|
||||||
|
data.events.map(async (event) => {
|
||||||
|
// adminApi.entries 走统一降级逻辑:后端未就绪时读示例数据。
|
||||||
|
const entries = await adminApi.entries(event.id)
|
||||||
|
if (entries.some((entry) => entry.result?.state === 'draft')) found.push(event.id)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!data.loaded) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const eventIds = await draftEventIds()
|
||||||
|
const loaded = await Promise.all(
|
||||||
|
eventIds.map(async (eventId) => {
|
||||||
|
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
|
||||||
|
const categories = event.categories ?? []
|
||||||
|
const scoped: Array<EventCategory | null> = categories.length > 0 ? categories : [null]
|
||||||
|
return scoped
|
||||||
|
.map((category) => buildGroup(event, category, entries))
|
||||||
|
.filter((group): group is DraftGroup => group !== null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
groups.value = loaded.flat().sort((a, b) => a.eventCode.localeCompare(b.eventCode))
|
||||||
|
if (selectedKey.value !== null && !groups.value.some((group) => group.key === selectedKey.value)) {
|
||||||
|
selectedKey.value = null
|
||||||
|
}
|
||||||
|
if (selectedKey.value === null && groups.value.length > 0) {
|
||||||
|
selectedKey.value = groups.value[0]?.key ?? null
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
error.value = caught instanceof Error ? caught.message : '加载草稿失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectGroup(group: DraftGroup): void {
|
||||||
|
selectedKey.value = group.key
|
||||||
|
selectedEntryId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReturned(group: DraftGroup): boolean {
|
||||||
|
return returnedKeys.value.includes(group.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestPublish(group: DraftGroup): void {
|
||||||
|
if (!canWrite.value) {
|
||||||
|
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishTarget.value = group
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPublish(): Promise<void> {
|
||||||
|
const group = publishTarget.value
|
||||||
|
if (group === null) return
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
|
||||||
|
publishTarget.value = null
|
||||||
|
await load()
|
||||||
|
await data.refreshStandings()
|
||||||
|
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmReturn(): void {
|
||||||
|
const group = returnTarget.value
|
||||||
|
if (group === null) return
|
||||||
|
if (!returnedKeys.value.includes(group.key)) {
|
||||||
|
returnedKeys.value = [...returnedKeys.value, group.key]
|
||||||
|
}
|
||||||
|
returnTarget.value = null
|
||||||
|
toast.warning('已标记退回', '当前接口未提供「退回」动作,本次仅在本地标记,不影响服务端数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoReturn(group: DraftGroup): void {
|
||||||
|
returnedKeys.value = returnedKeys.value.filter((key) => key !== group.key)
|
||||||
|
toast.info('已取消退回标记')
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: ResultStatus): string {
|
||||||
|
if (status === 'ok') return '有效'
|
||||||
|
if (status === 'dns') return 'DNS'
|
||||||
|
if (status === 'dq') return 'DQ'
|
||||||
|
if (status === 'dnf') return 'DNF'
|
||||||
|
return '退赛'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => data.loaded,
|
||||||
|
(loaded) => {
|
||||||
|
if (loaded) void load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="review-page">
|
||||||
|
<PageHeader title="成绩审核" subtitle="草稿按「小项 + 组别」分组,发布是整组事务;草稿态绝不上屏">
|
||||||
|
<template #actions>
|
||||||
|
<span class="mx-badge mx-badge-warning">{{ publishedCount }} 条草稿待审</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
|
||||||
|
<MxIcon name="refresh" :size="15" /> 刷新
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div v-if="error !== null" class="error-banner">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="review-grid">
|
||||||
|
<aside class="mx-card group-panel">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">待审草稿</span>
|
||||||
|
<span class="mx-badge mx-badge-neutral">{{ filteredGroups.length }} 组</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body group-body">
|
||||||
|
<div class="mx-input-wrap search">
|
||||||
|
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
|
||||||
|
<input v-model="search" class="mx-input" placeholder="搜索小项 / 组别 / 单位" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mx-empty">
|
||||||
|
<div class="mx-spinner mx-spinner-sm"></div>
|
||||||
|
<div class="mx-empty-title">正在加载草稿…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredGroups.length === 0" class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="review" :size="26" /></div>
|
||||||
|
<div class="mx-empty-title">没有待审草稿</div>
|
||||||
|
<div class="mx-empty-desc">录入页保存的草稿会出现在这里</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="group-list">
|
||||||
|
<button
|
||||||
|
v-for="group in filteredGroups"
|
||||||
|
:key="group.key"
|
||||||
|
class="group-item"
|
||||||
|
:class="{ active: group.key === selectedKey }"
|
||||||
|
type="button"
|
||||||
|
@click="selectGroup(group)"
|
||||||
|
>
|
||||||
|
<span class="group-main">
|
||||||
|
<span class="group-name">{{ group.eventName }}</span>
|
||||||
|
<span class="group-meta">{{ group.sportName }} · {{ group.categoryName }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="group-side">
|
||||||
|
<span class="mx-badge mx-badge-warning">{{ group.rows.length }} 条</span>
|
||||||
|
<span v-if="isReturned(group)" class="mx-badge mx-badge-neutral">已退回</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="review-main">
|
||||||
|
<div v-if="selectedGroup === null" class="mx-card">
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="review" :size="28" /></div>
|
||||||
|
<div class="mx-empty-title">选择左侧一组草稿</div>
|
||||||
|
<div class="mx-empty-desc">对比发布影响后再决定发布或退回</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">
|
||||||
|
{{ selectedGroup.eventName }}
|
||||||
|
<small class="head-sub">{{ selectedGroup.sportName }} · {{ selectedGroup.categoryName }}</small>
|
||||||
|
</span>
|
||||||
|
<div class="head-actions">
|
||||||
|
<span v-if="isReturned(selectedGroup)" class="mx-badge mx-badge-neutral">已退回待修改</span>
|
||||||
|
<button
|
||||||
|
v-if="isReturned(selectedGroup)"
|
||||||
|
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
@click="undoReturn(selectedGroup)"
|
||||||
|
>
|
||||||
|
取消退回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite"
|
||||||
|
@click="returnTarget = selectedGroup"
|
||||||
|
>
|
||||||
|
<MxIcon name="logout" :size="14" /> 退回
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canWrite || publishing"
|
||||||
|
@click="requestPublish(selectedGroup)"
|
||||||
|
>
|
||||||
|
<MxIcon name="push" :size="14" /> 发布整组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="compare-bar">
|
||||||
|
<div class="mx-tabs mx-tabs-pill">
|
||||||
|
<button
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: compareMode === 'group' }"
|
||||||
|
type="button"
|
||||||
|
@click="compareMode = 'group'"
|
||||||
|
>
|
||||||
|
整组对比
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: compareMode === 'row' }"
|
||||||
|
type="button"
|
||||||
|
:disabled="selectedRow === null"
|
||||||
|
@click="compareMode = 'row'"
|
||||||
|
>
|
||||||
|
逐条对比
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="impact-line">
|
||||||
|
本次将影响 <strong>{{ impactAffected }}</strong> 个单位,最大分差
|
||||||
|
<strong class="mono">{{ formatPoints(impactMax) }}</strong> 分
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-table-wrap">
|
||||||
|
<table class="mx-table mx-table-compact">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>单位</th>
|
||||||
|
<th>运动员 / 队</th>
|
||||||
|
<th>成绩</th>
|
||||||
|
<th class="center">名次</th>
|
||||||
|
<th class="center">状态</th>
|
||||||
|
<th class="right">积分</th>
|
||||||
|
<th class="right">对比</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in selectedGroup.rows"
|
||||||
|
:key="row.entryId"
|
||||||
|
:class="{ selected: row.entryId === selectedEntryId }"
|
||||||
|
@click="selectedEntryId = row.entryId"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||||||
|
<td class="mono">{{ row.resultText || '—' }}</td>
|
||||||
|
<td class="center mono">{{ row.rank ?? '—' }}</td>
|
||||||
|
<td class="center">{{ statusLabel(row.status) }}</td>
|
||||||
|
<td class="right mono points">{{ formatPoints(row.points) }}</td>
|
||||||
|
<td class="right">
|
||||||
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click.stop="selectedEntryId = row.entryId; compareMode = 'row'">
|
||||||
|
看该条
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">
|
||||||
|
发布影响({{ compareMode === 'row' ? '逐条' : '整组' }})
|
||||||
|
</span>
|
||||||
|
<span class="mx-badge mx-badge-info">{{ impactAffected }} 个单位</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<ul v-if="impactRows.length > 0" class="impact-list">
|
||||||
|
<li v-for="item in impactRows" :key="item.teamId" class="impact-item">
|
||||||
|
<span class="impact-name">{{ item.name }}</span>
|
||||||
|
<span class="impact-delta mono" :class="item.delta > 0 ? 'up' : 'down'">
|
||||||
|
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="impact-empty">
|
||||||
|
{{ compareMode === 'row' ? '选中一行查看它单独的得分影响' : '本次发布不会改变任何单位的分数' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="publishTarget !== null"
|
||||||
|
tone="warning"
|
||||||
|
title="确认发布该组成绩?"
|
||||||
|
:message="
|
||||||
|
publishTarget === null
|
||||||
|
? ''
|
||||||
|
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||||
|
"
|
||||||
|
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||||||
|
confirm-text="确认发布"
|
||||||
|
:pending="publishing"
|
||||||
|
@confirm="confirmPublish"
|
||||||
|
@cancel="publishTarget = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="returnTarget !== null"
|
||||||
|
tone="danger"
|
||||||
|
title="确认退回这组草稿?"
|
||||||
|
:message="returnSummary"
|
||||||
|
:details="['服务端接口清单里没有「退回」动作,本次只在管理端做本地标记', '成绩数据不变,录入员修改后重新保存草稿即可']"
|
||||||
|
confirm-text="标记退回"
|
||||||
|
@confirm="confirmReturn"
|
||||||
|
@cancel="returnTarget = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.review-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.group-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
max-height: calc(100dvh - 140px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.group-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.group-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.group-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
transition: all var(--mx-d1) var(--mx-ease);
|
||||||
|
}
|
||||||
|
.group-item:hover {
|
||||||
|
background: var(--mx-s-hover);
|
||||||
|
color: var(--mx-text);
|
||||||
|
}
|
||||||
|
.group-item.active {
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
color: var(--mx-primary);
|
||||||
|
}
|
||||||
|
.group-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.group-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.group-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.group-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.review-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.head-sub {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.compare-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.impact-line {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t2);
|
||||||
|
}
|
||||||
|
.impact-line strong {
|
||||||
|
color: var(--mx-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.team-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.points {
|
||||||
|
color: var(--mx-green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
tr.selected {
|
||||||
|
background: var(--mx-primary-glow);
|
||||||
|
}
|
||||||
|
.impact-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.impact-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.impact-delta {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.impact-delta.up {
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
.impact-delta.down {
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.impact-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
color: var(--mx-red);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.review-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.group-panel {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import UsersPanel from '../components/system/UsersPanel.vue'
|
||||||
|
import BackupPanel from '../components/system/BackupPanel.vue'
|
||||||
|
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统页(devdocs/07 第 1 节)。
|
||||||
|
*
|
||||||
|
* 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
|
||||||
|
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TabKey = 'users' | 'backup' | 'event'
|
||||||
|
|
||||||
|
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||||
|
{ key: 'users', label: '用户管理' },
|
||||||
|
{ key: 'backup', label: '备份' },
|
||||||
|
{ key: 'event', label: '赛事信息' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const data = useEventStore()
|
||||||
|
|
||||||
|
const activeTab = ref<TabKey>('users')
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader title="系统" subtitle="用户管理 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
|
||||||
|
|
||||||
|
<div class="system-toolbar">
|
||||||
|
<div class="mx-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.key"
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
type="button"
|
||||||
|
@click="activeTab = tab.key"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="mx-badge" :class="isAdmin ? 'mx-badge-success' : 'mx-badge-neutral'">
|
||||||
|
{{ isAdmin ? `当前身份:${auth.displayName}` : `只读访客 · ${auth.displayName}` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!isAdmin" class="role-note">
|
||||||
|
系统配置类接口对 Guest 一律 403(devdocs/08 第 2 节),因此这里的写操作按钮全部禁用;
|
||||||
|
登录管理员账号后即可操作。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
|
||||||
|
|
||||||
|
<UsersPanel v-if="activeTab === 'users'" />
|
||||||
|
<BackupPanel v-else-if="activeTab === 'backup'" />
|
||||||
|
<EventInfoPanel v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.system-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.role-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--mx-r-xs);
|
||||||
|
background: var(--mx-s3);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.role-note.error {
|
||||||
|
color: var(--mx-error);
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+41
-17
@@ -5,9 +5,11 @@ import { mix, parseHexColor, readableForeground, toCss } from './color'
|
|||||||
import type { DisplayPageKey } from './types'
|
import type { DisplayPageKey } from './types'
|
||||||
import { useScoreboard } from './useScoreboard'
|
import { useScoreboard } from './useScoreboard'
|
||||||
import { useStageScale } from './useStageScale'
|
import { useStageScale } from './useStageScale'
|
||||||
|
import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed'
|
||||||
import DetailOverlay from './components/DetailOverlay.vue'
|
import DetailOverlay from './components/DetailOverlay.vue'
|
||||||
import TickerBar from './components/TickerBar.vue'
|
import TickerBar from './components/TickerBar.vue'
|
||||||
import TopBar from './components/TopBar.vue'
|
import TopBar from './components/TopBar.vue'
|
||||||
|
import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
||||||
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
||||||
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
||||||
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
||||||
@@ -23,10 +25,19 @@ const PAGES: Record<DisplayPageKey, Component> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { bootstrap, dispose, page, settings } = useScoreboard()
|
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<Component>(() => PAGES[page.value])
|
const currentPage = computed<Component>(() => 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(() => ({
|
const stageStyle = computed(() => ({
|
||||||
transform: `scale(${scale.value})`,
|
transform: `scale(${scale.value})`,
|
||||||
left: `${offsetX.value}px`,
|
left: `${offsetX.value}px`,
|
||||||
@@ -72,30 +83,43 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="screen">
|
<div class="root" :style="rootStyle">
|
||||||
<div class="stage" :style="[stageStyle, themeVars]">
|
<WindowedTitleBar v-if="windowed" :title="barTitle" :logo="barLogo" />
|
||||||
<img v-if="background" class="bg" :src="background" alt="" />
|
|
||||||
<div class="bg-fallback" />
|
|
||||||
|
|
||||||
<div class="content">
|
<div class="screen">
|
||||||
<TopBar />
|
<div class="stage" :style="[stageStyle, themeVars]">
|
||||||
<main class="main">
|
<img v-if="background" class="bg" :src="background" alt="" />
|
||||||
<Transition name="page" mode="out-in">
|
<div class="bg-fallback" />
|
||||||
<component :is="currentPage" :key="page" />
|
|
||||||
</Transition>
|
<div class="content">
|
||||||
</main>
|
<TopBar />
|
||||||
<TickerBar />
|
<main class="main">
|
||||||
|
<Transition name="page" mode="out-in">
|
||||||
|
<component :is="currentPage" :key="page" />
|
||||||
|
</Transition>
|
||||||
|
</main>
|
||||||
|
<TickerBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailOverlay />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailOverlay />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
/* kiosk 下 root 里只有 .screen,flex:1 等价于原来的铺满视口 */
|
||||||
.screen {
|
.screen {
|
||||||
position: fixed;
|
position: relative;
|
||||||
inset: 0;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
cursor: none;
|
cursor: none;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 窗口化模式标题栏(单屏调试用)。
|
||||||
|
*
|
||||||
|
* 样式对齐 Console 自绘标题栏(console/renderer/styles.css 的 .topbar):
|
||||||
|
* 同样的 50px 高、8×22 圆角标记、--panel 底色与 --border 分隔线、整条可拖拽。
|
||||||
|
* 这里不引用 MIUIx 令牌——大屏端不加载管理端样式(07 第 9 节),所以直接写死同一组色值。
|
||||||
|
* 不放窗口控制按钮:大屏窗口由 Console 统一管理,本条只负责拖拽与提示。
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
logo: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="titlebar">
|
||||||
|
<div class="brand">
|
||||||
|
<img v-if="logo" class="brand-logo" :src="logo" alt="会标" />
|
||||||
|
<span v-else class="brand-mark" aria-hidden="true" />
|
||||||
|
<span class="brand-title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="hint">按 <kbd>F11</kbd> 切换全屏</span>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
height: var(--display-titlebar-h, 50px);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid #27314a;
|
||||||
|
background: #161d2d;
|
||||||
|
color: #e6ecf7;
|
||||||
|
user-select: none;
|
||||||
|
/* 无边框窗口靠这条拖动;条内没有可点控件,整条都是拖拽区 */
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 8px;
|
||||||
|
height: 22px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(180deg, #4fd8ff, #1b6bb8);
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
height: 26px;
|
||||||
|
max-width: 96px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.brand-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #8b98b4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid #27314a;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #1d2637;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e6ecf7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,20 +3,30 @@ import { onUnmounted, ref } from 'vue'
|
|||||||
export const DESIGN_WIDTH = 1920
|
export const DESIGN_WIDTH = 1920
|
||||||
export const DESIGN_HEIGHT = 1080
|
export const DESIGN_HEIGHT = 1080
|
||||||
|
|
||||||
|
export interface StageScaleOptions {
|
||||||
|
/**
|
||||||
|
* 窗口顶部被标题栏占掉的高度(kiosk 为 0)。
|
||||||
|
* 传 getter 而不是数值:缩放回调在 resize 时重新取值,避免外部状态变化后拿到旧值。
|
||||||
|
*/
|
||||||
|
topInset?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。
|
* 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。
|
||||||
*
|
*
|
||||||
* 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸;
|
* 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸;
|
||||||
* 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。
|
* 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。
|
||||||
*/
|
*/
|
||||||
export function useStageScale() {
|
export function useStageScale(options: StageScaleOptions = {}) {
|
||||||
|
const topInset = options.topInset ?? ((): number => 0)
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
const offsetX = ref(0)
|
const offsetX = ref(0)
|
||||||
const offsetY = ref(0)
|
const offsetY = ref(0)
|
||||||
|
|
||||||
function fit(): void {
|
function fit(): void {
|
||||||
const width = window.innerWidth
|
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)
|
const next = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT)
|
||||||
scale.value = next
|
scale.value = next
|
||||||
offsetX.value = (width - DESIGN_WIDTH * next) / 2
|
offsetX.value = (width - DESIGN_WIDTH * next) / 2
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
Reference in new issue
Block a user