diff --git a/deploy/backup.bat b/deploy/backup.bat new file mode 100644 index 0000000..5345200 --- /dev/null +++ b/deploy/backup.bat @@ -0,0 +1,124 @@ +@echo off +REM ============================================================================ +REM Scoreboard 一键备份(Windows) +REM +REM 用 sqlite3.exe 的 VACUUM INTO 生成一致性快照,服务运行中也能安全执行, +REM 不需要停服务、不需要重启。产物落在 data\backups\,滚动保留最近 20 份。 +REM +REM 依赖:sqlite3.exe(放到本脚本同目录、项目根目录,或加入 PATH 均可)。 +REM 下载地址 https://www.sqlite.org/download.html 的 sqlite-tools-win-x64。 +REM +REM 用法:deploy\backup.bat 手动备份(原因记 manual) +REM deploy\backup.bat publish 发布前备份(原因记 publish) +REM ============================================================================ +setlocal enabledelayedexpansion +chcp 65001 >nul 2>&1 + +cd /d "%~dp0.." +set "ROOT=%CD%" +set "DATA=%ROOT%\data" +set "OUT=%DATA%\backups" +set "KEEP=20" +set "REASON=manual" + +if not "%~1"=="" set "REASON=%~1" +set "REASON=%REASON: =_%" +set "REASON=%REASON:/=-%" +set "REASON=%REASON:\=-%" +set "REASON=%REASON:"=%" + +if not exist "%DATA%" ( + echo [错误] 找不到数据目录:%DATA% + echo 请在项目根目录执行本脚本。 + exit /b 1 +) + +REM ---- 定位 sqlite3.exe ------------------------------------------------------- +set "SQLITE=" +if exist "%~dp0sqlite3.exe" set "SQLITE=%~dp0sqlite3.exe" +if not defined SQLITE if exist "%ROOT%\sqlite3.exe" set "SQLITE=%ROOT%\sqlite3.exe" +if not defined SQLITE for %%I in (sqlite3.exe) do if exist "%%~$PATH:I" set "SQLITE=%%~$PATH:I" +if not defined SQLITE ( + echo [错误] 找不到 sqlite3.exe。 + echo 请把它放到 deploy\ 目录或加入 PATH 后重试。 + exit /b 1 +) + +REM ---- 时间戳:优先 PowerShell,避免受区域设置影响 ----------------------------- +set "TS=" +for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss" 2^>nul`) do set "TS=%%I" +if not defined TS call :fallback_ts +if not defined TS ( + echo [错误] 无法生成时间戳。 + exit /b 1 +) + +if not exist "%OUT%" mkdir "%OUT%" + +echo === Scoreboard 备份 === +echo 数据目录:%DATA% +echo 备份目录:%OUT% +echo 时间戳 :%TS% +echo. + +set /a OK=0 +set /a FAIL=0 + +if exist "%DATA%\system.db" call :backup "system" "%DATA%\system.db" + +for /d %%D in ("%DATA%\event_projs\*") do ( + if exist "%%~fD\event.db" call :backup "%%~nxD" "%%~fD\event.db" +) + +echo. +echo === 滚动清理(保留最近 %KEEP% 份)=== +set /a N=0 +for /f "delims=" %%F in ('dir /b /o-d /a-d "%OUT%\*.db" 2^>nul') do ( + set /a N+=1 + if !N! GTR %KEEP% ( + echo 删除 %%F + del /f /q "%OUT%\%%F" >nul 2>&1 + ) +) + +echo. +echo === 备份完成:成功 !OK! 个,失败 !FAIL! 个 === +dir /b "%OUT%\*.db" 2>nul + +if !FAIL! GTR 0 ( + endlocal + exit /b 1 +) +endlocal +exit /b 0 + +REM ---- 备份单个库:%1 文件名前缀,%2 数据库路径 ------------------------------- +:backup +set "PREFIX=%~1" +set "DBFILE=%~2" +set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%.db" +if exist "%TARGET%" set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%-%RANDOM%.db" + +echo 备份 %DBFILE% +echo -^> %TARGET% +"%SQLITE%" "%DBFILE%" "VACUUM INTO '%TARGET%'" +if errorlevel 1 ( + echo [失败] sqlite3 返回错误 + set /a FAIL+=1 +) else ( + set /a OK+=1 +) +exit /b 0 + +REM ---- %DATE%/%TIME% 兜底:只保证名字不重复 ----------------------------------- +:fallback_ts +set "D=%DATE%" +set "T=%TIME%" +set "D=%D:/=%" +set "D=%D:-=%" +set "D=%D:.=%" +set "T=%T::=%" +set "T=%T:.=%" +set "T=%T: =0%" +set "TS=%D%-%T%" +exit /b 0 diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go new file mode 100644 index 0000000..c5437ea --- /dev/null +++ b/server/internal/auth/auth.go @@ -0,0 +1,401 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// Cookie 名称与会话默认值(见 devdocs/08-安全与可靠性 第 1 节)。 +const ( + CookieSession = "scoreboard_session" + CookieCSRF = "csrf_token" + + // DefaultSessionTTL 是会话有效期:12h。 + DefaultSessionTTL = 12 * time.Hour + // DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。 + DefaultBcryptCost = 12 + // DefaultLoginPerMinute 登录限流:5 次/分钟/IP。 + DefaultLoginPerMinute = 5 + // DefaultWritePerMinute 写操作限流:30 次/分钟/用户。 + DefaultWritePerMinute = 30 +) + +// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较, +// 避免用响应时间探测用户名是否存在。 +const dummyBcryptHash = "$2a$12$m3MLFvKxRAgD/5cksQn9wuFpm/NmV0nyAWAJkZMCyT7s7oAoHWX8q" + +// 认证相关哨兵错误,调用方据此映射 REST 错误码。 +var ( + ErrInvalidCredentials = errors.New("auth: 用户名或密码错误") + ErrUserInactive = errors.New("auth: 用户已停用") + ErrSessionNotFound = errors.New("auth: 会话不存在或已过期") + ErrRateLimited = errors.New("auth: 触发限流") +) + +// User 是登录用户;guest 不落库,用 RoleGuest 表示。 +type User struct { + ID int64 + Username string + DisplayName string + Role Role + Active bool + MustChangePassword bool + LastLoginAt string +} + +// Session 是一次登录产生的服务端会话。 +type Session struct { + ID string + UserID int64 + ExpiresAt time.Time + IP string + UserAgent string +} + +// Options 是认证服务的可调参数;零值即文档默认。 +type Options struct { + Logger *slog.Logger + SessionTTL time.Duration + BcryptCost int + LoginPerMinute int + WritePerMinute int + // Secure 为 true 时 Cookie 带 Secure 标记(HTTPS 部署)。 + Secure bool + // Now 便于测试注入时间;默认 time.Now。 + Now func() time.Time +} + +// Service 封装用户与会话的读写、密码校验与限流。 +type Service struct { + db *sql.DB + log *slog.Logger + sessionTTL time.Duration + bcryptCost int + secureCookies bool + now func() time.Time + loginLimiter *Limiter + writeLimiter *Limiter +} + +// New 构建认证服务;db 为系统库(data/system.db)。 +func New(db *sql.DB, opts Options) *Service { + logger := opts.Logger + if logger == nil { + logger = slog.Default() + } + ttl := opts.SessionTTL + if ttl <= 0 { + ttl = DefaultSessionTTL + } + cost := opts.BcryptCost + if cost <= 0 { + cost = DefaultBcryptCost + } + now := opts.Now + if now == nil { + now = time.Now + } + loginLimit := opts.LoginPerMinute + if loginLimit == 0 { + loginLimit = DefaultLoginPerMinute + } + writeLimit := opts.WritePerMinute + if writeLimit == 0 { + writeLimit = DefaultWritePerMinute + } + return &Service{ + db: db, + log: logger, + sessionTTL: ttl, + bcryptCost: cost, + secureCookies: opts.Secure, + now: now, + loginLimiter: NewLimiter(loginLimit, time.Minute, now), + writeLimiter: NewLimiter(writeLimit, time.Minute, now), + } +} + +// SessionTTL 返回会话有效期。 +func (s *Service) SessionTTL() time.Duration { return s.sessionTTL } + +// AllowLogin 按 IP 限流登录。 +func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) } + +// AllowWrite 按用户限流写操作。 +func (s *Service) AllowWrite(userID int64) bool { + return s.writeLimiter.Allow(fmt.Sprintf("u:%d", userID)) +} + +// EnsureDefaultAdmin 在 users 表为空时创建默认管理员,返回随机初始密码。 +// 密码只在此处打印一次,数据库里只存 bcrypt 哈希。 +func (s *Service) EnsureDefaultAdmin(ctx context.Context) (username, password string, created bool, err error) { + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return "", "", false, fmt.Errorf("统计用户: %w", err) + } + if count > 0 { + return "", "", false, nil + } + + password, err = randomPassword(16) + if err != nil { + return "", "", false, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost) + if err != nil { + return "", "", false, fmt.Errorf("生成密码哈希: %w", err) + } + username = "admin" + _, err = s.db.ExecContext(ctx, + `INSERT INTO users (username, password_hash, display_name, role, active, must_change_password) + VALUES (?, ?, ?, 'admin', 1, 1)`, + username, string(hash), "系统管理员") + if err != nil { + return "", "", false, fmt.Errorf("创建默认管理员: %w", err) + } + return username, password, true, nil +} + +// Authenticate 校验用户名密码并创建会话,返回用户与不落库的明文 token。 +func (s *Service) Authenticate(ctx context.Context, username, password, ip, userAgent string) (*User, string, error) { + if !s.AllowLogin(ip) { + return nil, "", ErrRateLimited + } + + var ( + u User + hash string + role string + ) + err := s.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, display_name, role, active, must_change_password, + COALESCE(last_login_at, '') + FROM users WHERE username = ?`, strings.TrimSpace(username)). + Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt) + switch { + case errors.Is(err, sql.ErrNoRows): + // 用户名不存在也走一次 bcrypt,避免用响应时间探测用户是否存在。 + _ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(password)) + return nil, "", ErrInvalidCredentials + case err != nil: + return nil, "", fmt.Errorf("查询用户: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return nil, "", ErrInvalidCredentials + } + if !u.Active { + return nil, "", ErrUserInactive + } + u.Role = ParseRole(role) + + token, _, err := s.createSession(ctx, u.ID, ip, userAgent) + if err != nil { + return nil, "", err + } + if _, err := s.db.ExecContext(ctx, + `UPDATE users SET last_login_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`, u.ID); err != nil { + return nil, "", fmt.Errorf("更新登录时间: %w", err) + } + u.LastLoginAt = s.now().UTC().Format(time.DateTime) + + s.loginLimiter.Reset(ip) + return &u, token, nil +} + +// Lookup 按明文 token 查会话与用户;过期或停用都返回 ErrSessionNotFound。 +func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, error) { + if strings.TrimSpace(token) == "" { + return nil, nil, ErrSessionNotFound + } + hash := hashToken(token) + + var ( + u User + sess Session + expiresAt string + role string + ) + err := s.db.QueryRowContext(ctx, + `SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''), + u.id, u.username, u.display_name, u.role, u.active, u.must_change_password, + COALESCE(u.last_login_at, '') + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`, + hash, s.now().UTC().Format(time.DateTime)). + Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, + &u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, nil, ErrSessionNotFound + case err != nil: + return nil, nil, fmt.Errorf("查询会话: %w", err) + } + + exp, err := parseDBTime(expiresAt) + if err != nil { + return nil, nil, err + } + sess.ExpiresAt = exp + u.Role = ParseRole(role) + + _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, + s.now().UTC().Format(time.DateTime), sess.ID) + return &u, &sess, nil +} + +// Logout 删除会话(服务端强制下线)。 +func (s *Service) Logout(ctx context.Context, token string) error { + if strings.TrimSpace(token) == "" { + return nil + } + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hashToken(token)) + if err != nil { + return fmt.Errorf("删除会话: %w", err) + } + return nil +} + +// RevokeUserSessions 强制某用户全部下线。 +func (s *Service) RevokeUserSessions(ctx context.Context, userID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent string) (string, *Session, error) { + token, err := randomToken(32) + if err != nil { + return "", nil, err + } + id, err := randomToken(16) + if err != nil { + return "", nil, err + } + expiresAt := s.now().UTC().Add(s.sessionTTL) + _, err = s.db.ExecContext(ctx, + `INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, userID, hashToken(token), expiresAt.Format(time.DateTime), ip, userAgent, s.now().UTC().Format(time.DateTime)) + if err != nil { + return "", nil, fmt.Errorf("创建会话: %w", err) + } + return token, &Session{ID: id, UserID: userID, ExpiresAt: expiresAt, IP: ip, UserAgent: userAgent}, nil +} + +// ---------- Cookie 与 CSRF ---------- + +// SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。 +func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: CookieSession, + Value: token, + Path: "/", + MaxAge: int(s.sessionTTL.Seconds()), + HttpOnly: true, + Secure: s.secureCookies, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSessionCookie 删除会话 Cookie。 +func (s *Service) ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieSession, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.secureCookies, + SameSite: http.SameSiteLaxMode, + }) +} + +// SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。 +func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: CookieCSRF, + Value: token, + Path: "/", + MaxAge: int(s.sessionTTL.Seconds()), + HttpOnly: false, + Secure: s.secureCookies, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearCSRFCookie 删除 CSRF Cookie。 +func (s *Service) ClearCSRFCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieCSRF, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: false, + Secure: s.secureCookies, + SameSite: http.SameSiteLaxMode, + }) +} + +// NewCSRFToken 生成新的 CSRF Token。 +func NewCSRFToken() (string, error) { return randomToken(32) } + +// ValidCSRF 比较请求头与 Cookie 里的 CSRF Token(常数时间比较)。 +func ValidCSRF(header, cookie string) bool { + if header == "" || cookie == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(header), []byte(cookie)) == 1 +} + +// ---------- 辅助 ---------- + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func randomToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("生成随机数: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// randomPassword 生成去掉易混淆字符的随机密码。 +func randomPassword(n int) (string, error) { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("生成随机密码: %w", err) + } + out := make([]byte, n) + for i, b := range buf { + out[i] = alphabet[int(b)%len(alphabet)] + } + return string(out), nil +} + +// parseDBTime 解析迁移里 datetime('now') 写入的 UTC 时间。 +func parseDBTime(v string) (time.Time, error) { + for _, layout := range []string{time.DateTime, "2006-01-02T15:04:05Z07:00", time.RFC3339} { + if t, err := time.Parse(layout, v); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("auth: 无法解析时间 %q", v) +} diff --git a/server/internal/auth/ratelimit.go b/server/internal/auth/ratelimit.go new file mode 100644 index 0000000..2331714 --- /dev/null +++ b/server/internal/auth/ratelimit.go @@ -0,0 +1,59 @@ +package auth + +import ( + "sync" + "time" +) + +// Limiter 是按键(IP / 用户)计数的滑动窗口限流器。 +// 进程内内存实现:现场单实例部署足够,不引入额外存储。 +type Limiter struct { + mu sync.Mutex + hits map[string][]time.Time + limit int + window time.Duration + now func() time.Time +} + +// NewLimiter 创建限流器;limit <= 0 表示不限流。 +func NewLimiter(limit int, window time.Duration, now func() time.Time) *Limiter { + if now == nil { + now = time.Now + } + return &Limiter{hits: make(map[string][]time.Time), limit: limit, window: window, now: now} +} + +// Allow 记录一次访问并报告是否放行。 +func (l *Limiter) Allow(key string) bool { + if l == nil || l.limit <= 0 { + return true + } + now := l.now() + cutoff := now.Add(-l.window) + + l.mu.Lock() + defer l.mu.Unlock() + + kept := l.hits[key][:0] + for _, t := range l.hits[key] { + if t.After(cutoff) { + kept = append(kept, t) + } + } + if len(kept) >= l.limit { + l.hits[key] = kept + return false + } + l.hits[key] = append(kept, now) + return true +} + +// Reset 清空某个键的计数,例如登录成功后不再累计失败次数。 +func (l *Limiter) Reset(key string) { + if l == nil { + return + } + l.mu.Lock() + delete(l.hits, key) + l.mu.Unlock() +} diff --git a/server/internal/auth/role.go b/server/internal/auth/role.go new file mode 100644 index 0000000..a240618 --- /dev/null +++ b/server/internal/auth/role.go @@ -0,0 +1,49 @@ +// Package auth 提供用户、会话与权限模型。 +// +// 角色是包含关系:admin ⊇ recorder ⊇ guest;guest 是「未登录」的默认身份, +// 不落库(见 devdocs/08-安全与可靠性 第 2 节)。 +package auth + +import "strings" + +// Role 是权限角色。 +type Role string + +const ( + // RoleGuest 未登录访客:只读赛事数据,禁读系统配置。 + RoleGuest Role = "guest" + // RoleRecorder 记录员:guest 全部 + 录入、发布、撤销、大屏控制。 + RoleRecorder Role = "recorder" + // RoleAdmin 管理员:recorder 全部 + 系统配置、审计、备份。 + RoleAdmin Role = "admin" +) + +func roleRank(r Role) int { + switch r { + case RoleAdmin: + return 2 + case RoleRecorder: + return 1 + default: + return 0 + } +} + +// Allows 报告 r 是否覆盖 need,即 admin ⊇ recorder ⊇ guest。 +func (r Role) Allows(need Role) bool { + return roleRank(r) >= roleRank(need) +} + +// ParseRole 把数据库里的角色字符串收敛成已知角色;未知值按最低权限处理。 +func ParseRole(s string) Role { + switch Role(strings.ToLower(strings.TrimSpace(s))) { + case RoleAdmin: + return RoleAdmin + case RoleRecorder: + return RoleRecorder + default: + return RoleGuest + } +} + +func (r Role) String() string { return string(r) } diff --git a/server/internal/export/backup.go b/server/internal/export/backup.go new file mode 100644 index 0000000..bffc320 --- /dev/null +++ b/server/internal/export/backup.go @@ -0,0 +1,198 @@ +package export + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// DefaultBackupKeep 是滚动保留的备份份数(见 08-安全与可靠性 第 6 节)。 +const DefaultBackupKeep = 20 + +// BackupOptions 描述一次备份。 +type BackupOptions struct { + // Dir 是备份目录,通常为 /backups。 + Dir string + // Prefix 是文件名前缀,用于区分不同库(如 event / system)。 + Prefix string + // Reason 记录触发原因:manual / publish / auto。 + Reason string + // Keep 是滚动保留份数,<=0 时用 DefaultBackupKeep。 + Keep int + // SkipPrune 为 true 时只备份不清理,供「手动留档」场景使用。 + SkipPrune bool + // Now 便于测试注入固定时间;零值取当前时间。 + Now time.Time +} + +// BackupInfo 是一份备份的描述。 +type BackupInfo struct { + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"createdAt"` +} + +// Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。 +// VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。 +func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) { + if db == nil { + return BackupInfo{}, ErrNoDatabase + } + dir := strings.TrimSpace(opts.Dir) + if dir == "" { + return BackupInfo{}, errors.New("export: 备份目录为空") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err) + } + + prefix := strings.TrimSpace(opts.Prefix) + if prefix == "" { + prefix = "db" + } + reason := strings.TrimSpace(opts.Reason) + if reason == "" { + reason = "manual" + } + now := nowOr(opts.Now) + + path := uniqueBackupPath(dir, prefix, reason, now) + if err := vacuumInto(db, path); err != nil { + return BackupInfo{}, err + } + sum, size, err := hashFile(path) + if err != nil { + return BackupInfo{}, err + } + // 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。 + _ = os.Chtimes(path, now, now) + + info := BackupInfo{ + Path: path, + Name: filepath.Base(path), + Size: size, + SHA256: sum, + Reason: reason, + CreatedAt: now, + } + if !opts.SkipPrune { + if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil { + return info, err + } + } + return info, nil +} + +// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish, +// 便于在备份列表里一眼看出哪些是发布节点留的。 +func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) { + if strings.TrimSpace(opts.Reason) == "" { + opts.Reason = "publish" + } + return Backup(db, opts) +} + +// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。 +// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。 +func ListBackups(dir, prefix string) ([]BackupInfo, error) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err) + } + + prefix = strings.TrimSpace(prefix) + out := make([]BackupInfo, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") { + continue + } + if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") { + continue + } + info, err := e.Info() + if err != nil { + return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err) + } + out = append(out, BackupInfo{ + Path: filepath.Join(dir, e.Name()), + Name: e.Name(), + Size: info.Size(), + Reason: reasonFromName(e.Name(), prefix), + CreatedAt: info.ModTime(), + }) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.After(out[j].CreatedAt) + } + return out[i].Name > out[j].Name + }) + return out, nil +} + +// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。 +func LatestBackup(dir, prefix string) (BackupInfo, bool, error) { + list, err := ListBackups(dir, prefix) + if err != nil || len(list) == 0 { + return BackupInfo{}, false, err + } + return list[0], true, nil +} + +// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。 +func PruneBackups(dir, prefix string, keep int) ([]string, error) { + if keep <= 0 { + keep = DefaultBackupKeep + } + list, err := ListBackups(dir, prefix) + if err != nil { + return nil, err + } + if len(list) <= keep { + return nil, nil + } + + removed := make([]string, 0, len(list)-keep) + for _, b := range list[keep:] { + if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) { + return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err) + } + removed = append(removed, b.Path) + } + return removed, nil +} + +func uniqueBackupPath(dir, prefix, reason string, now time.Time) string { + base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405")) + p := filepath.Join(dir, base+".db") + for i := 2; ; i++ { + if _, err := os.Stat(p); os.IsNotExist(err) { + return p + } + p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i)) + } +} + +// reasonFromName 从 ---.db 里取回原因。 +func reasonFromName(name, prefix string) string { + base := strings.TrimSuffix(name, filepath.Ext(name)) + if prefix != "" { + base = strings.TrimPrefix(base, prefix+"-") + } + parts := strings.Split(base, "-") + if len(parts) >= 3 { + return strings.Join(parts[:len(parts)-2], "-") + } + return "" +} diff --git a/server/internal/export/backup_test.go b/server/internal/export/backup_test.go new file mode 100644 index 0000000..ffd3c4d --- /dev/null +++ b/server/internal/export/backup_test.go @@ -0,0 +1,123 @@ +package export + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestBackupAndRetention(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dir := t.TempDir() + base := time.Date(2026, 9, 9, 10, 0, 0, 0, time.Local) + + info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "publish", Keep: 3, Now: base}) + if err != nil { + t.Fatalf("备份: %v", err) + } + if info.Size == 0 || info.SHA256 == "" { + t.Errorf("备份信息不完整: %+v", info) + } + if info.Reason != "publish" { + t.Errorf("备份原因 = %q,期望 publish", info.Reason) + } + if _, err := os.Stat(info.Path); err != nil { + t.Fatalf("备份文件不存在: %v", err) + } + + // 快照必须可打开,且数据与源库一致。 + bdb := openSQLite(t, info.Path) + if got := countRows(t, bdb, "teams"); got != 2 { + t.Errorf("备份内 teams = %d,期望 2", got) + } + if got := countRows(t, bdb, "score_ledger"); got != 2 { + t.Errorf("备份内 score_ledger = %d,期望 2", got) + } + _ = bdb.Close() + + // 再生成 5 份,Keep=3 应只留最近 3 份。 + for i := 1; i <= 5; i++ { + if _, err := Backup(db, BackupOptions{ + Dir: dir, Prefix: "event", Reason: "auto", Keep: 3, + Now: base.Add(time.Duration(i) * time.Minute), + }); err != nil { + t.Fatalf("第 %d 次备份: %v", i, err) + } + } + list, err := ListBackups(dir, "event") + if err != nil { + t.Fatalf("列出备份: %v", err) + } + if len(list) != 3 { + t.Fatalf("保留份数 = %d,期望 3", len(list)) + } + if list[0].Reason != "auto" { + t.Errorf("最新备份原因 = %q,期望 auto", list[0].Reason) + } + + latest, ok, err := LatestBackup(dir, "event") + if err != nil || !ok { + t.Fatalf("最近备份: ok=%v err=%v", ok, err) + } + if !latest.CreatedAt.Equal(base.Add(5 * time.Minute)) { + t.Errorf("最近备份时间 = %s,期望 %s", latest.CreatedAt, base.Add(5*time.Minute)) + } + + if _, err := PruneBackups(dir, "event", 1); err != nil { + t.Fatalf("手动清理: %v", err) + } + list, err = ListBackups(dir, "event") + if err != nil { + t.Fatalf("清理后列出备份: %v", err) + } + if len(list) != 1 { + t.Errorf("清理后份数 = %d,期望 1", len(list)) + } +} + +func TestBackupBeforePublishAndUniqueNames(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dir := t.TempDir() + base := time.Date(2026, 9, 9, 11, 0, 0, 0, time.Local) + + a, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true}) + if err != nil { + t.Fatalf("备份 A: %v", err) + } + b, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true}) + if err != nil { + t.Fatalf("备份 B: %v", err) + } + if a.Path == b.Path { + t.Errorf("同一秒的两份备份重名: %s", a.Path) + } + + info, err := BackupBeforePublish(db, BackupOptions{Dir: dir, Prefix: "event", Keep: 20, Now: base.Add(time.Hour)}) + if err != nil { + t.Fatalf("发布前备份: %v", err) + } + if info.Reason != "publish" { + t.Errorf("发布前备份原因 = %q,期望 publish", info.Reason) + } + if filepath.Dir(info.Path) != dir { + t.Errorf("备份目录 = %q,期望 %q", filepath.Dir(info.Path), dir) + } +} + +func TestListBackupsMissingDir(t *testing.T) { + list, err := ListBackups(filepath.Join(t.TempDir(), "不存在"), "event") + if err != nil { + t.Fatalf("目录不存在不应报错: %v", err) + } + if len(list) != 0 { + t.Errorf("备份数 = %d,期望 0", len(list)) + } + if _, ok, err := LatestBackup(filepath.Join(t.TempDir(), "不存在"), "event"); err != nil || ok { + t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err) + } +} diff --git a/server/internal/export/doc.go b/server/internal/export/doc.go new file mode 100644 index 0000000..6e5ab77 --- /dev/null +++ b/server/internal/export/doc.go @@ -0,0 +1,29 @@ +// Package export 负责成绩导出(xlsx)、赛事项目包(zip)与数据库备份。 +// +// 本包只做三件事:把数据整理成可直接打印的表格、把整场赛事打包/解包、把库 +// 备份成一致性快照。所有业务数值(积分、名次、奖牌)都来自调用方传入的 +// domain 结构体或赛事库本身,这里不重新实现任何计分规则——规则只有一份, +// 在 internal/domain。 +// +// 依赖方向:export → domain(只读类型)与 database/sql;domain 不认识 export。 +package export + +import "errors" + +// 导出的哨兵错误,调用方据此把失败原因映射成 HTTP 状态码或界面提示。 +var ( + // ErrNoDatabase 表示调用方传入了空的数据库连接。 + ErrNoDatabase = errors.New("export: 数据库连接为空") + // ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。 + ErrInvalidPackage = errors.New("export: 赛事项目包非法") + // ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。 + ErrUnsupportedVersion = errors.New("export: 赛事项目包格式版本不受支持") + // ErrChecksumMismatch 表示包内文件与 meta.yaml 记录的校验和不一致。 + ErrChecksumMismatch = errors.New("export: 赛事项目包校验和不匹配") + // ErrProjectExists 表示目标目录已有同名赛事项目,且调用方未选择覆盖。 + ErrProjectExists = errors.New("export: 目标赛事项目已存在") + // ErrUnsafeName 表示赛事项目名含有路径分隔符、保留名等不安全的字符。 + ErrUnsafeName = errors.New("export: 赛事项目名非法") + // ErrTeamNotFound 表示导出单位积分明细时找不到该单位。 + ErrTeamNotFound = errors.New("export: 单位不存在") +) diff --git a/server/internal/export/event.go b/server/internal/export/event.go new file mode 100644 index 0000000..436209c --- /dev/null +++ b/server/internal/export/event.go @@ -0,0 +1,152 @@ +package export + +import ( + "database/sql" + "io" + "strings" + + "github.com/xuri/excelize/v2" +) + +// EventResultRow 是分项成绩表的一行。 +// Rank 用字符串是为了承载「1(并列)」这种展示值。 +type EventResultRow struct { + Rank string + Bib string + TeamName string + Participant string + ResultText string + Unit string + Points float64 + Status string + Remark string +} + +// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。 +type EventSheet struct { + SportName string + EventName string + Category string + Rows []EventResultRow +} + +// EventsWorkbook 是「分项成绩表」的内容:一个小项可含多个组别 sheet, +// 也可承载全部小项(ExportAllXLSX 用)。 +type EventsWorkbook struct { + Meta Meta + Sheets []EventSheet +} + +var eventColumns = []column{ + {"名次", 8, kindCenter}, + {"号码", 9, kindCenter}, + {"单位", 22, kindText}, + {"运动员/队", 18, kindText}, + {"成绩", 12, kindCenter}, + {"积分", 8, kindNumber}, + {"状态", 8, kindCenter}, + {"备注", 20, kindText}, +} + +// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。 +func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error { + wb, err := LoadEventWorkbook(db, eventID, opts) + if err != nil { + return err + } + f, err := eventFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteEventXLSX 把分项成绩表写成 xlsx 流。 +func WriteEventXLSX(w io.Writer, wb EventsWorkbook) error { + f, err := eventFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func eventFile(wb EventsWorkbook) (*excelize.File, error) { + return buildWorkbook("成绩表", func(f *excelize.File, st *styleSet) error { + return writeEventSheets(f, st, wb.Meta, wb.Sheets, &sheetNamer{}) + }) +} + +func writeEventSheets(f *excelize.File, st *styleSet, meta Meta, sheets []EventSheet, namer *sheetNamer) error { + meta = meta.normalized() + for _, sh := range sheets { + name := namer.add(eventSheetBase(sh)) + if err := addSheet(f, name); err != nil { + return err + } + if err := writeSheet(f, st, name, meta, eventSheetTitle(meta, sh), eventColumns, eventRows(sh.Rows)); err != nil { + return err + } + } + return nil +} + +// eventSheetBase 是工作表名:大项·小项·组别,够短且能区分。 +func eventSheetBase(sh EventSheet) string { + parts := make([]string, 0, 3) + if sh.SportName != "" { + parts = append(parts, sh.SportName) + } + parts = append(parts, sh.EventName) + if sh.Category != "" { + parts = append(parts, sh.Category) + } + return strings.Join(parts, "·") +} + +// eventSheetTitle 是表内大标题,带赛事名。 +func eventSheetTitle(meta Meta, sh EventSheet) string { + base := eventSheetBase(sh) + if base == "" { + base = "成绩表" + } + return sheetTitle(meta, base) +} + +func eventRows(rows []EventResultRow) [][]any { + out := make([][]any, 0, len(rows)) + for _, r := range rows { + out = append(out, []any{ + r.Rank, r.Bib, r.TeamName, r.Participant, + displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark, + }) + } + return out +} + +// displayResult 把成绩与单位拼成「11.58 秒」;单位已含在文本里时不重复拼接。 +func displayResult(text, unit string) string { + text = strings.TrimSpace(text) + unit = strings.TrimSpace(unit) + if text == "" { + return "" + } + if unit == "" || strings.HasSuffix(text, unit) { + return text + } + return text + " " + unitLabel(unit) +} + +func unitLabel(unit string) string { + switch unit { + case "s": + return "秒" + case "m": + return "米" + case "pts": + return "分" + case "次": + return "次" + default: + return unit + } +} diff --git a/server/internal/export/helpers_test.go b/server/internal/export/helpers_test.go new file mode 100644 index 0000000..220b178 --- /dev/null +++ b/server/internal/export/helpers_test.go @@ -0,0 +1,87 @@ +package export + +import ( + "database/sql" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// openEventDB 建一个临时赛事库并跑完迁移;DSN 与 store.Open 保持一致。 +func openEventDB(t *testing.T) *sql.DB { + t.Helper() + + path := filepath.Join(t.TempDir(), "event.db") + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("迁移赛事库: %v", err) + } + return db +} + +// seedEvent 造一场最小可用赛事:2 个单位、2 名运动员、1 个小项 1 个组别、 +// 2 条已发布成绩与对应积分流水。 +func seedEvent(t *testing.T, db *sql.DB) { + t.Helper() + + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`, + `INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 2)`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 2, '102')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 2, 2, '11.90', 11.90, 's', 'published')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (2, 2, 1, 2, 2, 'result', 2, 8)`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + `UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +func countRows(t *testing.T, db *sql.DB, table string) int { + t.Helper() + var n int + if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatalf("统计 %s: %v", table, err) + } + return n +} + +// openSQLite 以只读方式打开一个已存在的库,用于校验备份 / 导入产物可读。 +func openSQLite(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + t.Fatalf("打开 %s: %v", path, err) + } + return db +} diff --git a/server/internal/export/load.go b/server/internal/export/load.go new file mode 100644 index 0000000..0e8d23b --- /dev/null +++ b/server/internal/export/load.go @@ -0,0 +1,641 @@ +package export + +import ( + "database/sql" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。 +type Meta struct { + EventName string + Subtitle string + Organizer string + GeneratedAt time.Time + GeneratedBy string +} + +func (m Meta) normalized() Meta { + if m.GeneratedAt.IsZero() { + m.GeneratedAt = time.Now() + } + return m +} + +// Options 是导出选项;零值即「正式成绩」口径。 +type Options struct { + Meta Meta + // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。 + // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 + IncludeDrafts bool +} + +// resolveMeta 读一次 settings,把留空的抬头字段补齐;顺带把设置表返回给调用方复用。 +func resolveMeta(db *sql.DB, meta Meta) (Meta, map[string]string, error) { + settings, err := loadSettings(db) + if err != nil { + return Meta{}, nil, err + } + if meta.EventName == "" { + meta.EventName = settings["event.name"] + } + if meta.Subtitle == "" { + meta.Subtitle = settings["event.subtitle"] + } + if meta.Organizer == "" { + meta.Organizer = settings["event.organizer"] + } + return meta.normalized(), settings, nil +} + +func loadSettings(db *sql.DB) (map[string]string, error) { + rows, err := db.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, fmt.Errorf("export: 读取赛事设置: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("export: 扫描赛事设置: %w", err) + } + out[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历赛事设置: %w", err) + } + return out, nil +} + +// LoadStandingsWorkbook 读取单位、运动员、积分流水与名次,交给 domain 算出三个榜。 +// 排名规则只在 domain 里有一份,这里不做任何二次计算。 +func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error) { + if db == nil { + return StandingsWorkbook{}, ErrNoDatabase + } + meta, settings, err := resolveMeta(db, opts.Meta) + if err != nil { + return StandingsWorkbook{}, err + } + + teams, err := loadTeams(db) + if err != nil { + return StandingsWorkbook{}, err + } + athletes, err := loadAthletes(db) + if err != nil { + return StandingsWorkbook{}, err + } + ledger, err := loadLedger(db) + if err != nil { + return StandingsWorkbook{}, err + } + ranked, err := loadRanked(db) + if err != nil { + return StandingsWorkbook{}, err + } + + in := domain.StandingsInput{ + Teams: teams, + Athletes: athletes, + Ledger: ledger, + Ranked: ranked, + IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1), + ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"), + } + return StandingsWorkbook{ + Meta: meta, + Team: domain.BuildTeamStandings(in), + Medal: domain.BuildMedalStandings(in), + Athlete: domain.BuildAthleteStandings(in), + }, nil +} + +func loadTeams(db *sql.DB) ([]domain.Team, error) { + // 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。 + rows, err := db.Query(` + SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), + COALESCE(color, ''), sort_order, active + FROM teams WHERE active = 1 + ORDER BY sort_order, code`) + if err != nil { + return nil, fmt.Errorf("export: 读取单位: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.Team + for rows.Next() { + var t domain.Team + var active int + if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil { + return nil, fmt.Errorf("export: 扫描单位: %w", err) + } + t.Active = active != 0 + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历单位: %w", err) + } + return out, nil +} + +func loadAthletes(db *sql.DB) ([]domain.Athlete, error) { + // 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。 + rows, err := db.Query(` + SELECT id, name, COALESCE(gender, ''), COALESCE(team_id, 0), COALESCE(employee_no, ''), active + FROM athletes ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("export: 读取运动员: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.Athlete + for rows.Next() { + var a domain.Athlete + var active int + if err := rows.Scan(&a.ID, &a.Name, &a.Gender, &a.TeamID, &a.EmployeeNo, &active); err != nil { + return nil, fmt.Errorf("export: 扫描运动员: %w", err) + } + a.Active = active != 0 + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历运动员: %w", err) + } + return out, nil +} + +func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) { + rows, err := db.Query(` + SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0), + COALESCE(entry_id, 0), COALESCE(result_id, 0), + source_type, source_id, points, voided, COALESCE(void_reason, '') + FROM score_ledger ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("export: 读取积分流水: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.LedgerEntry + for rows.Next() { + var e domain.LedgerEntry + var voided int + if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, + &e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil { + return nil, fmt.Errorf("export: 扫描积分流水: %w", err) + } + e.Voided = voided != 0 + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历积分流水: %w", err) + } + return out, nil +} + +// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。 +// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要 +// 重算分值,也就能保证与正式计分结果完全一致。 +func loadRanked(db *sql.DB) ([]domain.RankedResult, error) { + scopes, err := loadChannelScopes(db) + if err != nil { + return nil, err + } + + rows, err := db.Query(` + SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id, + COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value + FROM results r + JOIN entries en ON en.id = r.entry_id + JOIN events e ON e.id = r.event_id + LEFT JOIN athletes a ON a.id = en.athlete_id + WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 + AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok' + ORDER BY r.event_id, r.rank, r.id`) + if err != nil { + return nil, fmt.Errorf("export: 读取名次: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.RankedResult + for rows.Next() { + var ( + rr domain.RankedResult + resultValue sql.NullFloat64 + ) + if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID, + &rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil { + return nil, fmt.Errorf("export: 扫描名次: %w", err) + } + if resultValue.Valid { + v := resultValue.Float64 + rr.ResultValue = &v + } + if rr.TeamID == 0 { + continue + } + // 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚, + // 因此按 scope 展开成多条明细。 + for _, sc := range scopes[rr.EventID] { + if sc == domain.ScopeAthlete && rr.AthleteID == 0 { + continue + } + item := rr + item.Scope = sc + item.Basis = domain.BasisResult + item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} + out = append(out, item) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历名次: %w", err) + } + return out, nil +} + +func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) { + rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`) + if err != nil { + return nil, fmt.Errorf("export: 读取计分通道: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64][]domain.Scope) + for rows.Next() { + var eventID int64 + var scope domain.Scope + if err := rows.Scan(&eventID, &scope); err != nil { + return nil, fmt.Errorf("export: 扫描计分通道: %w", err) + } + if !containsScope(out[eventID], scope) { + out[eventID] = append(out[eventID], scope) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历计分通道: %w", err) + } + return out, nil +} + +func containsScope(list []domain.Scope, scope domain.Scope) bool { + for _, s := range list { + if s == scope { + return true + } + } + return false +} + +// eventInfo 是导出分项成绩表需要的项目信息。 +type eventInfo struct { + id int64 + sportName string + name string +} + +// LoadEventWorkbook 读取一个小项(eventID=0 表示全部)的成绩,按组别拆成多个 sheet。 +func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, error) { + if db == nil { + return EventsWorkbook{}, ErrNoDatabase + } + meta, _, err := resolveMeta(db, opts.Meta) + if err != nil { + return EventsWorkbook{}, err + } + + events, err := loadEvents(db, eventID) + if err != nil { + return EventsWorkbook{}, err + } + if eventID != 0 && len(events) == 0 { + return EventsWorkbook{}, fmt.Errorf("export: 小项 %d 不存在", eventID) + } + + sheets := make([]EventSheet, 0, len(events)) + for _, ev := range events { + cats, err := loadCategories(db, ev.id) + if err != nil { + return EventsWorkbook{}, err + } + if len(cats) == 0 { + rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts) + if err != nil { + return EventsWorkbook{}, err + } + sheets = append(sheets, EventSheet{SportName: ev.sportName, EventName: ev.name, Rows: rows}) + continue + } + for _, cat := range cats { + rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts) + if err != nil { + return EventsWorkbook{}, err + } + sheets = append(sheets, EventSheet{ + SportName: ev.sportName, + EventName: ev.name, + Category: cat.name, + Rows: rows, + }) + } + } + return EventsWorkbook{Meta: meta, Sheets: sheets}, nil +} + +func loadEvents(db *sql.DB, eventID int64) ([]eventInfo, error) { + query := ` + SELECT e.id, COALESCE(s.name, ''), e.name + FROM events e + LEFT JOIN sports s ON s.id = e.sport_id` + args := []any{} + if eventID != 0 { + query += ` WHERE e.id = ?` + args = append(args, eventID) + } + query += ` ORDER BY s.sort_order, e.sort_order, e.id` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("export: 读取小项: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []eventInfo + for rows.Next() { + var ev eventInfo + if err := rows.Scan(&ev.id, &ev.sportName, &ev.name); err != nil { + return nil, fmt.Errorf("export: 扫描小项: %w", err) + } + out = append(out, ev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历小项: %w", err) + } + return out, nil +} + +type categoryInfo struct { + id int64 + name string +} + +func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) { + rows, err := db.Query(` + SELECT id, name FROM event_categories + WHERE event_id = ? ORDER BY sort_order, id`, eventID) + if err != nil { + return nil, fmt.Errorf("export: 读取组别: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []categoryInfo + for rows.Next() { + var c categoryInfo + if err := rows.Scan(&c.id, &c.name); err != nil { + return nil, fmt.Errorf("export: 扫描组别: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历组别: %w", err) + } + return out, nil +} + +// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。 +// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 +func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) { + query := ` + SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), + COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''), + COALESCE(r.result_text, ''), COALESCE(r.unit, ''), + COALESCE((SELECT SUM(l.points) FROM score_ledger l + WHERE l.entry_id = en.id AND l.voided = 0), 0), + r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '') + FROM entries en + JOIN teams t ON t.id = en.team_id + LEFT JOIN athletes a ON a.id = en.athlete_id + LEFT JOIN results r ON r.entry_id = en.id + WHERE en.event_id = ?` + args := []any{eventID} + if categoryID != 0 { + query += ` AND en.category_id = ?` + args = append(args, categoryID) + } + if !includeDrafts { + query += ` AND (r.state IS NULL OR r.state = 'published')` + } + query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("export: 读取成绩: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []EventResultRow + for rows.Next() { + var ( + row EventResultRow + rank, isTie int + memberJSON, athleteName string + resultStat, resultState sql.NullString + entryStat, entryRemark string + resultNote string + ) + if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName, + &row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat, + &entryRemark, &resultNote); err != nil { + return nil, fmt.Errorf("export: 扫描成绩: %w", err) + } + row.Participant = participantName(athleteName, memberJSON) + row.Rank = rankLabel(rank, isTie != 0) + row.Status = statusLabel(resultStat, resultState, entryStat) + row.Remark = joinRemark(entryRemark, resultNote) + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历成绩: %w", err) + } + return out, nil +} + +// LoadTeamDetailWorkbook 读取某单位未被作废的积分流水,逐条列出得分来源。 +func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailWorkbook, error) { + if db == nil { + return TeamDetailWorkbook{}, ErrNoDatabase + } + meta, _, err := resolveMeta(db, opts.Meta) + if err != nil { + return TeamDetailWorkbook{}, err + } + team, err := loadTeam(db, teamID) + if err != nil { + return TeamDetailWorkbook{}, err + } + rows, err := loadTeamDetailRows(db, teamID) + if err != nil { + return TeamDetailWorkbook{}, err + } + return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil +} + +func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) { + var t domain.Team + var active int + err := db.QueryRow(` + SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), + COALESCE(color, ''), sort_order, active + FROM teams WHERE id = ?`, teamID). + Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active) + if err == sql.ErrNoRows { + return domain.Team{}, fmt.Errorf("%w: %d", ErrTeamNotFound, teamID) + } + if err != nil { + return domain.Team{}, fmt.Errorf("export: 读取单位 %d: %w", teamID, err) + } + t.Active = active != 0 + return t, nil +} + +func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { + rows, err := db.Query(` + SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''), + COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''), + l.source_type, l.points, COALESCE(l.void_reason, '') + FROM score_ledger l + LEFT JOIN events e ON e.id = l.event_id + LEFT JOIN sports s ON s.id = e.sport_id + LEFT JOIN entries en ON en.id = l.entry_id + LEFT JOIN event_categories c ON c.id = en.category_id + LEFT JOIN results r ON r.id = l.result_id + LEFT JOIN athletes a ON a.id = l.athlete_id + WHERE l.team_id = ? AND l.voided = 0 + ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID) + if err != nil { + return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err) + } + defer func() { _ = rows.Close() }() + + var out []TeamDetailRow + for rows.Next() { + var r TeamDetailRow + var source string + if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, + &r.ResultText, &source, &r.Points, &r.Remark); err != nil { + return nil, fmt.Errorf("export: 扫描积分明细: %w", err) + } + r.Source = sourceLabel(source) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("export: 遍历积分明细: %w", err) + } + return out, nil +} + +func rankLabel(rank int, isTie bool) string { + if rank <= 0 { + return "" + } + if isTie { + return strconv.Itoa(rank) + "(并列)" + } + return strconv.Itoa(rank) +} + +// statusLabel 把成绩状态翻成中文;没有成绩行时退回报名状态。 +func statusLabel(resultStatus, resultState sql.NullString, entryStatus string) string { + if !resultStatus.Valid { + return entryStatusLabel(entryStatus) + } + if resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft { + return "草稿" + } + switch domain.ResultStatus(resultStatus.String) { + case domain.ResultStatusOK, "": + return "有效" + case domain.ResultStatusDQ: + return "犯规" + case domain.ResultStatusDNS: + return "弃权" + case domain.ResultStatusDNF: + return "未完成" + case domain.ResultStatusWithdrawn: + return "退赛" + default: + return resultStatus.String + } +} + +// joinRemark 合并报名备注与成绩备注,两条都有时用分号隔开。 +func joinRemark(entryRemark, resultNote string) string { + entryRemark = strings.TrimSpace(entryRemark) + resultNote = strings.TrimSpace(resultNote) + switch { + case entryRemark == "": + return resultNote + case resultNote == "": + return entryRemark + default: + return entryRemark + ";" + resultNote + } +} + +func entryStatusLabel(status string) string { + switch domain.EntryStatus(status) { + case domain.EntryStatusRegistered, "": + return "未录入" + case domain.EntryStatusScratched: + return "弃权" + case domain.EntryStatusDQ: + return "犯规" + case domain.EntryStatusDNS: + return "弃权" + default: + return status + } +} + +func sourceLabel(source string) string { + switch domain.SourceType(source) { + case domain.SourceResult: + return "成绩" + case domain.SourceRecordBonus: + return "破纪录加分" + case domain.SourceAdjustment: + return "人工调整" + default: + return source + } +} + +func parseFloatDefault(raw string, def float64) float64 { + v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return def + } + return v +} + +// participantName 取个人姓名;团体项目则把成员 JSON 数组拼成顿号列表。 +func participantName(athleteName, memberJSON string) string { + if strings.TrimSpace(athleteName) != "" { + return athleteName + } + memberJSON = strings.TrimSpace(memberJSON) + if memberJSON == "" { + return "" + } + var names []string + if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 { + return strings.Join(names, "、") + } + return memberJSON +} diff --git a/server/internal/export/project.go b/server/internal/export/project.go new file mode 100644 index 0000000..ece4a54 --- /dev/null +++ b/server/internal/export/project.go @@ -0,0 +1,628 @@ +package export + +import ( + "archive/zip" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" + + _ "modernc.org/sqlite" +) + +const ( + // projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。 + projectFormat = "scoreboard-event-project" + // projectFormatVersion 是当前程序能读写的包格式版本。 + projectFormatVersion = 1 + // eventDBName 是包内赛事库的固定文件名。 + eventDBName = "event.db" + // maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。 + maxExtractBytes = 4 << 30 +) + +// ProjectFile 描述包内一个文件的校验信息。 +type ProjectFile struct { + Path string `yaml:"path"` + Size int64 `yaml:"size"` + SHA256 string `yaml:"sha256"` +} + +// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。 +type ProjectMeta struct { + Format string `yaml:"format"` + FormatVersion int `yaml:"formatVersion"` + Name string `yaml:"name"` + EventName string `yaml:"eventName,omitempty"` + Subtitle string `yaml:"subtitle,omitempty"` + Organizer string `yaml:"organizer,omitempty"` + CreatedAt time.Time `yaml:"createdAt"` + CreatedBy string `yaml:"createdBy,omitempty"` + AppVersion string `yaml:"appVersion,omitempty"` + Database ProjectFile `yaml:"database"` + Files []ProjectFile `yaml:"files"` +} + +// ProjectExportOptions 描述一次赛事包导出。 +type ProjectExportOptions struct { + Name string // 项目名,导入后落成 data/event_projs// + EventName string // 留空则读赛事库 settings + Subtitle string + Organizer string + CreatedBy string + AppVersion string + UploadsDir string // 可选:背景图 / 会标目录 + FontsDir string // 可选:字体目录 + IncludeFonts bool // 是否把字体一并打包 + RulesPath string // 可选:规则包 YAML + Now time.Time +} + +// ImportOptions 描述一次赛事包导入。 +type ImportOptions struct { + // Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。 + Overwrite bool + // Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。 + Rename string +} + +// ImportResult 是导入结果。 +type ImportResult struct { + Name string `json:"name"` + Dir string `json:"dir"` + EventDB string `json:"eventDB"` + Meta ProjectMeta `json:"meta"` +} + +// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。 +// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。 +func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) { + if db == nil { + return ProjectMeta{}, ErrNoDatabase + } + name, err := SafeProjectName(opts.Name) + if err != nil { + return ProjectMeta{}, err + } + + meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer}) + if err != nil { + return ProjectMeta{}, err + } + + tmp, err := os.MkdirTemp("", "scoreboard-project-*") + if err != nil { + return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err) + } + defer func() { _ = os.RemoveAll(tmp) }() + + if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil { + return ProjectMeta{}, err + } + // uploads 即使为空也要保留目录,导入方才知道该往哪放资源。 + uploads := filepath.Join(tmp, "uploads") + if err := os.MkdirAll(uploads, 0o755); err != nil { + return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err) + } + if opts.UploadsDir != "" { + if err := copyTree(opts.UploadsDir, uploads); err != nil { + return ProjectMeta{}, err + } + } + if opts.IncludeFonts && opts.FontsDir != "" { + if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil { + return ProjectMeta{}, err + } + } + if opts.RulesPath != "" { + if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil { + return ProjectMeta{}, err + } + } + + files, err := buildManifest(tmp) + if err != nil { + return ProjectMeta{}, err + } + dbFile, ok := findFile(files, eventDBName) + if !ok { + return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName) + } + + pkg := ProjectMeta{ + Format: projectFormat, + FormatVersion: projectFormatVersion, + Name: name, + EventName: meta.EventName, + Subtitle: meta.Subtitle, + Organizer: meta.Organizer, + CreatedAt: nowOr(opts.Now), + CreatedBy: opts.CreatedBy, + AppVersion: opts.AppVersion, + Database: dbFile, + Files: files, + } + raw, err := yaml.Marshal(&pkg) + if err != nil { + return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err) + } + if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil { + return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err) + } + if err := zipDir(tmp, dst); err != nil { + return ProjectMeta{}, err + } + return pkg, nil +} + +// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。 +func InspectProjectZip(src string) (ProjectMeta, error) { + r, err := zip.OpenReader(src) + if err != nil { + return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err) + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" { + continue + } + rc, err := f.Open() + if err != nil { + return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err) + } + defer func() { _ = rc.Close() }() + + raw, err := io.ReadAll(io.LimitReader(rc, 1<<20)) + if err != nil { + return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err) + } + var meta ProjectMeta + if err := yaml.Unmarshal(raw, &meta); err != nil { + return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err) + } + if err := validateMeta(meta); err != nil { + return ProjectMeta{}, err + } + return meta, nil + } + return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage) +} + +func validateMeta(meta ProjectMeta) error { + if meta.Format != projectFormat { + return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format) + } + if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion { + return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion) + } + if _, err := SafeProjectName(meta.Name); err != nil { + return err + } + if path.Clean(meta.Database.Path) != eventDBName { + return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path) + } + if meta.Database.SHA256 == "" { + return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage) + } + return nil +} + +// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。 +func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) { + meta, err := InspectProjectZip(src) + if err != nil { + return ImportResult{}, err + } + name := meta.Name + if strings.TrimSpace(opts.Rename) != "" { + name = opts.Rename + } + name, err = SafeProjectName(name) + if err != nil { + return ImportResult{}, err + } + + if err := os.MkdirAll(projRoot, 0o755); err != nil { + return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err) + } + tmp, err := os.MkdirTemp(projRoot, ".import-*") + if err != nil { + return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err) + } + defer func() { _ = os.RemoveAll(tmp) }() + + if err := extractZip(src, tmp); err != nil { + return ImportResult{}, err + } + if err := verifyManifest(tmp, meta); err != nil { + return ImportResult{}, err + } + eventDB := filepath.Join(tmp, eventDBName) + if err := verifySQLite(eventDB); err != nil { + return ImportResult{}, err + } + + target := filepath.Join(projRoot, name) + if _, err := os.Stat(target); err == nil { + if !opts.Overwrite { + return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name) + } + stale := target + ".replaced-" + time.Now().Format("20060102-150405") + if err := os.Rename(target, stale); err != nil { + return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err) + } + defer func() { _ = os.RemoveAll(stale) }() + } + if err := os.Rename(tmp, target); err != nil { + return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err) + } + return ImportResult{ + Name: name, + Dir: target, + EventDB: filepath.Join(target, eventDBName), + Meta: meta, + }, nil +} + +// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。 +func SafeProjectName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName) + } + if name == "." || name == ".." { + return "", fmt.Errorf("%w: %q", ErrUnsafeName, name) + } + if len([]rune(name)) > 64 { + return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName) + } + if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) { + return "", fmt.Errorf("%w: %q", ErrUnsafeName, name) + } + for _, r := range name { + if r < 0x20 { + return "", fmt.Errorf("%w: %q", ErrUnsafeName, name) + } + } + if filepath.IsAbs(name) { + return "", fmt.Errorf("%w: %q", ErrUnsafeName, name) + } + base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name))) + switch base { + case "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": + return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name) + } + return name, nil +} + +func vacuumInto(db *sql.DB, dst string) error { + // VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。 + stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'" + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err) + } + return nil +} + +func buildManifest(root string) ([]ProjectFile, error) { + var out []ProjectFile + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == "meta.yaml" { + return nil + } + sum, size, err := hashFile(p) + if err != nil { + return err + } + out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum}) + return nil + }) + if err != nil { + return nil, fmt.Errorf("export: 生成文件清单: %w", err) + } + return out, nil +} + +func findFile(files []ProjectFile, name string) (ProjectFile, bool) { + for _, f := range files { + if f.Path == name { + return f, true + } + } + return ProjectFile{}, false +} + +func hashFile(p string) (string, int64, error) { + f, err := os.Open(p) + if err != nil { + return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err) + } + return hex.EncodeToString(h.Sum(nil)), n, nil +} + +func copyTree(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return fmt.Errorf("export: 读取目录 %s: %w", src, err) + } + if !info.IsDir() { + return fmt.Errorf("export: %s 不是目录", src) + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("export: 创建目录 %s: %w", dst, err) + } + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dst, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + return copyFile(p, target) + }) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("export: 读取 %s: %w", src, err) + } + defer func() { _ = in.Close() }() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err) + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("export: 创建 %s: %w", dst, err) + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("export: 关闭 %s: %w", dst, err) + } + return nil +} + +// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。 +func zipDir(root, dst string) error { + if dir := filepath.Dir(dst); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err) + } + } + tmp := dst + ".tmp" + out, err := os.Create(tmp) + if err != nil { + return fmt.Errorf("export: 创建 %s: %w", tmp, err) + } + zw := zip.NewWriter(out) + if err := writeZipTree(zw, root); err != nil { + _ = zw.Close() + _ = out.Close() + _ = os.Remove(tmp) + return err + } + if err := zw.Close(); err != nil { + _ = out.Close() + _ = os.Remove(tmp) + return fmt.Errorf("export: 关闭 zip: %w", err) + } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("export: 关闭 %s: %w", tmp, err) + } + if err := os.Rename(tmp, dst); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("export: 重命名 %s: %w", dst, err) + } + return nil +} + +func writeZipTree(zw *zip.Writer, root string) error { + return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + if rel == "." { + return nil + } + name := filepath.ToSlash(rel) + if d.IsDir() { + // 保留空目录(如空的 uploads/),导入方才能得到完整结构。 + _, err := zw.Create(name + "/") + return err + } + info, err := d.Info() + if err != nil { + return err + } + hdr, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + hdr.Name = name + hdr.Method = zip.Deflate + w, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("export: 读取 %s: %w", p, err) + } + _, copyErr := io.Copy(w, src) + closeErr := src.Close() + if copyErr != nil { + return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr) + } + return closeErr + }) +} + +func extractZip(src, dst string) error { + r, err := zip.OpenReader(src) + if err != nil { + return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err) + } + defer func() { _ = r.Close() }() + + var total uint64 + for _, f := range r.File { + target, err := secureJoin(dst, f.Name) + if err != nil { + return err + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return fmt.Errorf("export: 创建目录 %s: %w", target, err) + } + continue + } + total += f.UncompressedSize64 + if total > maxExtractBytes { + return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err) + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err) + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + _ = rc.Close() + return fmt.Errorf("export: 写入 %s: %w", target, err) + } + _, copyErr := io.Copy(out, rc) + closeOutErr := out.Close() + closeInErr := rc.Close() + if copyErr != nil { + return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr) + } + if closeOutErr != nil { + return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr) + } + if closeInErr != nil { + return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr) + } + } + return nil +} + +// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。 +func secureJoin(root, name string) (string, error) { + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + if clean == "." { + return root, nil + } + if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name) + } + target := filepath.Join(root, filepath.FromSlash(clean)) + rel, err := filepath.Rel(root, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name) + } + return target, nil +} + +func verifyManifest(root string, meta ProjectMeta) error { + if len(meta.Files) == 0 { + return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage) + } + for _, f := range meta.Files { + target, err := secureJoin(root, f.Path) + if err != nil { + return err + } + sum, size, err := hashFile(target) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidPackage, err) + } + if f.Size != 0 && f.Size != size { + return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size) + } + if !strings.EqualFold(sum, f.SHA256) { + return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path) + } + } + dbFile, ok := findFile(meta.Files, eventDBName) + if !ok { + return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName) + } + if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) { + return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch) + } + return nil +} + +// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。 +func verifySQLite(p string) error { + db, err := sql.Open("sqlite", "file:"+p+"?mode=ro") + if err != nil { + return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err) + } + defer func() { _ = db.Close() }() + + var integrity string + if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil { + return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err) + } + if !strings.EqualFold(integrity, "ok") { + return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity) + } + var n int + if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil { + return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err) + } + if n == 0 { + return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName) + } + return nil +} diff --git a/server/internal/export/project_test.go b/server/internal/export/project_test.go new file mode 100644 index 0000000..eb1218e --- /dev/null +++ b/server/internal/export/project_test.go @@ -0,0 +1,224 @@ +package export + +import ( + "archive/zip" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestExportImportProjectZip(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + uploads := t.TempDir() + if err := os.WriteFile(filepath.Join(uploads, "logo.txt"), []byte("logo"), 0o644); err != nil { + t.Fatalf("写 uploads: %v", err) + } + rules := filepath.Join(t.TempDir(), "rules.yaml") + if err := os.WriteFile(rules, []byte("format: scoreboard-rules\nformatVersion: 1\n"), 0o644); err != nil { + t.Fatalf("写 rules.yaml: %v", err) + } + + zipPath := filepath.Join(t.TempDir(), "赛事包.zip") + meta, err := ExportProjectZip(db, zipPath, ProjectExportOptions{ + Name: "测试赛事", + UploadsDir: uploads, + RulesPath: rules, + CreatedBy: "tester", + Now: time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local), + }) + if err != nil { + t.Fatalf("导出赛事包: %v", err) + } + if meta.Format != projectFormat || meta.FormatVersion != projectFormatVersion { + t.Fatalf("meta 格式 = %s/%d", meta.Format, meta.FormatVersion) + } + if meta.EventName != "测试运动会" { + t.Errorf("meta.eventName = %q,期望 测试运动会", meta.EventName) + } + for _, want := range []string{"event.db", "uploads/logo.txt", "rules.yaml", "meta.yaml"} { + if !zipHas(zipPath, t, want) { + t.Errorf("包内缺少 %s", want) + } + } + + got, err := InspectProjectZip(zipPath) + if err != nil { + t.Fatalf("预览赛事包: %v", err) + } + if got.Name != "测试赛事" || got.Database.Path != "event.db" || got.Database.SHA256 == "" { + t.Fatalf("预览结果异常: %+v", got) + } + + root := t.TempDir() + res, err := ImportProjectZip(zipPath, root, ImportOptions{}) + if err != nil { + t.Fatalf("导入赛事包: %v", err) + } + if res.Dir != filepath.Join(root, "测试赛事") { + t.Errorf("导入目录 = %q", res.Dir) + } + if _, err := os.Stat(res.EventDB); err != nil { + t.Fatalf("导入后 event.db 不存在: %v", err) + } + if _, err := os.Stat(filepath.Join(res.Dir, "uploads", "logo.txt")); err != nil { + t.Errorf("导入后 uploads 资源缺失: %v", err) + } + assertTeams(t, res.EventDB, 2) + + if _, err := ImportProjectZip(zipPath, root, ImportOptions{}); !errors.Is(err, ErrProjectExists) { + t.Fatalf("同名导入应报 ErrProjectExists,实际 %v", err) + } + if _, err := ImportProjectZip(zipPath, root, ImportOptions{Overwrite: true}); err != nil { + t.Fatalf("覆盖导入: %v", err) + } + assertTeams(t, filepath.Join(root, "测试赛事", "event.db"), 2) + + renamed, err := ImportProjectZip(zipPath, root, ImportOptions{Rename: "另一场赛事"}) + if err != nil { + t.Fatalf("改名导入: %v", err) + } + if renamed.Name != "另一场赛事" { + t.Errorf("改名导入结果 = %q", renamed.Name) + } +} + +func TestImportRejectsBadPackages(t *testing.T) { + base := ProjectMeta{ + Format: projectFormat, + FormatVersion: projectFormatVersion, + Name: "坏包", + Database: ProjectFile{Path: "event.db", SHA256: strings.Repeat("0", 64)}, + Files: []ProjectFile{{Path: "event.db", SHA256: strings.Repeat("0", 64)}}, + } + + t.Run("校验和不匹配", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.zip") + writeZip(t, p, map[string]string{ + "meta.yaml": marshalMeta(t, base), + "event.db": "not a database", + }) + _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("期望 ErrChecksumMismatch,实际 %v", err) + } + }) + + t.Run("版本过新", func(t *testing.T) { + meta := base + meta.FormatVersion = 99 + dir := t.TempDir() + p := filepath.Join(dir, "bad.zip") + writeZip(t, p, map[string]string{"meta.yaml": marshalMeta(t, meta), "event.db": "x"}) + _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) + if !errors.Is(err, ErrUnsupportedVersion) { + t.Fatalf("期望 ErrUnsupportedVersion,实际 %v", err) + } + }) + + t.Run("路径穿越", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.zip") + writeZip(t, p, map[string]string{ + "meta.yaml": marshalMeta(t, base), + "../evil.txt": "oops", + "event.db": "x", + }) + _, err := ImportProjectZip(p, t.TempDir(), ImportOptions{}) + if !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("期望 ErrInvalidPackage,实际 %v", err) + } + }) + + t.Run("缺 meta.yaml", func(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "bad.zip") + writeZip(t, p, map[string]string{"event.db": "x"}) + if _, err := InspectProjectZip(p); !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("期望 ErrInvalidPackage,实际 %v", err) + } + }) +} + +func TestSafeProjectName(t *testing.T) { + bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"} + for _, name := range bad { + if _, err := SafeProjectName(name); err == nil { + t.Errorf("SafeProjectName(%q) 应报错", name) + } + } + for _, name := range []string{"2026 运动会", "test-event_1", "测试赛事"} { + if _, err := SafeProjectName(name); err != nil { + t.Errorf("SafeProjectName(%q) 不应报错: %v", name, err) + } + } +} + +func assertTeams(t *testing.T, path string, want int) { + t.Helper() + db := openSQLite(t, path) + defer func() { _ = db.Close() }() + + var n int + if err := db.QueryRow(`SELECT count(*) FROM teams`).Scan(&n); err != nil { + t.Fatalf("查询 %s 的 teams: %v", path, err) + } + if n != want { + t.Errorf("%s 的 teams 数 = %d,期望 %d", path, n, want) + } +} + +func zipHas(path string, t *testing.T, name string) bool { + t.Helper() + r, err := zip.OpenReader(path) + if err != nil { + t.Fatalf("打开 zip: %v", err) + } + defer func() { _ = r.Close() }() + for _, f := range r.File { + if f.Name == name { + return true + } + } + return false +} + +func writeZip(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("创建 zip: %v", err) + } + zw := zip.NewWriter(f) + for name, body := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("创建条目 %s: %v", name, err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatalf("写入条目 %s: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("关闭 zip: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("关闭文件: %v", err) + } +} + +func marshalMeta(t *testing.T, meta ProjectMeta) string { + t.Helper() + raw, err := yaml.Marshal(&meta) + if err != nil { + t.Fatalf("序列化 meta: %v", err) + } + return string(raw) +} diff --git a/server/internal/export/standings.go b/server/internal/export/standings.go new file mode 100644 index 0000000..82d02c4 --- /dev/null +++ b/server/internal/export/standings.go @@ -0,0 +1,203 @@ +package export + +import ( + "database/sql" + "io" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。 +// 三个榜由 domain 的排名函数算出,本包只负责排版。 +type StandingsWorkbook struct { + Meta Meta + Team []domain.StandingsRow + Medal []domain.MedalRow + Athlete []domain.AthleteStandingsRow +} + +var ( + teamColumns = []column{ + {"名次", 6, kindInt}, + {"单位代码", 10, kindCenter}, + {"单位名称", 26, kindText}, + {"总分", 9, kindNumber}, + {"团体项目分", 12, kindNumber}, + {"个人项目分", 12, kindNumber}, + {"金牌", 7, kindInt}, + {"银牌", 7, kindInt}, + {"铜牌", 7, kindInt}, + {"奖牌合计", 10, kindInt}, + } + medalColumns = []column{ + {"名次", 6, kindInt}, + {"单位代码", 10, kindCenter}, + {"单位名称", 26, kindText}, + {"金牌", 7, kindInt}, + {"银牌", 7, kindInt}, + {"铜牌", 7, kindInt}, + {"奖牌合计", 10, kindInt}, + {"总分", 9, kindNumber}, + } + athleteColumns = []column{ + {"名次", 6, kindInt}, + {"运动员", 16, kindText}, + {"单位代码", 10, kindCenter}, + {"单位名称", 24, kindText}, + {"总分", 9, kindNumber}, + {"金牌", 7, kindInt}, + {"银牌", 7, kindInt}, + {"铜牌", 7, kindInt}, + {"奖牌合计", 10, kindInt}, + } +) + +// ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。 +func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error { + wb, err := LoadStandingsWorkbook(db, opts) + if err != nil { + return err + } + f, err := standingsFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteStandingsXLSX 把已算好的总表写成 xlsx 流,供 HTTP 直接响应下载。 +func WriteStandingsXLSX(w io.Writer, wb StandingsWorkbook) error { + f, err := standingsFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func standingsFile(wb StandingsWorkbook) (*excelize.File, error) { + return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error { + return writeStandingsSheets(f, st, wb) + }) +} + +func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook) error { + meta := wb.Meta.normalized() + specs := []struct { + sheet string + title string + cols []column + rows [][]any + }{ + {"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)}, + {"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)}, + {"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)}, + } + for _, s := range specs { + if err := addSheet(f, s.sheet); err != nil { + return err + } + if err := writeSheet(f, st, s.sheet, meta, s.title, s.cols, s.rows); err != nil { + return err + } + } + return nil +} + +func teamRows(rows []domain.StandingsRow) [][]any { + out := make([][]any, 0, len(rows)) + for _, r := range rows { + out = append(out, []any{ + r.Rank, r.Code, displayName(r.Name, r.ShortName), r.Points, + r.TeamOnlyPoints, r.IndividualPoints, + r.Gold, r.Silver, r.Bronze, r.Medals, + }) + } + return out +} + +func medalRows(rows []domain.MedalRow) [][]any { + out := make([][]any, 0, len(rows)) + for _, r := range rows { + out = append(out, []any{ + r.Rank, r.Code, displayName(r.Name, r.ShortName), + r.Gold, r.Silver, r.Bronze, r.Total, r.Points, + }) + } + return out +} + +func athleteRows(rows []domain.AthleteStandingsRow) [][]any { + out := make([][]any, 0, len(rows)) + for _, r := range rows { + out = append(out, []any{ + r.Rank, r.Name, r.TeamCode, displayName(r.TeamName, ""), + r.Points, r.Gold, r.Silver, r.Bronze, r.Medals, + }) + } + return out +} + +// displayName 优先用全称,其次简称;都为空时留空,不显示 "0" 之类的噪声。 +func displayName(name, short string) string { + if name != "" { + return name + } + return short +} + +// AllWorkbook 是「全部」导出:总表 + 全部分项成绩。 +type AllWorkbook struct { + Standings StandingsWorkbook + Events EventsWorkbook +} + +// ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。 +func ExportAllXLSX(db *sql.DB, dst string, opts Options) error { + wb, err := LoadAllWorkbook(db, opts) + if err != nil { + return err + } + f, err := allFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteAllXLSX 把「全部」写成 xlsx 流。 +func WriteAllXLSX(w io.Writer, wb AllWorkbook) error { + f, err := allFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +// LoadAllWorkbook 一次性读取总表与全部分项成绩。 +func LoadAllWorkbook(db *sql.DB, opts Options) (AllWorkbook, error) { + standings, err := LoadStandingsWorkbook(db, opts) + if err != nil { + return AllWorkbook{}, err + } + events, err := LoadEventWorkbook(db, 0, opts) + if err != nil { + return AllWorkbook{}, err + } + return AllWorkbook{Standings: standings, Events: events}, nil +} + +func allFile(wb AllWorkbook) (*excelize.File, error) { + return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error { + if err := writeStandingsSheets(f, st, wb.Standings); err != nil { + return err + } + namer := &sheetNamer{} + // 三个榜先登记名称,分项表才不会撞名。 + for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} { + namer.add(n) + } + return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer) + }) +} diff --git a/server/internal/export/style.go b/server/internal/export/style.go new file mode 100644 index 0000000..a88cf86 --- /dev/null +++ b/server/internal/export/style.go @@ -0,0 +1,367 @@ +package export + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/xuri/excelize/v2" +) + +// 配色跟随管理端 MIUIx 令牌,导出件与界面观感一致;字体用 Windows 自带的微软雅黑, +// 现场机器不需要额外装字体就能正确显示。 +const ( + colorPrimary = "3482FF" + colorHeaderText = "FFFFFF" + colorBorder = "D0D7E5" + colorBand = "F5F8FF" + colorMuted = "6B7280" + fontFamily = "微软雅黑" +) + +// 每张表的固定版式:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起为数据。 +const ( + rowTitle = 1 + rowSubtitle = 2 + rowHeader = 4 +) + +// maxSheetName 是 Excel 对工作表名的硬限制。 +const maxSheetName = 31 + +// cellKind 决定单元格的对齐方式与数字格式。 +type cellKind int + +const ( + kindText cellKind = iota + kindCenter + kindNumber + kindInt +) + +// column 是一列的展示定义;宽度按内容预留,导出即可打印,不用再手工调列宽。 +type column struct { + title string + width float64 + kind cellKind +} + +type dataKey struct { + kind cellKind + band bool +} + +// styleSet 是一份工作簿内复用的样式集合;excelize 的样式 id 与工作簿绑定, +// 因此每打开一份新工作簿都要重建一次。 +type styleSet struct { + title int + subtitle int + header int + data map[dataKey]int +} + +func newStyleSet(f *excelize.File) (*styleSet, error) { + border := []excelize.Border{ + {Type: "left", Color: colorBorder, Style: 1}, + {Type: "right", Color: colorBorder, Style: 1}, + {Type: "top", Color: colorBorder, Style: 1}, + {Type: "bottom", Color: colorBorder, Style: 1}, + } + + st := &styleSet{data: make(map[dataKey]int, 8)} + var err error + if st.title, err = f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 16, Family: fontFamily}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }); err != nil { + return nil, fmt.Errorf("export: 创建标题样式: %w", err) + } + if st.subtitle, err = f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 10, Family: fontFamily, Color: colorMuted}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + }); err != nil { + return nil, fmt.Errorf("export: 创建副标题样式: %w", err) + } + if st.header, err = f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Size: 11, Family: fontFamily, Color: colorHeaderText}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorPrimary}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true}, + Border: border, + }); err != nil { + return nil, fmt.Errorf("export: 创建表头样式: %w", err) + } + + for _, kind := range []cellKind{kindText, kindCenter, kindNumber, kindInt} { + for _, band := range []bool{false, true} { + id, err := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 11, Family: fontFamily}, + Alignment: &excelize.Alignment{Horizontal: horizontal(kind), Vertical: "center", WrapText: kind == kindText}, + Border: border, + Fill: fill(band), + CustomNumFmt: numFmt(kind), + }) + if err != nil { + return nil, fmt.Errorf("export: 创建数据样式: %w", err) + } + st.data[dataKey{kind: kind, band: band}] = id + } + } + return st, nil +} + +func horizontal(kind cellKind) string { + if kind == kindText { + return "left" + } + return "center" +} + +func fill(band bool) excelize.Fill { + if !band { + return excelize.Fill{} + } + return excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorBand}} +} + +// numFmt 让积分最多保留两位小数、不显示多余的 0;整数列固定不带小数。 +func numFmt(kind cellKind) *string { + switch kind { + case kindNumber: + return strPtr("0.##") + case kindInt: + return strPtr("0") + default: + return nil + } +} + +func (s *styleSet) cell(kind cellKind, band bool) int { + return s.data[dataKey{kind: kind, band: band}] +} + +// buildWorkbook 建好工作簿与样式后交给 build 填充。 +// build 若不新建任何工作表,就把默认工作表改名为 defaultSheet,避免出现空白的 "Sheet1"。 +func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSet) error) (*excelize.File, error) { + f := excelize.NewFile() + st, err := newStyleSet(f) + if err != nil { + return nil, err + } + if err := build(f, st); err != nil { + return nil, err + } + + sheets := f.GetSheetList() + if len(sheets) == 1 && sheets[0] == "Sheet1" { + if err := f.SetSheetName("Sheet1", sanitizeSheetName(defaultSheet)); err != nil { + return nil, fmt.Errorf("export: 重命名工作表: %w", err) + } + return f, nil + } + if idx, err := f.GetSheetIndex("Sheet1"); err == nil && idx >= 0 { + _ = f.DeleteSheet("Sheet1") + } + return f, nil +} + +func addSheet(f *excelize.File, name string) error { + if _, err := f.NewSheet(name); err != nil { + return fmt.Errorf("export: 新建工作表 %s: %w", name, err) + } + return nil +} + +// writeSheet 按统一版式写一张表:合并标题、副标题、带底色的表头、斑马纹数据行、 +// 冻结表头,并预设 A4 横向、按宽度缩放、每页重复表头,导出后可直接打印。 +func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title string, cols []column, rows [][]any) error { + if len(cols) == 0 { + return fmt.Errorf("export: 工作表 %s 没有列定义", sheet) + } + lastCol := colName(len(cols)) + + if err := f.SetCellValue(sheet, "A1", title); err != nil { + return err + } + if err := f.MergeCell(sheet, "A1", lastCol+"1"); err != nil { + return err + } + if err := f.SetCellStyle(sheet, "A1", lastCol+"1", st.title); err != nil { + return err + } + if err := f.SetRowHeight(sheet, rowTitle, 32); err != nil { + return err + } + + if sub := metaSubtitle(meta); sub != "" { + if err := f.SetCellValue(sheet, "A2", sub); err != nil { + return err + } + if err := f.MergeCell(sheet, "A2", lastCol+"2"); err != nil { + return err + } + if err := f.SetCellStyle(sheet, "A2", lastCol+"2", st.subtitle); err != nil { + return err + } + } + + for i, c := range cols { + name := colName(i + 1) + if err := f.SetCellValue(sheet, name+strconv.Itoa(rowHeader), c.title); err != nil { + return err + } + if err := f.SetColWidth(sheet, name, name, c.width); err != nil { + return err + } + } + if err := f.SetCellStyle(sheet, "A"+strconv.Itoa(rowHeader), lastCol+strconv.Itoa(rowHeader), st.header); err != nil { + return err + } + if err := f.SetRowHeight(sheet, rowHeader, 26); err != nil { + return err + } + + for r, row := range rows { + excelRow := rowHeader + 1 + r + for i, c := range cols { + var v any = "" + if i < len(row) && row[i] != nil { + v = row[i] + } + cell := colName(i+1) + strconv.Itoa(excelRow) + if err := f.SetCellValue(sheet, cell, v); err != nil { + return err + } + if err := f.SetCellStyle(sheet, cell, cell, st.cell(c.kind, r%2 == 1)); err != nil { + return err + } + } + } + + if err := f.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: rowHeader, + TopLeftCell: "A" + strconv.Itoa(rowHeader+1), + ActivePane: "bottomLeft", + }); err != nil { + return err + } + if err := f.SetPageLayout(sheet, &excelize.PageLayoutOptions{ + Size: intPtr(9), // 9 = A4 + Orientation: strPtr("landscape"), + FitToWidth: intPtr(1), + FitToHeight: intPtr(0), + }); err != nil { + return err + } + // 打印标题与页码是「锦上添花」,失败不影响文件可用性,故忽略错误。 + _ = f.SetDefinedName(&excelize.DefinedName{ + Name: "_xlnm.Print_Titles", + RefersTo: fmt.Sprintf("'%s'!$1:$%d", sheet, rowHeader), + Scope: sheet, + }) + _ = f.SetHeaderFooter(sheet, &excelize.HeaderFooterOptions{ + OddHeader: "&C" + title, + OddFooter: "&C第 &P 页 / 共 &N 页", + }) + return nil +} + +// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。 +func saveWorkbook(f *excelize.File, dst string) error { + if strings.TrimSpace(dst) == "" { + return errors.New("export: 导出路径为空") + } + if dir := filepath.Dir(dst); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err) + } + } + if err := f.SaveAs(dst); err != nil { + return fmt.Errorf("export: 保存 xlsx %s: %w", dst, err) + } + return nil +} + +// sheetTitle 把赛事名拼在表名前面,打印出来一眼能看出是哪场赛事。 +func sheetTitle(meta Meta, name string) string { + if meta.EventName == "" { + return name + } + return meta.EventName + " · " + name +} + +func metaSubtitle(m Meta) string { + parts := make([]string, 0, 4) + if m.Organizer != "" { + parts = append(parts, m.Organizer) + } + if m.Subtitle != "" { + parts = append(parts, m.Subtitle) + } + if !m.GeneratedAt.IsZero() { + parts = append(parts, "导出时间 "+m.GeneratedAt.Format("2006-01-02 15:04")) + } + if m.GeneratedBy != "" { + parts = append(parts, "导出人 "+m.GeneratedBy) + } + return strings.Join(parts, " · ") +} + +// sanitizeSheetName 替换 Excel 不允许的字符并截断到 31 字符。 +func sanitizeSheetName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + name = "成绩" + } + name = strings.NewReplacer( + "[", "(", "]", ")", ":", ":", "*", "*", + "?", "?", "/", "/", "\\", "\", + ).Replace(name) + if rs := []rune(name); len(rs) > maxSheetName { + name = string(rs[:maxSheetName]) + } + return name +} + +// sheetNamer 保证同一工作簿内的工作表名唯一(Excel 不允许重名)。 +type sheetNamer struct { + used map[string]bool +} + +func (n *sheetNamer) add(base string) string { + base = sanitizeSheetName(base) + if n.used == nil { + n.used = make(map[string]bool) + } + name := base + for i := 2; n.used[name]; i++ { + suffix := fmt.Sprintf("(%d)", i) + rs := []rune(base) + if keep := maxSheetName - len([]rune(suffix)); len(rs) > keep { + rs = rs[:keep] + } + name = string(rs) + suffix + } + n.used[name] = true + return name +} + +func colName(n int) string { + name, _ := excelize.ColumnNumberToName(n) + return name +} + +func intPtr(v int) *int { return &v } +func strPtr(v string) *string { return &v } + +// nowOr 让测试可以注入固定时间;零值取当前时间。 +func nowOr(t time.Time) time.Time { + if t.IsZero() { + return time.Now() + } + return t +} diff --git a/server/internal/export/team.go b/server/internal/export/team.go new file mode 100644 index 0000000..12dc261 --- /dev/null +++ b/server/internal/export/team.go @@ -0,0 +1,98 @@ +package export + +import ( + "database/sql" + "io" + "strconv" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// TeamDetailRow 是单位积分明细的一行:一分一行的得分来源。 +type TeamDetailRow struct { + SportName string + EventName string + Category string + AthleteName string + Rank int + ResultText string + Source string + Points float64 + Remark string +} + +// TeamDetailWorkbook 是「单位积分明细」的内容。 +type TeamDetailWorkbook struct { + Meta Meta + Team domain.Team + Rows []TeamDetailRow +} + +var teamDetailColumns = []column{ + {"序号", 6, kindInt}, + {"大项", 14, kindText}, + {"小项", 18, kindText}, + {"组别", 14, kindText}, + {"运动员", 14, kindText}, + {"名次", 7, kindCenter}, + {"成绩", 12, kindCenter}, + {"来源", 12, kindCenter}, + {"积分", 8, kindNumber}, + {"备注", 20, kindText}, +} + +// ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。 +func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error { + wb, err := LoadTeamDetailWorkbook(db, teamID, opts) + if err != nil { + return err + } + f, err := teamDetailFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteTeamDetailXLSX 把单位积分明细写成 xlsx 流。 +func WriteTeamDetailXLSX(w io.Writer, wb TeamDetailWorkbook) error { + f, err := teamDetailFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) { + return buildWorkbook("积分明细", func(f *excelize.File, st *styleSet) error { + meta := wb.Meta.normalized() + name := sanitizeSheetName(displayName(wb.Team.Name, wb.Team.ShortName) + "积分明细") + if err := addSheet(f, name); err != nil { + return err + } + title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细") + return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows)) + }) +} + +func teamDetailRows(rows []TeamDetailRow) [][]any { + out := make([][]any, 0, len(rows)+1) + var total float64 + for i, r := range rows { + total += r.Points + rank := "" + if r.Rank > 0 { + rank = strconv.Itoa(r.Rank) + } + out = append(out, []any{ + i + 1, r.SportName, r.EventName, r.Category, r.AthleteName, + rank, r.ResultText, r.Source, r.Points, r.Remark, + }) + } + if len(rows) > 0 { + out = append(out, []any{"合计", "", "", "", "", "", "", "", total, ""}) + } + return out +} diff --git a/server/internal/export/xlsx_test.go b/server/internal/export/xlsx_test.go new file mode 100644 index 0000000..e57791a --- /dev/null +++ b/server/internal/export/xlsx_test.go @@ -0,0 +1,259 @@ +package export + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestExportStandingsXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "总表.xlsx") + if err := ExportStandingsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总表: %v", err) + } + + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + + // 版式固定:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起数据;2 个单位 → 6 行。 + team, err := f.GetRows("总积分榜") + if err != nil { + t.Fatalf("读取总积分榜: %v", err) + } + if len(team) != 6 { + t.Fatalf("总积分榜行数 = %d,期望 6", len(team)) + } + if got := cell(team, 0, 0); got != "测试运动会 · 总积分榜" { + t.Errorf("标题 = %q", got) + } + if got := cell(team, 3, 0); got != "名次" { + t.Errorf("表头首列 = %q,期望 名次", got) + } + if got := cell(team, 4, 1); got != "T1" { + t.Errorf("第一名单位代码 = %q,期望 T1", got) + } + if got := cell(team, 4, 3); got != "10" { + t.Errorf("T1 总分 = %q,期望 10", got) + } + if got := cell(team, 4, 6); got != "1" { + t.Errorf("T1 金牌 = %q,期望 1", got) + } + if got := cell(team, 5, 1); got != "T2" { + t.Errorf("第二名单位代码 = %q,期望 T2", got) + } + + medal, err := f.GetRows("团体奖牌榜") + if err != nil { + t.Fatalf("读取团体奖牌榜: %v", err) + } + if len(medal) != 6 { + t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal)) + } + if got := cell(medal, 4, 3); got != "1" { // T1 金牌 + t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got) + } + + athlete, err := f.GetRows("个人积分奖牌榜") + if err != nil { + t.Fatalf("读取个人积分奖牌榜: %v", err) + } + if len(athlete) != 6 { + t.Fatalf("个人积分奖牌榜行数 = %d,期望 6", len(athlete)) + } + if got := cell(athlete, 4, 1); got != "张三" { + t.Errorf("个人榜第一行 = %q,期望 张三", got) + } +} + +func TestWriteStandingsXLSXStream(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + wb, err := LoadStandingsWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读取总表数据: %v", err) + } + var buf bytes.Buffer + if err := WriteStandingsXLSX(&buf, wb); err != nil { + t.Fatalf("流式导出: %v", err) + } + f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("打开流式导出的 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + if got := len(f.GetSheetList()); got != 3 { + t.Errorf("sheet 数 = %d,期望 3", got) + } +} + +func TestExportEventXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "分项.xlsx") + if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil { + t.Fatalf("导出分项成绩表: %v", err) + } + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + want := []string{"田径·100米跑·男子"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } + rows, err := f.GetRows(want[0]) + if err != nil { + t.Fatalf("读取分项成绩: %v", err) + } + if len(rows) != 6 { + t.Fatalf("分项成绩行数 = %d,期望 6", len(rows)) + } + if got := cell(rows, 4, 0); got != "1" { + t.Errorf("第一名名次 = %q,期望 1", got) + } + if got := cell(rows, 4, 1); got != "101" { + t.Errorf("第一名号码 = %q,期望 101", got) + } + if got := cell(rows, 4, 3); got != "张三" { + t.Errorf("第一名运动员 = %q,期望 张三", got) + } + if got := cell(rows, 4, 4); got != "11.58 秒" { + t.Errorf("第一名成绩 = %q,期望 11.58 秒", got) + } + if got := cell(rows, 4, 5); got != "10" { + t.Errorf("第一名积分 = %q,期望 10", got) + } + if got := cell(rows, 4, 6); got != "有效" { + t.Errorf("第一名状态 = %q,期望 有效", got) + } +} + +func TestExportAllXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "全部.xlsx") + if err := ExportAllXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出全部: %v", err) + } + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"} + if got := f.GetSheetList(); !equalStrings(got, want) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, want) + } +} + +func TestExportTeamDetailXLSX(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "明细.xlsx") + if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil { + t.Fatalf("导出单位积分明细: %v", err) + } + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + sheet := "第一分公司积分明细" + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取 %s: %v", sheet, err) + } + // 4 行版式 + 1 条明细 + 1 行合计 + if len(rows) != 6 { + t.Fatalf("%s 行数 = %d,期望 6", sheet, len(rows)) + } + if got := cell(rows, 4, 1); got != "田径" { + t.Errorf("明细大项 = %q,期望 田径", got) + } + if got := cell(rows, 4, 5); got != "1" { + t.Errorf("明细名次 = %q,期望 1", got) + } + if got := cell(rows, 4, 7); got != "成绩" { + t.Errorf("明细来源 = %q,期望 成绩", got) + } + if got := cell(rows, 4, 8); got != "10" { + t.Errorf("明细积分 = %q,期望 10", got) + } + if got := cell(rows, 5, 0); got != "合计" { + t.Errorf("合计行首列 = %q,期望 合计", got) + } + if got := cell(rows, 5, 8); got != "10" { + t.Errorf("合计积分 = %q,期望 10", got) + } +} + +// TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。 +func TestLoadStandingsHonoursSettings(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil { + t.Fatalf("修改折算系数: %v", err) + } + + wb, err := LoadStandingsWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读取总表数据: %v", err) + } + if len(wb.Team) != 2 { + t.Fatalf("单位数 = %d,期望 2", len(wb.Team)) + } + if wb.Team[0].Points != 20 { + t.Errorf("系数 2 时 T1 总分 = %v,期望 20", wb.Team[0].Points) + } +} + +func TestLoadEventWorkbookMissingEvent(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + if _, err := LoadEventWorkbook(db, 999, Options{}); err == nil { + t.Fatal("导出不存在的小项应报错") + } +} + +func cell(rows [][]string, r, c int) string { + if r < 0 || r >= len(rows) { + return "" + } + if c < 0 || c >= len(rows[r]) { + return "" + } + return rows[r][c] +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go new file mode 100644 index 0000000..24bc2a7 --- /dev/null +++ b/server/internal/httpapi/api_test.go @@ -0,0 +1,338 @@ +package httpapi_test + +import ( + "database/sql" + "encoding/json" + "net/http" + "testing" +) + +type seedIDs struct { + teamA, teamB int64 + athleteA, athB int64 + sport, event int64 + category, entryA int64 + entryB int64 +} + +func (e *testEnv) seed(t *testing.T) seedIDs { + t.Helper() + var ids seedIDs + + ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`) + ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`) + ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA) + ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB) + ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`) + ids.event = e.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) + ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event) + + var profileID int64 + if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读取积分模板: %v", err) + } + e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID) + + ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA) + ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB) + return ids +} + +func (e *testEnv) exec(t *testing.T, query string, args ...any) { + t.Helper() + if _, err := e.ev.Exec(query, args...); err != nil { + t.Fatalf("执行 SQL 失败: %v\n%s", err, query) + } +} + +func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 { + t.Helper() + res, err := e.ev.Exec(query, args...) + if err != nil { + t.Fatalf("执行 SQL 失败: %v\n%s", err, query) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("获取 id: %v", err) + } + return id +} + +// 未登录 = guest:赛事数据可读,系统配置一律 403。 +func TestGuestReadsEventDataButNotConfig(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + client, _ := env.client() + + if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK { + t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK) + } + if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK { + t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK) + } + + for _, path := range []string{"/api/settings", "/api/audit-logs"} { + status, env2 := env.do(client, http.MethodGet, path, nil, false) + if status != http.StatusForbidden { + t.Fatalf("guest 读 %s 应 403,得到 %d", path, status) + } + if env2.Error == nil || env2.Error.Code != "FORBIDDEN" { + t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error) + } + } + + // 未登录写操作要求先登录。 + status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{ + "code": "C", "name": "第三分公司", "active": true, + }, false) + if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error) + } +} + +// recorder 拥有录入权限,但没有系统配置权限。 +func TestRecorderForbiddenOnAdminRoutes(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "event": map[string]any{"name": "改名"}, + }, true) + if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" { + t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error) + } + if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden { + t.Fatalf("recorder 读审计应 403,得到 %d", status) + } + + // recorder 的合法写操作仍然可用。 + status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{ + "code": "C", "name": "第三分公司", "active": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 建单位应 200,得到 %d %+v", status, env2.Error) + } +} + +// 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。 +func TestLoginCreatesSession(t *testing.T) { + env := newTestEnv(t) + userID := env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + var count int + if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil { + t.Fatalf("查询会话: %v", err) + } + if count != 1 { + t.Fatalf("登录后应有 1 条会话,得到 %d", count) + } + + status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false) + if status != http.StatusOK { + t.Fatalf("me 应 200,得到 %d", status) + } + me := decodeData[map[string]any](t, env2) + if me["username"] != "recorder" || me["role"] != "recorder" { + t.Fatalf("me 返回身份不对: %+v", me) + } + + if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK { + t.Fatalf("登出应 200,得到 %d", status) + } + status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false) + me = decodeData[map[string]any](t, env2) + if status != http.StatusOK || me["role"] != "guest" { + t.Fatalf("登出后应为 guest,得到 %d %+v", status, me) + } +} + +// 首次启动创建默认管理员并打印随机初始密码。 +func TestEnsureDefaultAdmin(t *testing.T) { + env := newTestEnv(t) + username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context()) + if err != nil { + t.Fatalf("创建默认管理员: %v", err) + } + if !created || username == "" || len(password) < 12 { + t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password)) + } + client, _ := env.client() + env.login(client, username, password) +} + +// 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。 +func TestPublishWritesLedgerAndAudit(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + userID := env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "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) + if status != http.StatusOK || !env2.OK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error) + } + + status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "requestId": "publish-1", + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + results := decodeData[[]map[string]any](t, env2) + if len(results) != 2 { + t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results)) + } + for _, r := range results { + if r["state"] != "published" { + t.Fatalf("成绩应为 published: %+v", r) + } + } + + // 流水:A 10 分、B 8 分。 + rows, err := env.ev.Query( + `SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`) + if err != nil { + t.Fatalf("查询流水: %v", err) + } + defer func() { _ = rows.Close() }() + points := map[int64]float64{} + for rows.Next() { + var team int64 + var p float64 + if err := rows.Scan(&team, &p); err != nil { + t.Fatalf("扫描流水: %v", err) + } + points[team] = p + } + if points[ids.teamA] != 10 || points[ids.teamB] != 8 { + t.Fatalf("流水分值不对: %+v", points) + } + + // 积分榜:A 第一。 + _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false) + standings := decodeData[[]map[string]any](t, env2) + if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 { + t.Fatalf("积分榜不对: %+v", standings) + } + if standings[0]["gold"].(float64) != 1 { + t.Fatalf("A 应记 1 金: %+v", standings[0]) + } + + // 审计:操作人、IP、request_id、before/after 都要有。 + var ( + action string + auditUser sql.NullInt64 + ip, requestID sql.NullString + before, after sql.NullString + ) + err = env.ev.QueryRow( + `SELECT action, user_id, ip, request_id, before_json, after_json + FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`). + Scan(&action, &auditUser, &ip, &requestID, &before, &after) + if err != nil { + t.Fatalf("查询审计: %v", err) + } + if !auditUser.Valid || auditUser.Int64 != userID { + t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser) + } + if !ip.Valid || ip.String == "" { + t.Fatalf("审计应记录 IP") + } + if !requestID.Valid || requestID.String == "" { + t.Fatalf("审计应记录 request_id") + } + if !before.Valid || !after.Valid { + t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after) + } + var parsed []map[string]any + if err := json.Unmarshal([]byte(after.String), &parsed); err != nil { + t.Fatalf("审计 after_json 应为成绩数组: %v", err) + } +} + +// 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400; +// 同一个 X-Request-Id 重复提交应回放首次结果。 +func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"} + status, first := env.do(client, http.MethodPost, "/api/teams", body, true) + if status != http.StatusOK || !first.OK { + t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error) + } + + headers := map[string]string{"X-Request-Id": "replay-key-1"} + status, second := env.doH(client, http.MethodPost, "/api/teams", + map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers) + if status != http.StatusOK { + t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status) + } + status, replay := env.doH(client, http.MethodPost, "/api/teams", + map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers) + if status != http.StatusOK || string(replay.Data) != string(second.Data) { + t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data) + } + + var teams int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil { + t.Fatalf("统计单位: %v", err) + } + if teams != 2 { + t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams) + } +} + +// 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。 +func TestPublishIdempotentReplay(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") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true) + + body := map[string]any{"eventId": ids.event, "requestId": "publish-same"} + _, first := env.do(client, http.MethodPost, "/api/results/publish", body, true) + _, second := env.do(client, http.MethodPost, "/api/results/publish", body, true) + if string(first.Data) != string(second.Data) { + t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data) + } + + var ledgerRows int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil { + t.Fatalf("统计流水: %v", err) + } + if ledgerRows != 2 { + t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows) + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go new file mode 100644 index 0000000..77b81a7 --- /dev/null +++ b/server/internal/httpapi/dto.go @@ -0,0 +1,331 @@ +package httpapi + +import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + +// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。 +// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。 + +type teamDTO struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + ShortName *string `json:"shortName"` + LogoPath *string `json:"logoPath"` + Color *string `json:"color"` + SortOrder int `json:"sortOrder"` + Active bool `json:"active"` +} + +type athleteDTO struct { + ID int64 `json:"id"` + Name string `json:"name"` + Gender *string `json:"gender"` + TeamID int64 `json:"teamId"` + EmployeeNo *string `json:"employeeNo"` + Active bool `json:"active"` +} + +type sportDTO struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + ShortName *string `json:"shortName"` + DefaultProfileID *int64 `json:"defaultProfileId"` + SortOrder int `json:"sortOrder"` + Active bool `json:"active"` +} + +type categoryDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Name string `json:"name"` + Multiplier float64 `json:"multiplier"` + Gender *string `json:"gender"` + AgeMin *int `json:"ageMin"` + AgeMax *int `json:"ageMax"` + SortOrder int `json:"sortOrder"` +} + +type channelDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Scope string `json:"scope"` + Basis string `json:"basis"` + ProfileID int64 `json:"profileId"` + SortOrder int `json:"sortOrder"` +} + +type eventDTO struct { + ID int64 `json:"id"` + SportID int64 `json:"sportId"` + Code string `json:"code"` + Name string `json:"name"` + EventType string `json:"eventType"` + ResultMode string `json:"resultMode"` + ResultType string `json:"resultType"` + Compare string `json:"compare"` + RecordEligible bool `json:"recordEligible"` + Scored bool `json:"scored"` + Multiplier float64 `json:"multiplier"` + Status string `json:"status"` + ScheduledAt *string `json:"scheduledAt"` + Venue *string `json:"venue"` + SortOrder int `json:"sortOrder"` + Remark *string `json:"remark"` + Categories []categoryDTO `json:"categories,omitempty"` + Channels []channelDTO `json:"channels,omitempty"` +} + +type profileRuleDTO struct { + Rank int `json:"rank"` + Points float64 `json:"points"` +} + +type profileDTO struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + TieMode string `json:"tieMode"` + RecordBonusMode string `json:"recordBonusMode"` + AwardRanks int `json:"awardRanks"` + LastPlaceZero bool `json:"lastPlaceZero"` + Rules []profileRuleDTO `json:"rules"` +} + +type entryDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + TeamID int64 `json:"teamId"` + AthleteID *int64 `json:"athleteId"` + MemberNames []string `json:"memberNames"` + Bib *string `json:"bib"` + Seed *string `json:"seed"` + Status string `json:"status"` + Remark *string `json:"remark"` + AthleteName *string `json:"athleteName,omitempty"` + TeamCode string `json:"teamCode,omitempty"` + TeamName string `json:"teamName,omitempty"` + ShortName *string `json:"shortName,omitempty"` + CategoryName string `json:"categoryName,omitempty"` +} + +type entryWithResultDTO struct { + entryDTO + Result *resultDTO `json:"result"` +} + +type resultDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + Rank *int `json:"rank"` + ResultText string `json:"resultText"` + ResultValue *float64 `json:"resultValue"` + Unit string `json:"unit"` + IsTie bool `json:"isTie"` + PointsOverride *float64 `json:"pointsOverride"` + Status string `json:"status"` + State string `json:"state"` + RecordedBy *int64 `json:"recordedBy"` + RecordedAt *string `json:"recordedAt"` + VerifiedBy *int64 `json:"verifiedBy"` + VerifiedAt *string `json:"verifiedAt"` + Note *string `json:"note"` + Points *float64 `json:"points,omitempty"` +} + +type standingRowDTO struct { + TeamID int64 `json:"teamId"` + TeamCode string `json:"teamCode"` + TeamName string `json:"teamName"` + ShortName *string `json:"shortName"` + Color *string `json:"color"` + LogoPath *string `json:"logoPath"` + Rank int `json:"rank"` + Gold int `json:"gold"` + Silver int `json:"silver"` + Bronze int `json:"bronze"` + MedalTotal int `json:"medalTotal"` + TotalPoints float64 `json:"totalPoints"` + RankChange *int `json:"rankChange"` +} + +type athleteStandingDTO struct { + Rank int `json:"rank"` + AthleteID int64 `json:"athleteId"` + AthleteName string `json:"athleteName"` + Gender *string `json:"gender"` + TeamID int64 `json:"teamId"` + TeamCode string `json:"teamCode"` + TeamName string `json:"teamName"` + ShortName *string `json:"shortName"` + TotalPoints float64 `json:"totalPoints"` + Gold int `json:"gold"` + Silver int `json:"silver"` + Bronze int `json:"bronze"` +} + +type breakdownItemDTO struct { + LedgerID int64 `json:"ledgerId"` + SourceType string `json:"sourceType"` + EventID *int64 `json:"eventId"` + EventCode *string `json:"eventCode"` + EventName *string `json:"eventName"` + CategoryName *string `json:"categoryName"` + AthleteID *int64 `json:"athleteId"` + AthleteName *string `json:"athleteName"` + Rank *int `json:"rank"` + ResultText *string `json:"resultText"` + Points float64 `json:"points"` + CreatedAt string `json:"createdAt"` +} + +type teamBreakdownDTO struct { + TeamID int64 `json:"teamId"` + TeamCode string `json:"teamCode"` + TeamName string `json:"teamName"` + TotalPoints float64 `json:"totalPoints"` + Items []breakdownItemDTO `json:"items"` +} + +type adjustmentDTO struct { + ID int64 `json:"id"` + TeamID int64 `json:"teamId"` + Delta float64 `json:"delta"` + Category string `json:"category"` + Reason string `json:"reason"` + EventID *int64 `json:"eventId"` + Voided bool `json:"voided"` + CreatedBy *int64 `json:"createdBy"` + CreatedAt string `json:"createdAt"` +} + +type recordEntryDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Level string `json:"level"` + ResultValue float64 `json:"resultValue"` + ResultText string `json:"resultText"` + HolderName *string `json:"holderName"` + HolderTeamID *int64 `json:"holderTeamId"` + AchievedAt *string `json:"achievedAt"` + IsCurrent bool `json:"isCurrent"` + Source string `json:"source"` +} + +type recordBreakDTO struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + Level string `json:"level"` + ResultValue float64 `json:"resultValue"` + ResultText string `json:"resultText"` + BonusPoints float64 `json:"bonusPoints"` + State string `json:"state"` + ApprovedBy *int64 `json:"approvedBy"` + ApprovedAt *string `json:"approvedAt"` +} + +type auditLogDTO struct { + ID int64 `json:"id"` + UserID *int64 `json:"userId"` + Action string `json:"action"` + EntityType string `json:"entityType"` + EntityID *int64 `json:"entityId"` + BeforeJSON *string `json:"beforeJson"` + AfterJSON *string `json:"afterJson"` + IP *string `json:"ip"` + RequestID *string `json:"requestId"` + CreatedAt string `json:"createdAt"` +} + +type currentUserDTO struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + LastLoginAt *string `json:"lastLoginAt"` + MustChangePassword bool `json:"mustChangePassword"` +} + +type backupResultDTO struct { + Path string `json:"path"` + CreatedAt string `json:"createdAt"` +} + +// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。 +type eventSettingsDTO struct { + Event eventSettingsEvent `json:"event"` + Display eventSettingsDisplay `json:"display"` + Standings eventSettingsStandings `json:"standings"` + DefaultProfileID *int64 `json:"defaultProfileId"` + RequireApproval bool `json:"requireApproval"` +} + +type eventSettingsEvent struct { + Name string `json:"name"` + Subtitle *string `json:"subtitle"` + Host *string `json:"host"` + LogoPath *string `json:"logoPath"` + PrimaryColor *string `json:"primaryColor"` +} + +type eventSettingsDisplay struct { + Background *string `json:"background"` + Logo *string `json:"logo"` + Title string `json:"title"` + Subtitle *string `json:"subtitle"` + PrimaryColor *string `json:"primaryColor"` + RotateSecond int `json:"rotateSeconds"` +} + +type eventSettingsStandings struct { + IndividualToTeamWeight float64 `json:"individualToTeamWeight"` + RankingMode string `json:"rankingMode"` + ShowIndividualBoard bool `json:"showIndividualBoard"` +} + +// ---------- domain → DTO ---------- + +func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO { + out := make([]standingRowDTO, 0, len(rows)) + for _, r := range rows { + t := teams[r.TeamID] + dto := standingRowDTO{ + TeamID: r.TeamID, + TeamCode: r.Code, + TeamName: r.Name, + ShortName: nilIfEmpty(r.ShortName), + Rank: r.Rank, + Gold: r.Gold, + Silver: r.Silver, + Bronze: r.Bronze, + MedalTotal: r.Medals, + TotalPoints: r.Points, + RankChange: nil, + } + if t.ID != 0 { + dto.Color = t.Color + dto.LogoPath = t.LogoPath + if dto.TeamCode == "" { + dto.TeamCode = t.Code + } + if dto.TeamName == "" { + dto.TeamName = t.Name + } + if dto.ShortName == nil { + dto.ShortName = t.ShortName + } + } + out = append(out, dto) + } + return out +} + +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go new file mode 100644 index 0000000..1e8ab7a --- /dev/null +++ b/server/internal/httpapi/export_handlers.go @@ -0,0 +1,150 @@ +package httpapi + +import ( + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" +) + +const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。 +func (s *server) exportRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX)) + mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX)) + // Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。 + mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX)) + mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX)) + + mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip)) + mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip)) + mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups)) +} + +// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。 +func (s *server) streamDownload(w http.ResponseWriter, filename, contentType string, write func(io.Writer) error) (any, error) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filename)) + if err := write(w); err != nil { + s.logger.Error("导出写出失败", "file", filename, "err", err) + } + return nil, errHandled +} + +func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + wb, err := export.LoadStandingsWorkbook(s.ev, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteStandingsXLSX(out, wb) + }) +} + +func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteEventXLSX(out, wb) + }) +} + +func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + wb, err := export.LoadAllWorkbook(s.ev, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteAllXLSX(out, wb) + }) +} + +func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteTeamDetailXLSX(out, wb) + }) +} + +func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) { + dir := filepath.Join(s.dataDir, "exports") + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, internal(err) + } + dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip") + if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{}); err != nil { + return nil, internal(err) + } + defer func() { _ = os.Remove(dst) }() + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.zip")) + http.ServeFile(w, r, dst) + return nil, errHandled +} + +func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) { + if err := r.ParseMultipartForm(512 << 20); err != nil { + return nil, badRequest("解析上传失败", nil) + } + file, _, err := r.FormFile("file") + if err != nil { + return nil, badRequest("缺少上传字段 file", nil) + } + defer func() { _ = file.Close() }() + + tmpDir, err := os.MkdirTemp("", "scoreboard-import-") + if err != nil { + return nil, internal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + tmp := filepath.Join(tmpDir, "project.zip") + out, err := os.Create(tmp) + if err != nil { + return nil, internal(err) + } + if _, err := io.Copy(out, file); err != nil { + _ = out.Close() + return nil, internal(err) + } + if err := out.Close(); err != nil { + return nil, internal(err) + } + + res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{ + Overwrite: r.FormValue("overwrite") == "1", + Rename: r.FormValue("rename"), + }) + if err != nil { + return nil, badRequest(err.Error(), nil) + } + return res, nil +} + +func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) { + items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "") + if err != nil { + return nil, internal(err) + } + return items, nil +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go new file mode 100644 index 0000000..ca16a88 --- /dev/null +++ b/server/internal/httpapi/handlers_config.go @@ -0,0 +1,652 @@ +package httpapi + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// ---------- 认证 ---------- + +func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) { + var in loginInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + if strings.TrimSpace(in.Username) == "" || in.Password == "" { + return nil, badRequest("username 与 password 必填", nil) + } + + ip := middleware.ClientIP(r) + u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent()) + switch { + case err == auth.ErrRateLimited: + return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"} + case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive: + return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()} + case err != nil: + return nil, internal(err) + } + + s.auth.SetSessionCookie(w, token) + csrf, err := auth.NewCSRFToken() + if err != nil { + return nil, internal(err) + } + s.auth.SetCSRFCookie(w, csrf) + s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username}) + return userDTO(u), nil +} + +func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) { + if c, err := r.Cookie(auth.CookieSession); err == nil { + if err := s.auth.Logout(r.Context(), c.Value); err != nil { + return nil, internal(err) + } + } + s.auth.ClearSessionCookie(w) + s.auth.ClearCSRFCookie(w) + return nil, nil +} + +func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) { + u := middleware.User(r.Context()) + return userDTO(u), nil +} + +func userDTO(u *auth.User) currentUserDTO { + dto := currentUserDTO{ + ID: u.ID, + Username: u.Username, + DisplayName: u.DisplayName, + Role: u.Role.String(), + MustChangePassword: u.MustChangePassword, + } + if u.Role == auth.RoleGuest { + dto.Username = "guest" + dto.DisplayName = "访客" + } + if u.LastLoginAt != "" { + v := u.LastLoginAt + dto.LastLoginAt = &v + } + return dto +} + +// ---------- settings ---------- + +func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.getSettings(r.Context()) +} + +func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in settingsPatch + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + before, err := s.getSettings(r.Context()) + if err != nil { + return nil, err + } + updated, err := s.updateSettings(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "settings.update", "settings", 0, before, updated) + return updated, nil + }) +} + +// ---------- teams ---------- + +func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.listTeams(r.Context()) +} + +func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getTeam(r.Context(), id) +} + +func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in teamDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createTeam(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "team.create", "team", created.ID, nil, created) + return created, nil +} + +func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in teamDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getTeam(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateTeam(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "team.update", "team", id, before, updated) + return updated, nil +} + +func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + before, err := s.getTeam(r.Context(), id) + if err != nil { + return err + } + if err := s.deleteTeam(r.Context(), id); err != nil { + return err + } + s.audit(r.Context(), "team.delete", "team", id, before, nil) + return nil +} + +// ---------- sports ---------- + +func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.listSports(r.Context()) +} + +func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getSport(r.Context(), id) +} + +func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in sportDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createSport(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created) + return created, nil +} + +func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in sportDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getSport(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateSport(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "sport.update", "sport", id, before, updated) + return updated, nil +} + +func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + before, err := s.getSport(r.Context(), id) + if err != nil { + return err + } + if err := s.deleteSport(r.Context(), id); err != nil { + return err + } + s.audit(r.Context(), "sport.delete", "sport", id, before, nil) + return nil +} + +// ---------- events ---------- + +func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + sportID, _ := int64Query(r, "sportId") + return s.listEvents(r.Context(), sportID) +} + +func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getEvent(r.Context(), id) +} + +func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in eventDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createEvent(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "event.create", "event", created.ID, nil, created) + return created, nil +} + +func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in eventDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getEvent(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateEvent(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "event.update", "event", id, before, updated) + return updated, nil +} + +func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + before, err := s.getEvent(r.Context(), id) + if err != nil { + return err + } + if err := s.deleteEvent(r.Context(), id); err != nil { + return err + } + s.audit(r.Context(), "event.delete", "event", id, before, nil) + return nil +} + +func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.listEntriesWithResults(r.Context(), id) +} + +func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.listCategories(r.Context(), id) +} + +func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in categoriesPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.listCategories(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.replaceCategories(r.Context(), id, in.Categories) + if err != nil { + return nil, err + } + s.audit(r.Context(), "event.categories.replace", "event", id, before, updated) + return updated, nil +} + +func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.listChannels(r.Context(), id) +} + +func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in channelsPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.listChannels(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.replaceChannels(r.Context(), id, in.Channels) + if err != nil { + return nil, err + } + s.audit(r.Context(), "event.channels.replace", "event", id, before, updated) + return updated, nil +} + +// ---------- scoring profiles ---------- + +func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.listProfiles(r.Context()) +} + +func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getProfile(r.Context(), id) +} + +func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in profileDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createProfile(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created) + return created, nil +} + +func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in profileDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getProfile(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateProfile(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated) + return updated, nil +} + +// ---------- athletes ---------- + +func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + teamID, _ := int64Query(r, "teamId") + return s.listAthletes(r.Context(), teamID) +} + +func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.getAthlete(r.Context(), id) +} + +func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in athleteDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + created, err := s.createAthlete(r.Context(), in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created) + return created, nil +} + +func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in athleteDTO + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + before, err := s.getAthlete(r.Context(), id) + if err != nil { + return nil, err + } + updated, err := s.updateAthlete(r.Context(), id, in) + if err != nil { + return nil, err + } + s.audit(r.Context(), "athlete.update", "athlete", id, before, updated) + return updated, nil +} + +func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error { + id, err := pathID(r, "id") + if err != nil { + return err + } + before, err := s.getAthlete(r.Context(), id) + if err != nil { + return err + } + if err := s.deleteAthlete(r.Context(), id); err != nil { + return err + } + s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil) + return nil +} + +// ---------- 报名表导入 ---------- + +type entryImportResult struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Errors []string `json:"errors"` +} + +// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。 +func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + if err := r.ParseMultipartForm(16 << 20); err != nil { + return nil, badRequest("上传文件解析失败", err.Error()) + } + file, header, err := r.FormFile("file") + if err != nil { + return nil, badRequest("缺少 file 字段", err.Error()) + } + defer func() { _ = file.Close() }() + + rows, err := readTable(filepath.Ext(header.Filename), file) + if err != nil { + return nil, err + } + result, err := s.importEntries(r.Context(), rows) + if err != nil { + return nil, err + } + s.audit(r.Context(), "entries.import", "entry", 0, nil, result) + return result, nil +} + +func readTable(ext string, file io.Reader) ([][]string, error) { + switch strings.ToLower(ext) { + case ".xlsx": + f, err := excelize.OpenReader(file) + if err != nil { + return nil, badRequest("无法解析 xlsx", err.Error()) + } + defer func() { _ = f.Close() }() + sheets := f.GetSheetList() + if len(sheets) == 0 { + return nil, badRequest("xlsx 没有工作表", nil) + } + rows, err := f.GetRows(sheets[0]) + if err != nil { + return nil, badRequest("读取 xlsx 失败", err.Error()) + } + return rows, nil + default: + reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 + reader.TrimLeadingSpace = true + rows, err := reader.ReadAll() + if err != nil { + return nil, badRequest("无法解析 CSV", err.Error()) + } + return rows, nil + } +} + +// importColumns 是报名表必须包含的表头(大小写不敏感)。 +var importColumns = []string{ + "event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names", +} + +func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) { + result := entryImportResult{Errors: []string{}} + if len(rows) < 2 { + return result, badRequest("报名表为空", nil) + } + index := make(map[string]int, len(rows[0])) + for i, h := range rows[0] { + index[strings.ToLower(strings.TrimSpace(h))] = i + } + for _, col := range importColumns { + if _, ok := index[col]; !ok { + return result, badRequest("报名表缺少列 "+col, nil) + } + } + + for n, row := range rows[1:] { + line := n + 2 + cell := func(name string) string { + i := index[name] + if i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) + } + eventCode := cell("event_code") + categoryName := cell("category") + teamCode := cell("team_code") + athleteName := cell("athlete_name") + if eventCode == "" || teamCode == "" { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line)) + continue + } + + eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode)) + continue + } + teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode)) + continue + } + var categoryID int64 + if categoryName != "" { + categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName)) + continue + } + } else { + categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode)) + continue + } + } + var athleteID *int64 + if athleteName != "" { + if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil { + athleteID = &id + } + } + + created, err := s.upsertImportedEntry(ctx, entryDTO{ + EventID: eventID, + CategoryID: categoryID, + TeamID: teamID, + AthleteID: athleteID, + MemberNames: splitNames(cell("member_names")), + Bib: nilIfEmpty(cell("bib")), + Seed: nilIfEmpty(cell("seed")), + Status: orDefault(cell("status"), "registered"), + }) + switch { + case err != nil: + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error())) + case created: + result.Created++ + default: + result.Updated++ + } + } + return result, nil +} + +func splitNames(raw string) []string { + if raw == "" { + return nil + } + parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go new file mode 100644 index 0000000..316e9b6 --- /dev/null +++ b/server/internal/httpapi/handlers_results.go @@ -0,0 +1,222 @@ +package httpapi + +import ( + "net/http" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// ---------- 成绩 ---------- + +func (s *server) listResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + eventID, _ := int64Query(r, "eventId") + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" && state != string(domain.ResultStateDraft) && state != string(domain.ResultStatePublished) { + return nil, badRequest("state 只能是 draft/published", state) + } + page, size := pageQuery(r) + return s.listResultsPage(r.Context(), eventID, state, page, size) +} + +func (s *server) saveDraftHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in saveDraftPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + if in.EventID == 0 { + return nil, badRequest("eventId 必填", nil) + } + if len(in.Results) == 0 { + return nil, badRequest("results 不能为空", nil) + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + saved, err := s.upsertDraft(r.Context(), in.EventID, middlewareUserID(r), in.Results) + if err != nil { + return nil, err + } + s.audit(r.Context(), "result.draft.save", "event", in.EventID, nil, saved) + return saved, nil + }) +} + +func (s *server) publishHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in publishPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.publishResults(r.Context(), in, middlewareUserID(r)) + }) +} + +func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in publishPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.unpublishResults(r.Context(), in, middlewareUserID(r)) + }) +} + +func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in voidResultPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.voidResult(r.Context(), id, in.Reason, middlewareUserID(r)) + }) +} + +// ---------- 积分与排名 ---------- + +func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + in, err := s.loadStandingsInput(r.Context()) + if err != nil { + return nil, err + } + teams, err := s.teamMap(r.Context()) + if err != nil { + return nil, err + } + return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil +} + +func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + in, err := s.loadStandingsInput(r.Context()) + if err != nil { + return nil, err + } + genders := make(map[int64]*string, len(in.Athletes)) + for _, a := range in.Athletes { + if a.Gender != "" { + g := a.Gender + genders[a.ID] = &g + } + } + rows := domain.BuildAthleteStandings(in) + out := make([]athleteStandingDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, athleteStandingDTO{ + Rank: row.Rank, + AthleteID: row.AthleteID, + AthleteName: row.Name, + Gender: genders[row.AthleteID], + TeamID: row.TeamID, + TeamCode: row.TeamCode, + TeamName: row.TeamName, + ShortName: nilIfEmpty(row.TeamName), + TotalPoints: row.Points, + Gold: row.Gold, + Silver: row.Silver, + Bronze: row.Bronze, + }) + } + return out, nil +} + +func (s *server) breakdownHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.teamBreakdown(r.Context(), id) +} + +// ---------- 人工调整 ---------- + +func (s *server) listAdjustmentsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + teamID, _ := int64Query(r, "teamId") + page, size := pageQuery(r) + return s.listAdjustments(r.Context(), teamID, page, size) +} + +func (s *server) createAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in adjustmentInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.createAdjustment(r.Context(), in, middlewareUserID(r)) + }) +} + +func (s *server) voidAdjustmentHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + var in voidAdjustmentPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.voidAdjustment(r.Context(), id, middlewareUserID(r), in.Reason) + }) +} + +// ---------- 纪录 ---------- + +func (s *server) listRecordsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + eventID, _ := int64Query(r, "eventId") + return s.listRecords(r.Context(), eventID) +} + +func (s *server) createRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in recordBreakInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + created, err := s.createRecordBreak(r.Context(), in, middlewareUserID(r)) + if err != nil { + return nil, err + } + s.audit(r.Context(), "record_break.create", "record_break", created.ID, nil, created) + return created, nil + }) +} + +func (s *server) approveRecordBreakHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id, err := pathID(r, "id") + if err != nil { + return nil, err + } + return s.approveRecordBreak(r.Context(), id, middlewareUserID(r)) +} + +// ---------- 大屏控制与运维 ---------- + +func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in displayCommandInput + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + if in.Action == "" { + return nil, badRequest("action 必填", nil) + } + if s.display != nil { + if err := s.display.SendDisplayCommand(in.Action, in.Payload); err != nil { + return nil, internal(err) + } + } + s.audit(r.Context(), "display.command", "display", 0, nil, in) + return nil, nil +} + +func (s *server) listAuditLogsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + page, size := pageQuery(r) + entityType := strings.TrimSpace(r.URL.Query().Get("entityType")) + userID, _ := int64Query(r, "userId") + return s.listAuditLogs(r.Context(), page, size, entityType, userID) +} + +func (s *server) backupHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.backup(r.Context()) +} diff --git a/server/internal/httpapi/idempotency.go b/server/internal/httpapi/idempotency.go new file mode 100644 index 0000000..20c8460 --- /dev/null +++ b/server/internal/httpapi/idempotency.go @@ -0,0 +1,139 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// errHandled 表示响应已经由 handler 自己写出(例如幂等回放),s.handle 不再重复写。 +var errHandled = errors.New("httpapi: response already written") + +// idempotencyStore 缓存写操作的首次响应(见 devdocs/05 第 5 节:重复提交返回首次结果)。 +// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存; +// 进程重启后客户端重试会重新执行,属于可接受的降级。 +type idempotencyStore struct { + mu sync.Mutex + entries map[string]idempotentResponse + ttl time.Duration +} + +type idempotentResponse struct { + body []byte + expires time.Time +} + +func newIdempotencyStore(ttl time.Duration) *idempotencyStore { + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl} +} + +func (s *idempotencyStore) get(key string) ([]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.entries[key] + if !ok || time.Now().After(e.expires) { + delete(s.entries, key) + return nil, false + } + return e.body, true +} + +func (s *idempotencyStore) put(key string, body []byte) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + if len(s.entries) > 4096 { + for k, e := range s.entries { + if now.After(e.expires) { + delete(s.entries, k) + } + } + } + s.entries[key] = idempotentResponse{body: body, expires: now.Add(s.ttl)} +} + +// idempotent 以 requestId 为键执行写操作;重复请求直接回放首次响应。 +// 返回值恒为 errHandled 或真正的错误——调用方写成 `return nil, s.idempotent(...)`。 +func (s *server) idempotent(w http.ResponseWriter, r *http.Request, requestID string, fn func() (any, error)) error { + if requestID == "" || s.idem == nil { + data, err := fn() + if err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data}) + return errHandled + } + + key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID) + if body, ok := s.idem.get(key); ok { + writeCached(w, body) + return errHandled + } + + data, err := fn() + if err != nil { + return err + } + body, err := json.Marshal(map[string]any{"ok": true, "data": data}) + if err != nil { + return internal(err) + } + s.idem.put(key, body) + writeCached(w, body) + return errHandled +} + +// cached 给所有写路由加一层基于 X-Request-Id 的幂等缓存: +// handler 自己写响应,这里负责在成功后留存响应体、重复请求时回放。 +func (s *server) cached(inner http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get(middleware.HeaderRequestID) + if requestID == "" || s.idem == nil { + inner.ServeHTTP(w, r) + return + } + key := fmt.Sprintf("%s|%d|%s", r.URL.Path, middlewareUserID(r), requestID) + if body, ok := s.idem.get(key); ok { + writeCached(w, body) + return + } + + cw := &captureWriter{ResponseWriter: w, status: http.StatusOK} + inner.ServeHTTP(cw, r) + if cw.status >= 200 && cw.status < 300 && cw.body.Len() > 0 { + s.idem.put(key, cw.body.Bytes()) + } + } +} + +func writeCached(w http.ResponseWriter, body []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Idempotent-Replay", "1") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +type captureWriter struct { + http.ResponseWriter + status int + body bytes.Buffer +} + +func (w *captureWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *captureWriter) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go new file mode 100644 index 0000000..ee1ab3d --- /dev/null +++ b/server/internal/httpapi/payload.go @@ -0,0 +1,108 @@ +package httpapi + +// 本文件是各写接口的请求体契约;字段名与 web/src/shared/types.ts 对齐。 + +type loginInput struct { + Username string `json:"username"` + Password string `json:"password"` + RequestID string `json:"requestId"` +} + +type saveDraftPayload struct { + EventID int64 `json:"eventId"` + CategoryID *int64 `json:"categoryId"` + Results []draftResultInput `json:"results"` + RequestID string `json:"requestId"` +} + +type draftResultInput struct { + EntryID int64 `json:"entryId"` + Rank *int `json:"rank"` + ResultText *string `json:"resultText"` + ResultValue *float64 `json:"resultValue"` + Unit *string `json:"unit"` + IsTie bool `json:"isTie"` + Status string `json:"status"` + PointsOverride *float64 `json:"pointsOverride"` + Note *string `json:"note"` +} + +type publishPayload struct { + EventID int64 `json:"eventId"` + CategoryID *int64 `json:"categoryId"` + RequestID string `json:"requestId"` +} + +type voidResultPayload struct { + Reason string `json:"reason"` + RequestID string `json:"requestId"` +} + +type adjustmentInput struct { + TeamID int64 `json:"teamId"` + Delta float64 `json:"delta"` + Category string `json:"category"` + Reason string `json:"reason"` + EventID *int64 `json:"eventId"` + RequestID string `json:"requestId"` +} + +type recordBreakInput struct { + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + Level string `json:"level"` + ResultValue float64 `json:"resultValue"` + ResultText string `json:"resultText"` + RequestID string `json:"requestId"` +} + +type displayCommandInput struct { + Action string `json:"action"` + Payload any `json:"payload"` + RequestID string `json:"requestId"` +} + +type voidAdjustmentPayload struct { + Reason string `json:"reason"` + RequestID string `json:"requestId"` +} + +type settingsPatch struct { + Event *settingsEventPatch `json:"event"` + Display *settingsDisplayPatch `json:"display"` + Standings *settingsStandingsPatch `json:"standings"` + DefaultProfileID *int64 `json:"defaultProfileId"` + RequireApproval *bool `json:"requireApproval"` + RequestID string `json:"requestId"` +} + +type settingsEventPatch struct { + Name *string `json:"name"` + Subtitle *string `json:"subtitle"` + Host *string `json:"host"` + LogoPath *string `json:"logoPath"` + PrimaryColor *string `json:"primaryColor"` +} + +type settingsDisplayPatch struct { + Background *string `json:"background"` + Logo *string `json:"logo"` + Title *string `json:"title"` + Subtitle *string `json:"subtitle"` + PrimaryColor *string `json:"primaryColor"` + RotateSeconds *float64 `json:"rotateSeconds"` +} + +type settingsStandingsPatch struct { + IndividualToTeamWeight *float64 `json:"individualToTeamWeight"` + RankingMode *string `json:"rankingMode"` + ShowIndividualBoard *bool `json:"showIndividualBoard"` +} + +type categoriesPayload struct { + Categories []categoryDTO `json:"categories"` +} + +type channelsPayload struct { + Channels []channelDTO `json:"channels"` +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go new file mode 100644 index 0000000..c13315a --- /dev/null +++ b/server/internal/httpapi/repo.go @@ -0,0 +1,582 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移, +// 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。 + +func strPtr(ns sql.NullString) *string { + if !ns.Valid { + return nil + } + v := ns.String + return &v +} + +func i64Ptr(ni sql.NullInt64) *int64 { + if !ni.Valid { + return nil + } + v := ni.Int64 + return &v +} + +func intPtr(ni sql.NullInt64) *int { + if !ni.Valid { + return nil + } + v := int(ni.Int64) + return &v +} + +func f64Ptr(nf sql.NullFloat64) *float64 { + if !nf.Valid { + return nil + } + v := nf.Float64 + return &v +} + +func nullStr(p *string) any { + if p == nil || *p == "" { + return nil + } + return *p +} + +func nullI64(p *int64) any { + if p == nil { + return nil + } + return *p +} + +func nullInt(p *int) any { + if p == nil { + return nil + } + return *p +} + +func nullF64(p *float64) any { + if p == nil { + return nil + } + return *p +} + +func lastInsertID(res sql.Result) (int64, error) { + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("获取自增 id: %w", err) + } + return id, nil +} + +// requireChanges 把「影响行数为 0」翻译成 404。 +func requireChanges(res sql.Result, message string) error { + n, err := res.RowsAffected() + if err != nil { + return internal(err) + } + if n == 0 { + return notFound(message) + } + return nil +} + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed") +} + +func isForeignKeyViolation(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "foreign key") +} + +func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) { + var one int + err := db.QueryRowContext(ctx, query, args...).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, err + } + return true, nil +} + +// ---------- teams ---------- + +const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active` + +func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) { + var ( + t teamDTO + shortName, logoPath, color sql.NullString + ) + err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active) + t.ShortName = strPtr(shortName) + t.LogoPath = strPtr(logoPath) + t.Color = strPtr(color) + return t, err +} + +func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) { + rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]teamDTO, 0) + for rows.Next() { + t, err := scanTeam(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) { + list, err := s.listTeams(ctx) + if err != nil { + return nil, err + } + out := make(map[int64]teamDTO, len(list)) + for _, t := range list { + out[t.ID] = t + } + return out, nil +} + +func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) { + t, err := scanTeam(s.ev.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return teamDTO{}, notFound("单位不存在") + } + if err != nil { + return teamDTO{}, internal(err) + } + return t, nil +} + +func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) { + if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + return teamDTO{}, badRequest("code 与 name 必填", nil) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active) + if isUniqueViolation(err) { + return teamDTO{}, conflict("单位编码已存在") + } + if err != nil { + return teamDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return teamDTO{}, internal(err) + } + return s.getTeam(ctx, id) +} + +func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) { + res, err := s.ev.ExecContext(ctx, + `UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ? + WHERE id = ?`, + in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id) + if isUniqueViolation(err) { + return teamDTO{}, conflict("单位编码已存在") + } + if err != nil { + return teamDTO{}, internal(err) + } + if err := requireChanges(res, "单位不存在"); err != nil { + return teamDTO{}, err + } + return s.getTeam(ctx, id) +} + +func (s *server) deleteTeam(ctx context.Context, id int64) error { + // 业务数据软删除:置 active=0,保留历史成绩与流水。 + res, err := s.ev.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id) + if err != nil { + return internal(err) + } + return requireChanges(res, "单位不存在") +} + +// ---------- athletes ---------- + +const athleteColumns = `id, name, gender, team_id, employee_no, active` + +func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) { + var ( + a athleteDTO + gender, empNo sql.NullString + teamID sql.NullInt64 + ) + err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active) + a.Gender = strPtr(gender) + a.EmployeeNo = strPtr(empNo) + if teamID.Valid { + a.TeamID = teamID.Int64 + } + return a, err +} + +func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) { + query := `SELECT ` + athleteColumns + ` FROM athletes` + args := []any{} + if teamID > 0 { + query += ` WHERE team_id = ?` + args = append(args, teamID) + } + query += ` ORDER BY name` + + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]athleteDTO, 0) + for rows.Next() { + a, err := scanAthlete(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) { + a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return athleteDTO{}, notFound("运动员不存在") + } + if err != nil { + return athleteDTO{}, internal(err) + } + return a, nil +} + +func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) { + if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 { + return athleteDTO{}, badRequest("name 与 teamId 必填", nil) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`, + in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active) + if isForeignKeyViolation(err) { + return athleteDTO{}, badRequest("teamId 不存在", nil) + } + if err != nil { + return athleteDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return athleteDTO{}, internal(err) + } + return s.getAthlete(ctx, id) +} + +func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) { + res, err := s.ev.ExecContext(ctx, + `UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`, + in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id) + if isForeignKeyViolation(err) { + return athleteDTO{}, badRequest("teamId 不存在", nil) + } + if err != nil { + return athleteDTO{}, internal(err) + } + if err := requireChanges(res, "运动员不存在"); err != nil { + return athleteDTO{}, err + } + return s.getAthlete(ctx, id) +} + +func (s *server) deleteAthlete(ctx context.Context, id int64) error { + res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id) + if err != nil { + return internal(err) + } + return requireChanges(res, "运动员不存在") +} + +// ---------- sports ---------- + +const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active` + +func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) { + var ( + sp sportDTO + shortName sql.NullString + defaultProfileID sql.NullInt64 + ) + err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active) + sp.ShortName = strPtr(shortName) + sp.DefaultProfileID = i64Ptr(defaultProfileID) + return sp, err +} + +func (s *server) listSports(ctx context.Context) ([]sportDTO, error) { + rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]sportDTO, 0) + for rows.Next() { + sp, err := scanSport(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, sp) + } + return out, rows.Err() +} + +func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) { + sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return sportDTO{}, notFound("大项不存在") + } + if err != nil { + return sportDTO{}, internal(err) + } + return sp, nil +} + +func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) { + if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + return sportDTO{}, badRequest("code 与 name 必填", nil) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active) + VALUES (?, ?, ?, ?, ?, ?)`, + in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active) + if isUniqueViolation(err) { + return sportDTO{}, conflict("大项编码已存在") + } + if err != nil { + return sportDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return sportDTO{}, internal(err) + } + return s.getSport(ctx, id) +} + +func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) { + res, err := s.ev.ExecContext(ctx, + `UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ? + WHERE id = ?`, + in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id) + if isUniqueViolation(err) { + return sportDTO{}, conflict("大项编码已存在") + } + if err != nil { + return sportDTO{}, internal(err) + } + if err := requireChanges(res, "大项不存在"); err != nil { + return sportDTO{}, err + } + return s.getSport(ctx, id) +} + +func (s *server) deleteSport(ctx context.Context, id int64) error { + // 大项有子项目时不允许软删,避免项目挂在不可见的大项下。 + has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id) + if err != nil { + return internal(err) + } + if has { + return conflict("大项下仍有小项,不能删除") + } + res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id) + if err != nil { + return internal(err) + } + return requireChanges(res, "大项不存在") +} + +// ---------- scoring profiles ---------- + +func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero + FROM scoring_profiles ORDER BY code`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]profileDTO, 0) + for rows.Next() { + var p profileDTO + if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil { + return nil, internal(err) + } + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + for i := range out { + rules, err := s.profileRules(ctx, out[i].ID) + if err != nil { + return nil, err + } + out[i].Rules = rules + } + return out, nil +} + +func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]profileRuleDTO, 0) + for rows.Next() { + var r profileRuleDTO + if err := rows.Scan(&r.Rank, &r.Points); err != nil { + return nil, internal(err) + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) { + var p profileDTO + err := s.ev.QueryRowContext(ctx, + `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero + FROM scoring_profiles WHERE id = ?`, id). + Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero) + if errors.Is(err, sql.ErrNoRows) { + return profileDTO{}, notFound("积分模板不存在") + } + if err != nil { + return profileDTO{}, internal(err) + } + rules, err := s.profileRules(ctx, p.ID) + if err != nil { + return profileDTO{}, err + } + p.Rules = rules + return p, nil +} + +func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) { + if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + return profileDTO{}, badRequest("code 与 name 必填", nil) + } + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return profileDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, + `INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) + VALUES (?, ?, ?, ?, ?, ?)`, + in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero) + if isUniqueViolation(err) { + return profileDTO{}, conflict("积分模板编码已存在") + } + if err != nil { + return profileDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return profileDTO{}, internal(err) + } + if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { + return profileDTO{}, err + } + if err := tx.Commit(); err != nil { + return profileDTO{}, internal(err) + } + return s.getProfile(ctx, id) +} + +func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return profileDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, + `UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?, + award_ranks = ?, last_place_zero = ?, updated_at = datetime('now') + WHERE id = ?`, + in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id) + if isUniqueViolation(err) { + return profileDTO{}, conflict("积分模板编码已存在") + } + if err != nil { + return profileDTO{}, internal(err) + } + if err := requireChanges(res, "积分模板不存在"); err != nil { + return profileDTO{}, err + } + if in.Rules != nil { + if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { + return profileDTO{}, err + } + } + if err := tx.Commit(); err != nil { + return profileDTO{}, internal(err) + } + return s.getProfile(ctx, id) +} + +func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil { + return internal(err) + } + for _, r := range rules { + if r.Rank < 1 { + return badRequest("积分名次必须 >= 1", r) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, + profileID, r.Rank, r.Points); err != nil { + return internal(err) + } + } + return nil +} + +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go new file mode 100644 index 0000000..0027aab --- /dev/null +++ b/server/internal/httpapi/repo_events.go @@ -0,0 +1,449 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +// ---------- events ---------- + +const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark` + +func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) { + var ( + e eventDTO + scheduledAt, venue, note sql.NullString + ) + err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, + &e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, + &scheduledAt, &venue, &e.SortOrder, ¬e) + e.ScheduledAt = strPtr(scheduledAt) + e.Venue = strPtr(venue) + e.Remark = strPtr(note) + return e, err +} + +func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) { + query := `SELECT ` + eventColumns + ` FROM events` + args := []any{} + if sportID > 0 { + query += ` WHERE sport_id = ?` + args = append(args, sportID) + } + query += ` ORDER BY sort_order, code` + + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]eventDTO, 0) + for rows.Next() { + e, err := scanEvent(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) { + e, err := scanEvent(s.ev.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return eventDTO{}, notFound("小项不存在") + } + if err != nil { + return eventDTO{}, internal(err) + } + cats, err := s.listCategories(ctx, id) + if err != nil { + return eventDTO{}, err + } + chs, err := s.listChannels(ctx, id) + if err != nil { + return eventDTO{}, err + } + e.Categories = cats + e.Channels = chs + return e, nil +} + +func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) { + if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { + return eventDTO{}, badRequest("sportId、code、name 必填", nil) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + in.SportID, in.Code, in.Name, + orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"), + orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"), + in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"), + nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark)) + if isUniqueViolation(err) { + return eventDTO{}, conflict("小项编码已存在") + } + if isForeignKeyViolation(err) { + return eventDTO{}, badRequest("sportId 不存在", nil) + } + if err != nil { + return eventDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return eventDTO{}, internal(err) + } + return s.getEvent(ctx, id) +} + +func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) { + res, err := s.ev.ExecContext(ctx, + `UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?, + result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?, + status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ? + WHERE id = ?`, + in.SportID, in.Code, in.Name, + orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"), + orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"), + in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"), + nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id) + if isUniqueViolation(err) { + return eventDTO{}, conflict("小项编码已存在") + } + if isForeignKeyViolation(err) { + return eventDTO{}, badRequest("sportId 不存在", nil) + } + if err != nil { + return eventDTO{}, internal(err) + } + if err := requireChanges(res, "小项不存在"); err != nil { + return eventDTO{}, err + } + return s.getEvent(ctx, id) +} + +func (s *server) deleteEvent(ctx context.Context, id int64) error { + has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id) + if err != nil { + return internal(err) + } + if has { + return conflict("小项已有报名数据,不能删除") + } + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return internal(err) + } + defer func() { _ = tx.Rollback() }() + for _, stmt := range []string{ + `DELETE FROM event_score_channels WHERE event_id = ?`, + `DELETE FROM event_categories WHERE event_id = ?`, + `DELETE FROM events WHERE id = ?`, + } { + if _, err := tx.ExecContext(ctx, stmt, id); err != nil { + return internal(err) + } + } + if err := tx.Commit(); err != nil { + return internal(err) + } + return nil +} + +func multiplierOrOne(v float64) float64 { + if v <= 0 { + return 1 + } + return v +} + +// ---------- categories ---------- + +func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order + FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]categoryDTO, 0) + for rows.Next() { + var ( + c categoryDTO + gender sql.NullString + ageMin, ageMax sql.NullInt64 + ) + if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil { + return nil, internal(err) + } + c.Gender = strPtr(gender) + c.AgeMin = intPtr(ageMin) + c.AgeMax = intPtr(ageMax) + out = append(out, c) + } + return out, rows.Err() +} + +// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换, +// 否则外键会把历史报名项一并带走。 +func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) { + has, err := exists(ctx, s.ev, + `SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id + WHERE c.event_id = ? LIMIT 1`, eventID) + if err != nil { + return nil, internal(err) + } + if has { + return nil, conflict("该项目已有报名数据,不能整体替换组别") + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil { + return nil, internal(err) + } + for i, c := range in { + if strings.TrimSpace(c.Name) == "" { + return nil, badRequest("组别 name 必填", i) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil { + if isUniqueViolation(err) { + return nil, conflict("组别名称重复:" + c.Name) + } + return nil, internal(err) + } + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + return s.listCategories(ctx, eventID) +} + +// ---------- score channels ---------- + +func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT id, event_id, scope, basis, profile_id, sort_order + FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]channelDTO, 0) + for rows.Next() { + var c channelDTO + if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil { + return nil, internal(err) + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { + return nil, internal(err) + } + for i, c := range in { + if c.Scope != "athlete" && c.Scope != "team" { + return nil, badRequest("通道 scope 只能是 athlete/team", i) + } + switch c.Basis { + case "result", "manual", "aggregate": + default: + return nil, badRequest("通道 basis 非法", i) + } + if c.ProfileID == 0 { + return nil, badRequest("通道 profileId 必填", i) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, ?, ?, ?, ?)`, + eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil { + if isUniqueViolation(err) { + return nil, conflict("同一 scope+basis 只能配置一个通道") + } + if isForeignKeyViolation(err) { + return nil, badRequest("profileId 不存在", i) + } + return nil, internal(err) + } + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + return s.listChannels(ctx, eventID) +} + +// ---------- entries ---------- + +const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, + e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` + +func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { + var ( + e entryDTO + athleteID sql.NullInt64 + memberNames sql.NullString + bib, seed, rem sql.NullString + athleteName sql.NullString + teamCode sql.NullString + teamName sql.NullString + shortName sql.NullString + categoryName sql.NullString + ) + if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, + &bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { + return e, err + } + e.AthleteID = i64Ptr(athleteID) + e.MemberNames = parseNames(memberNames) + e.Bib = strPtr(bib) + e.Seed = strPtr(seed) + e.Remark = strPtr(rem) + e.AthleteName = strPtr(athleteName) + e.TeamCode = teamCode.String + e.TeamName = teamName.String + e.ShortName = strPtr(shortName) + e.CategoryName = categoryName.String + return e, nil +} + +const entrySelect = `SELECT ` + entryColumns + ` + FROM entries e + LEFT JOIN athletes a ON a.id = e.athlete_id + JOIN teams t ON t.id = e.team_id + JOIN event_categories c ON c.id = e.category_id` + +func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) { + rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]entryDTO, 0) + for rows.Next() { + e, err := scanEntry(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。 +func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) { + entries, err := s.listEntries(ctx, eventID) + if err != nil { + return nil, err + } + results, err := s.resultsByEvent(ctx, eventID) + if err != nil { + return nil, err + } + byEntry := make(map[int64]resultDTO, len(results)) + for _, r := range results { + byEntry[r.EntryID] = r + } + + out := make([]entryWithResultDTO, 0, len(entries)) + for _, e := range entries { + item := entryWithResultDTO{entryDTO: e} + if r, ok := byEntry[e.ID]; ok { + cp := r + item.Result = &cp + } + out = append(out, item) + } + return out, nil +} + +func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) { + e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return entryDTO{}, notFound("报名项不存在") + } + if err != nil { + return entryDTO{}, internal(err) + } + return e, nil +} + +func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) { + if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 { + return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), + nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark)) + if isUniqueViolation(err) { + return entryDTO{}, conflict("该报名项已存在") + } + if isForeignKeyViolation(err) { + return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil) + } + if err != nil { + return entryDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return entryDTO{}, internal(err) + } + return s.getEntry(ctx, id) +} + +func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) { + res, err := s.ev.ExecContext(ctx, + `UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?, + bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`, + in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), + nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id) + if isUniqueViolation(err) { + return entryDTO{}, conflict("该报名项已存在") + } + if isForeignKeyViolation(err) { + return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil) + } + if err != nil { + return entryDTO{}, internal(err) + } + if err := requireChanges(res, "报名项不存在"); err != nil { + return entryDTO{}, err + } + return s.getEntry(ctx, id) +} + +func (s *server) deleteEntry(ctx context.Context, id int64) error { + // 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。 + res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id) + if err != nil { + return internal(err) + } + return requireChanges(res, "报名项不存在") +} diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go new file mode 100644 index 0000000..d481864 --- /dev/null +++ b/server/internal/httpapi/repo_import.go @@ -0,0 +1,52 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" +) + +func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) { + var id int64 + if err := s.ev.QueryRowContext(ctx, query, args...).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。 +func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) { + var existing int64 + var err error + if in.AthleteID != nil { + err = s.ev.QueryRowContext(ctx, + `SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`, + in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing) + } else { + err = s.ev.QueryRowContext(ctx, + `SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`, + in.CategoryID, in.TeamID).Scan(&existing) + } + + switch { + case errors.Is(err, sql.ErrNoRows): + if _, err := s.ev.ExecContext(ctx, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), + nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil { + return false, err + } + return true, nil + case err != nil: + return false, err + default: + if _, err := s.ev.ExecContext(ctx, + `UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`, + marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), + orDefault(in.Status, "registered"), existing); err != nil { + return false, err + } + return false, nil + } +} diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go new file mode 100644 index 0000000..6f48d99 --- /dev/null +++ b/server/internal/httpapi/repo_misc.go @@ -0,0 +1,760 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// ---------- settings ---------- + +func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) { + rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, internal(err) + } + out[k] = v + } + return out, rows.Err() +} + +func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) { + values, err := loadSettingsMap(ctx, s.ev) + if err != nil { + return eventSettingsDTO{}, err + } + + var out eventSettingsDTO + out.Event.Name = orDefault(values["event.name"], "未命名赛事") + out.Event.Subtitle = nilIfEmpty(values["event.subtitle"]) + out.Event.Host = nilIfEmpty(values["event.organizer"]) + out.Event.LogoPath = nilIfEmpty(values["event.logo_path"]) + out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"]) + + out.Display.Background = nilIfEmpty(values["display.background"]) + out.Display.Logo = nilIfEmpty(values["display.logo"]) + if out.Display.Logo == nil { + out.Display.Logo = out.Event.LogoPath + } + out.Display.Title = orDefault(values["display.title"], out.Event.Name) + out.Display.Subtitle = nilIfEmpty(values["display.subtitle"]) + if out.Display.Subtitle == nil { + out.Display.Subtitle = out.Event.Subtitle + } + out.Display.PrimaryColor = out.Event.PrimaryColor + out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15) + + out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) + out.Standings.RankingMode = orDefault(values["standings.mode"], "points") + out.Standings.ShowIndividualBoard = boolDefault( + firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true) + + out.RequireApproval = boolDefault(values["results.review_required"], false) + + if code := values["scoring.default_profile"]; code != "" { + var id int64 + err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id) + switch { + case errors.Is(err, sql.ErrNoRows): + // 配置引用的模板已被删除:返回 null,由管理端提示重新选择。 + case err != nil: + return eventSettingsDTO{}, internal(err) + default: + out.DefaultProfileID = &id + } + } + return out, nil +} + +func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return eventSettingsDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + set := func(key, value string) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + key, value) + if err != nil { + return internal(err) + } + return nil + } + setPtr := func(key string, v *string) error { + if v == nil { + return nil + } + return set(key, *v) + } + + if p := patch.Event; p != nil { + if err := setPtr("event.name", p.Name); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("event.subtitle", p.Subtitle); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("event.organizer", p.Host); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("event.logo_path", p.LogoPath); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { + return eventSettingsDTO{}, err + } + } + if p := patch.Display; p != nil { + if err := setPtr("display.background", p.Background); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("display.logo", p.Logo); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("display.title", p.Title); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("display.subtitle", p.Subtitle); err != nil { + return eventSettingsDTO{}, err + } + if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { + return eventSettingsDTO{}, err + } + if p.RotateSeconds != nil { + if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil { + return eventSettingsDTO{}, err + } + } + } + if p := patch.Standings; p != nil { + if p.IndividualToTeamWeight != nil { + if err := set("standings.individual_to_team_weight", + strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil { + return eventSettingsDTO{}, err + } + } + if err := setPtr("standings.mode", p.RankingMode); err != nil { + return eventSettingsDTO{}, err + } + if p.ShowIndividualBoard != nil { + if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil { + return eventSettingsDTO{}, err + } + } + } + if patch.DefaultProfileID != nil { + code := "" + if *patch.DefaultProfileID != 0 { + if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil) + } + return eventSettingsDTO{}, internal(err) + } + } + if err := set("scoring.default_profile", code); err != nil { + return eventSettingsDTO{}, err + } + } + if patch.RequireApproval != nil { + if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil { + return eventSettingsDTO{}, err + } + } + + if err := tx.Commit(); err != nil { + return eventSettingsDTO{}, internal(err) + } + return s.getSettings(ctx) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func atoiDefault(v string, def int) int { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return def + } + return n +} + +func atofDefault(v string, def float64) float64 { + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return def + } + return n +} + +func boolDefault(v string, def bool) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes": + return true + case "false", "0", "no": + return false + default: + return def + } +} + +// ---------- standings ---------- + +func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) { + teams, err := s.domainTeams(ctx) + if err != nil { + return domain.StandingsInput{}, err + } + athletes, err := s.domainAthletes(ctx) + if err != nil { + return domain.StandingsInput{}, err + } + ledger, err := s.activeLedger(ctx) + if err != nil { + return domain.StandingsInput{}, err + } + ranked, err := s.buildRanked(ctx) + if err != nil { + return domain.StandingsInput{}, err + } + values, err := loadSettingsMap(ctx, s.ev) + if err != nil { + return domain.StandingsInput{}, err + } + weight := atofDefault(values["standings.individual_to_team_weight"], 1) + exclude := !boolDefault(values["standings.medal_counts_team_events"], true) + + return domain.StandingsInput{ + Teams: teams, + Athletes: athletes, + Ledger: ledger, + Ranked: ranked, + IndividualToTeamWeight: weight, + ExcludeTeamEventMedals: exclude, + }, nil +} + +func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.Team, 0) + for rows.Next() { + var ( + t domain.Team + shortName sql.NullString + color sql.NullString + ) + if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil { + return nil, internal(err) + } + t.ShortName = shortName.String + t.Color = color.String + out = append(out, t) + } + return out, rows.Err() +} + +func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) { + rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.Athlete, 0) + for rows.Next() { + var ( + a domain.Athlete + gender sql.NullString + teamID sql.NullInt64 + ) + if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil { + return nil, internal(err) + } + a.Gender = gender.String + a.TeamID = teamID.Int64 + out = append(out, a) + } + return out, rows.Err() +} + +// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。 +// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。 +func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status, + e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id + FROM results r + JOIN entries e ON e.id = r.entry_id + JOIN event_score_channels c ON c.event_id = r.event_id + LEFT JOIN athletes a ON a.id = e.athlete_id + WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok' + AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.RankedResult, 0) + for rows.Next() { + var ( + rr domain.RankedResult + value sql.NullFloat64 + athleteID sql.NullInt64 + rank int + ) + if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status, + &rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil { + return nil, internal(err) + } + rr.Rank = rank + if value.Valid { + v := value.Float64 + rr.ResultValue = &v + } + rr.AthleteID = athleteID.Int64 + rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} + out = append(out, rr) + } + return out, rows.Err() +} + +func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) { + team, err := s.getTeam(ctx, teamID) + if err != nil { + return teamBreakdownDTO{}, err + } + rows, err := s.ev.QueryContext(ctx, + `SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name, + l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '') + FROM score_ledger l + LEFT JOIN events ev ON ev.id = l.event_id + LEFT JOIN entries e ON e.id = l.entry_id + LEFT JOIN event_categories cat ON cat.id = e.category_id + LEFT JOIN athletes a ON a.id = l.athlete_id + LEFT JOIN results r ON r.id = l.result_id + WHERE l.team_id = ? AND l.voided = 0 + ORDER BY l.id DESC`, teamID) + if err != nil { + return teamBreakdownDTO{}, internal(err) + } + defer func() { _ = rows.Close() }() + + out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}} + for rows.Next() { + var ( + item breakdownItemDTO + eventID, athleteID sql.NullInt64 + eventCode, eventName sql.NullString + categoryName, athleteName sql.NullString + rank sql.NullInt64 + resultText sql.NullString + ) + if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName, + &categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil { + return teamBreakdownDTO{}, internal(err) + } + item.EventID = i64Ptr(eventID) + item.EventCode = strPtr(eventCode) + item.EventName = strPtr(eventName) + item.CategoryName = strPtr(categoryName) + item.AthleteID = i64Ptr(athleteID) + item.AthleteName = strPtr(athleteName) + item.Rank = intPtr(rank) + item.ResultText = strPtr(resultText) + out.Items = append(out.Items, item) + out.TotalPoints += item.Points + } + return out, rows.Err() +} + +// ---------- records ---------- + +func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) { + query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''), + holder_name, holder_team_id, achieved_at, is_current, source + FROM records WHERE is_current = 1` + args := []any{} + if eventID > 0 { + query += ` AND event_id = ?` + args = append(args, eventID) + } + query += ` ORDER BY event_id, level` + + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]recordEntryDTO, 0) + for rows.Next() { + var ( + r recordEntryDTO + holderName sql.NullString + holderTeamID sql.NullInt64 + achievedAt sql.NullString + ) + if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText, + &holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil { + return nil, internal(err) + } + r.HolderName = strPtr(holderName) + r.HolderTeamID = i64Ptr(holderTeamID) + r.AchievedAt = strPtr(achievedAt) + out = append(out, r) + } + return out, rows.Err() +} + +func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) { + if in.EventID == 0 || in.EntryID == 0 || in.Level == "" { + return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil) + } + switch in.Level { + case "meet", "city", "province", "national": + default: + return recordBreakDTO{}, badRequest("level 非法", in.Level) + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state) + VALUES (?, ?, ?, ?, ?, 0, 'pending')`, + in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText) + if isForeignKeyViolation(err) { + return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil) + } + if err != nil { + return recordBreakDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return recordBreakDTO{}, internal(err) + } + return s.getRecordBreak(ctx, id) +} + +func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) { + var ( + b recordBreakDTO + value sql.NullFloat64 + text sql.NullString + approvedBy sql.NullInt64 + approvedAt sql.NullString + ) + err := s.ev.QueryRowContext(ctx, + `SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at + FROM record_breaks WHERE id = ?`, id). + Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt) + if errors.Is(err, sql.ErrNoRows) { + return recordBreakDTO{}, notFound("破纪录登记不存在") + } + if err != nil { + return recordBreakDTO{}, internal(err) + } + b.ResultValue = value.Float64 + b.ResultText = text.String + b.ApprovedBy = i64Ptr(approvedBy) + b.ApprovedAt = strPtr(approvedAt) + return b, nil +} + +func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return recordBreakDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + var state string + var eventID int64 + if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return recordBreakDTO{}, notFound("破纪录登记不存在") + } + return recordBreakDTO{}, internal(err) + } + if state != "pending" { + return recordBreakDTO{}, stateInvalid("该破纪录登记已处理") + } + if _, err := tx.ExecContext(ctx, + `UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`, + nullID(userID), id); err != nil { + return recordBreakDTO{}, internal(err) + } + if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil { + return recordBreakDTO{}, err + } + if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil { + return recordBreakDTO{}, err + } + if err := tx.Commit(); err != nil { + return recordBreakDTO{}, internal(err) + } + s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"}) + return s.getRecordBreak(ctx, id) +} + +// ---------- adjustments ---------- + +func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) { + where := []string{"1 = 1"} + args := []any{} + if teamID > 0 { + where = append(where, "team_id = ?") + args = append(args, teamID) + } + clause := " WHERE " + joinAnd(where) + + var total int + if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil { + return pageResult[adjustmentDTO]{}, internal(err) + } + query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at + FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, size, (page-1)*size) + + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return pageResult[adjustmentDTO]{}, internal(err) + } + defer func() { _ = rows.Close() }() + + items := make([]adjustmentDTO, 0) + for rows.Next() { + var ( + a adjustmentDTO + eventID, createdBy sql.NullInt64 + ) + if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil { + return pageResult[adjustmentDTO]{}, internal(err) + } + a.EventID = i64Ptr(eventID) + a.CreatedBy = i64Ptr(createdBy) + items = append(items, a) + } + if err := rows.Err(); err != nil { + return pageResult[adjustmentDTO]{}, internal(err) + } + return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil +} + +func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) { + values, err := loadSettingsMap(ctx, s.ev) + if err != nil { + return adjustmentDTO{}, err + } + if !boolDefault(values["manual_adjustment.enabled"], false) { + return adjustmentDTO{}, stateInvalid("人工调整当前未启用") + } + if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" { + return adjustmentDTO{}, badRequest("reason 必填", nil) + } + switch in.Category { + case "penalty", "bonus", "correction": + default: + return adjustmentDTO{}, badRequest("category 非法", in.Category) + } + if in.TeamID == 0 { + return adjustmentDTO{}, badRequest("teamId 必填", nil) + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return adjustmentDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, + `INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by) + VALUES (?, ?, ?, ?, ?, ?)`, + in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID)) + if isForeignKeyViolation(err) { + return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil) + } + if err != nil { + return adjustmentDTO{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return adjustmentDTO{}, internal(err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by) + VALUES (?, ?, 'adjustment', ?, ?, ?)`, + in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil { + return adjustmentDTO{}, internal(err) + } + if err := tx.Commit(); err != nil { + return adjustmentDTO{}, internal(err) + } + s.audit(ctx, "adjustment.create", "adjustment", id, nil, in) + return s.getAdjustment(ctx, id) +} + +func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) { + var ( + a adjustmentDTO + eventID, createdBy sql.NullInt64 + ) + err := s.ev.QueryRowContext(ctx, + `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at + FROM point_adjustments WHERE id = ?`, id). + Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return adjustmentDTO{}, notFound("人工调整不存在") + } + if err != nil { + return adjustmentDTO{}, internal(err) + } + a.EventID = i64Ptr(eventID) + a.CreatedBy = i64Ptr(createdBy) + return a, nil +} + +func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return adjustmentDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + var voided bool + if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return adjustmentDTO{}, notFound("人工调整不存在") + } + return adjustmentDTO{}, internal(err) + } + if voided { + return adjustmentDTO{}, stateInvalid("该人工调整已作废") + } + if _, err := tx.ExecContext(ctx, + `UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`, + nullID(userID), id); err != nil { + return adjustmentDTO{}, internal(err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? + WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`, + nullID(userID), reason, id); err != nil { + return adjustmentDTO{}, internal(err) + } + if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil { + return adjustmentDTO{}, err + } + if err := tx.Commit(); err != nil { + return adjustmentDTO{}, internal(err) + } + s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason}) + return s.getAdjustment(ctx, id) +} + +// ---------- audit logs ---------- + +func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64) (pageResult[auditLogDTO], error) { + where := []string{"1 = 1"} + args := []any{} + if entityType != "" { + where = append(where, "entity_type = ?") + args = append(args, entityType) + } + if userID > 0 { + where = append(where, "user_id = ?") + args = append(args, userID) + } + clause := " WHERE " + joinAnd(where) + + var total int + if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil { + return pageResult[auditLogDTO]{}, internal(err) + } + query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at + FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, size, (page-1)*size) + + rows, err := s.ev.QueryContext(ctx, query, args...) + if err != nil { + return pageResult[auditLogDTO]{}, internal(err) + } + defer func() { _ = rows.Close() }() + + items := make([]auditLogDTO, 0) + for rows.Next() { + var ( + a auditLogDTO + uid, eid sql.NullInt64 + before sql.NullString + after sql.NullString + ip, reqID sql.NullString + ) + if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil { + return pageResult[auditLogDTO]{}, internal(err) + } + a.UserID = i64Ptr(uid) + a.EntityID = i64Ptr(eid) + a.BeforeJSON = strPtr(before) + a.AfterJSON = strPtr(after) + a.IP = strPtr(ip) + a.RequestID = strPtr(reqID) + items = append(items, a) + } + if err := rows.Err(); err != nil { + return pageResult[auditLogDTO]{}, internal(err) + } + return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil +} + +// ---------- backup ---------- + +// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。 +func (s *server) backup(ctx context.Context) (backupResultDTO, error) { + if strings.TrimSpace(s.dataDir) == "" { + return backupResultDTO{}, internal(errors.New("未配置数据目录")) + } + dir := filepath.Join(s.dataDir, "backups") + if err := os.MkdirAll(dir, 0o755); err != nil { + return backupResultDTO{}, internal(err) + } + now := time.Now() + stamp := now.Format("20060102-150405") + path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp)) + if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil { + return backupResultDTO{}, internal(err) + } + eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp)) + if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil { + return backupResultDTO{}, internal(err) + } + s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path}) + return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil +} diff --git a/server/internal/httpapi/repo_results.go b/server/internal/httpapi/repo_results.go new file mode 100644 index 0000000..2a0e549 --- /dev/null +++ b/server/internal/httpapi/repo_results.go @@ -0,0 +1,231 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// ---------- 成员姓名 JSON 数组 ---------- + +func parseNames(ns sql.NullString) []string { + if !ns.Valid || ns.String == "" { + return nil + } + var out []string + if err := json.Unmarshal([]byte(ns.String), &out); err != nil { + return nil + } + return out +} + +func marshalNames(names []string) any { + if len(names) == 0 { + return nil + } + raw, err := json.Marshal(names) + if err != nil { + return nil + } + return string(raw) +} + +// ---------- results ---------- + +const resultColumns = `id, event_id, entry_id, rank, result_text, result_value, unit, is_tie, + points_override, status, state, recorded_by, recorded_at, verified_by, verified_at, note` + +func scanResult(row interface{ Scan(...any) error }) (resultDTO, error) { + var ( + r resultDTO + rank sql.NullInt64 + text, unit sql.NullString + value, override sql.NullFloat64 + recordedBy, verifiedBy sql.NullInt64 + recordedAt, verifiedAt, note sql.NullString + ) + if err := row.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie, + &override, &r.Status, &r.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e); err != nil { + return r, err + } + r.Rank = intPtr(rank) + r.ResultText = text.String + r.ResultValue = f64Ptr(value) + r.Unit = unit.String + r.PointsOverride = f64Ptr(override) + r.RecordedBy = i64Ptr(recordedBy) + r.RecordedAt = strPtr(recordedAt) + r.VerifiedBy = i64Ptr(verifiedBy) + r.VerifiedAt = strPtr(verifiedAt) + r.Note = strPtr(note) + return r, nil +} + +func (s *server) resultsByEvent(ctx context.Context, eventID int64) ([]resultDTO, error) { + return queryResults(ctx, s.ev, `SELECT `+resultColumns+` FROM results WHERE event_id = ? ORDER BY id`, eventID) +} + +func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]resultDTO, error) { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]resultDTO, 0) + for rows.Next() { + r, err := scanResult(rows) + if err != nil { + return nil, internal(err) + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) { + r, err := scanResult(s.ev.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return resultDTO{}, notFound("成绩不存在") + } + if err != nil { + return resultDTO{}, internal(err) + } + return r, nil +} + +func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) { + where := []string{"1 = 1"} + args := []any{} + if eventID > 0 { + where = append(where, "event_id = ?") + args = append(args, eventID) + } + if state != "" { + where = append(where, "state = ?") + args = append(args, state) + } + clause := " WHERE " + joinAnd(where) + + var total int + if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM results`+clause, args...).Scan(&total); err != nil { + return pageResult[resultDTO]{}, internal(err) + } + + query := `SELECT ` + resultColumns + ` FROM results` + clause + ` ORDER BY id LIMIT ? OFFSET ?` + args = append(args, size, (page-1)*size) + items, err := queryResults(ctx, s.ev, query, args...) + if err != nil { + return pageResult[resultDTO]{}, err + } + return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil +} + +// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。 +func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + for i, it := range items { + if it.EntryID == 0 { + return nil, badRequest("results[].entryId 必填", i) + } + var entryEvent int64 + if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, badRequest("entryId 不存在", it.EntryID) + } + return nil, internal(err) + } + if entryEvent != eventID { + return nil, badRequest("entryId 不属于该小项", it.EntryID) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie, + points_override, status, state, recorded_by, recorded_at, note, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, datetime('now'), ?, datetime('now')) + ON CONFLICT(entry_id) DO UPDATE SET + rank = excluded.rank, + result_text = excluded.result_text, + result_value = excluded.result_value, + unit = excluded.unit, + is_tie = excluded.is_tie, + points_override = excluded.points_override, + status = excluded.status, + note = excluded.note, + recorded_by = excluded.recorded_by, + recorded_at = excluded.recorded_at, + updated_at = datetime('now')`, + eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue), + nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"), + nullID(userID), nullStr(it.Note)); err != nil { + return nil, internal(err) + } + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + return s.resultsByEvent(ctx, eventID) +} + +func nullID(id int64) any { + if id == 0 { + return nil + } + return id +} + +func joinAnd(parts []string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += " AND " + } + out += p + } + return out +} + +// ---------- ledger ---------- + +func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) { + return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`) +} + +func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.LedgerEntry, 0) + for rows.Next() { + var ( + l domain.LedgerEntry + athlete sql.NullInt64 + event sql.NullInt64 + entry sql.NullInt64 + result sql.NullInt64 + created sql.NullString + ) + if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result, + &l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); err != nil { + return nil, internal(err) + } + l.AthleteID = athlete.Int64 + l.EventID = event.Int64 + l.EntryID = entry.Int64 + l.ResultID = result.Int64 + l.CreatedAt = created.String + out = append(out, l) + } + return out, rows.Err() +} diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go new file mode 100644 index 0000000..14b7549 --- /dev/null +++ b/server/internal/httpapi/response.go @@ -0,0 +1,193 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// apiError 是可直接映射为统一响应结构的业务错误(见 devdocs/05 第 8 节错误码)。 +type apiError struct { + status int + code string + message string + detail any +} + +func (e *apiError) Error() string { return e.code + ": " + e.message } + +func badRequest(message string, detail any) *apiError { + return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail} +} + +func notFound(message string) *apiError { + return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message} +} + +func conflict(message string) *apiError { + return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message} +} + +func stateInvalid(message string) *apiError { + return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message} +} + +func internal(err error) *apiError { + return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()} +} + +func notImplemented(message string) *apiError { + return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message} +} + +// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。 +type handler func(w http.ResponseWriter, r *http.Request) (any, error) + +// handle 统一把业务结果/错误写成 REST 响应。 +func (s *server) handle(fn handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, err := fn(w, r) + if err != nil { + if errors.Is(err, errHandled) { + return + } + writeAPIError(w, s.logger, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data}) + } +} + +// handleVoid 用于无返回体的写操作。 +func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := fn(w, r); err != nil { + writeAPIError(w, s.logger, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil}) + } +} + +func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) { + var api *apiError + if !errors.As(err, &api) { + api = internal(err) + } + if api.status >= http.StatusInternalServerError { + logger.Error("请求失败", "code", api.code, "err", api.message) + } + body := map[string]any{ + "ok": false, + "error": map[string]any{ + "code": api.code, + "message": api.message, + }, + } + if api.detail != nil { + body["error"].(map[string]any)["detail"] = api.detail + } + writeJSON(w, api.status, body) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// decodeJSON 解析请求体;空体视为错误,避免把「没传参数」当成合法写操作。 +// 不启用 DisallowUnknownFields:前端会在每个写请求体里注入 requestId, +// 服务端按需读取即可,不该因为多一个字段就拒绝整个请求。 +func decodeJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(r.Body) + if err := dec.Decode(dst); err != nil { + return badRequest("请求体不是合法 JSON", err.Error()) + } + return nil +} + +// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。 +func pageQuery(r *http.Request) (page, size int) { + page = intQuery(r, "page", 1) + size = intQuery(r, "size", 50) + if page < 1 { + page = 1 + } + switch { + case size < 1: + size = 50 + case size > 500: + size = 500 + } + return page, size +} + +func intQuery(r *http.Request, key string, def int) int { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil { + return def + } + return n +} + +func int64Query(r *http.Request, key string) (int64, bool) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return 0, false + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0, false + } + return n, true +} + +// pathID 解析路由通配符 {id}。 +func pathID(r *http.Request, name string) (int64, error) { + raw := r.PathValue(name) + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n <= 0 { + return 0, badRequest("路径参数 "+name+" 非法", raw) + } + return n, nil +} + +// pageResult 是分页响应体(见 devdocs/05 第 6 节)。 +type pageResult[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +// mapDomainError 把 domain 引擎的哨兵错误映射为 REST 错误码。 +func mapDomainError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrManualAdjustmentDisabled), + errors.Is(err, domain.ErrAdjustmentReasonRequired), + errors.Is(err, domain.ErrCompareRequired), + errors.Is(err, domain.ErrUnknownBasis), + errors.Is(err, domain.ErrUnknownLedgerOp): + return stateInvalid(err.Error()) + case errors.Is(err, domain.ErrProfileNotFound): + return badRequest("计分通道引用的积分模板不存在", err.Error()) + case errors.Is(err, domain.ErrLedgerAlreadyVoided): + return stateInvalid("该积分流水已作废,不能重复作废") + case errors.Is(err, domain.ErrLedgerNotFound): + return notFound("积分流水不存在") + default: + return internal(err) + } +} diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go deleted file mode 100644 index edd9b34..0000000 --- a/server/internal/httpapi/router.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package httpapi 提供 HTTP 接口与静态资源托管。 -package httpapi - -import ( - "encoding/json" - "io/fs" - "log/slog" - "net/http" - "strings" - "time" - - "git.yeij.top/AskaEth/Scoreboard/server/internal/webui" -) - -// Options 是构建路由所需的依赖。 -type Options struct { - Logger *slog.Logger - Version string -} - -// New 组装并返回服务端的 HTTP 处理器。 -func New(opts Options) http.Handler { - logger := opts.Logger - if logger == nil { - logger = slog.Default() - } - - mux := http.NewServeMux() - - mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "version": opts.Version, - }) - }) - - assets, err := webui.FS() - if err != nil { - logger.Error("前端资源不可用", "err", err) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "前端资源不可用", http.StatusServiceUnavailable) - }) - } else { - mux.Handle("/", staticHandler(assets)) - } - - return withRequestLog(mux, logger) -} - -// staticHandler 把 URL 映射到多页面前端产物。 -// -// / → index.html -// /display → display.html -// /admin → admin.html -// /其他 → 按文件路径查找 -func staticHandler(assets fs.FS) http.Handler { - files := http.FileServerFS(assets) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") - - switch p { - case "", "index.html": - http.ServeFileFS(w, r, assets, "index.html") - case "display", "display/": - http.ServeFileFS(w, r, assets, "display.html") - case "admin", "admin/": - http.ServeFileFS(w, r, assets, "admin.html") - default: - if _, err := fs.Stat(assets, p); err != nil { - http.NotFound(w, r) - return - } - files.ServeHTTP(w, r) - } - }) -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -type statusRecorder struct { - http.ResponseWriter - status int -} - -func (r *statusRecorder) WriteHeader(code int) { - r.status = code - r.ResponseWriter.WriteHeader(code) -} - -func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} - next.ServeHTTP(rec, r) - logger.Debug("http", - "method", r.Method, - "path", r.URL.Path, - "status", rec.status, - "dur", time.Since(start), - ) - }) -} diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go new file mode 100644 index 0000000..d097b83 --- /dev/null +++ b/server/internal/httpapi/rules_handlers.go @@ -0,0 +1,109 @@ +package httpapi + +import ( + "context" + "io" + "net/http" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" +) + +// rulesRoutes 挂载规则包的导出与导入(导入支持 ?preview=1 只预览差异)。 +func (s *server) rulesRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules)) + mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules)) +} + +func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) { + pack, err := rules.Export(s.ev, rules.Meta{}) + if err != nil { + return nil, internal(err) + } + body, err := rules.Marshal(pack) + if err != nil { + return nil, internal(err) + } + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="rules.yaml"`) + if _, err := w.Write(body); err != nil { + s.logger.Error("规则包写出失败", "err", err) + } + return nil, errHandled +} + +func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + return nil, badRequest("读取请求体失败", nil) + } + if len(body) == 0 { + return nil, badRequest("请求体为空", nil) + } + + pack, err := rules.Parse(body) + if err != nil { + return nil, badRequest(err.Error(), nil) + } + cur, err := rules.Load(s.ev) + if err != nil { + return nil, internal(err) + } + cs, err := rules.Diff(cur, pack) + if err != nil { + return nil, badRequest(err.Error(), nil) + } + + // 先预览再落地:?preview=1 只返回差异。 + if r.URL.Query().Get("preview") == "1" { + return cs, nil + } + + res, err := rules.Apply(s.ev, cs) + if err != nil { + return nil, internal(err) + } + // 分值一变,历史成绩的积分必须全量重算。 + if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil { + return nil, internal(err) + } + return res, nil +} + +// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。 +func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error { + rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`) + if err != nil { + return err + } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return err + } + ids = append(ids, id) + } + if err := rows.Close(); err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + + for _, eventID := range ids { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go new file mode 100644 index 0000000..a4fe4cc --- /dev/null +++ b/server/internal/httpapi/scoring.go @@ -0,0 +1,642 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。 +type queryer interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// ---------- 加载:DB 行 → domain 输入 ---------- + +func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, error) { + var ( + e domain.Event + scheduledAt sql.NullString + venue, remark sql.NullString + ) + err := q.QueryRowContext(ctx, + `SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark + FROM events WHERE id = ?`, eventID). + Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare, + &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark) + if errors.Is(err, sql.ErrNoRows) { + return domain.Event{}, notFound("小项不存在") + } + if err != nil { + return domain.Event{}, internal(err) + } + e.ScheduledAt = scheduledAt.String + e.Venue = venue.String + e.Remark = remark.String + return e, nil +} + +func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.EventCategory, error) { + rows, err := q.QueryContext(ctx, + `SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order + FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.EventCategory, 0) + for rows.Next() { + var ( + c domain.EventCategory + gender sql.NullString + ageMin, ageMax sql.NullInt64 + ) + if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil { + return nil, internal(err) + } + c.Gender = gender.String + if ageMin.Valid { + v := int(ageMin.Int64) + c.AgeMin = &v + } + if ageMax.Valid { + v := int(ageMax.Int64) + c.AgeMax = &v + } + out = append(out, c) + } + return out, rows.Err() +} + +func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) { + rows, err := q.QueryContext(ctx, + `SELECT id, event_id, scope, basis, profile_id, sort_order + FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.ScoreChannel, 0) + for rows.Next() { + var c domain.ScoreChannel + if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil { + return nil, internal(err) + } + out = append(out, c) + } + return out, rows.Err() +} + +func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) { + out := make(map[int64]domain.ScoringProfile, len(ids)) + for id := range ids { + var p domain.ScoringProfile + err := q.QueryRowContext(ctx, + `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero + FROM scoring_profiles WHERE id = ?`, id). + Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero) + if errors.Is(err, sql.ErrNoRows) { + return nil, badRequest("积分模板不存在", id) + } + if err != nil { + return nil, internal(err) + } + rows, err := q.QueryContext(ctx, + `SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id) + if err != nil { + return nil, internal(err) + } + rules := make([]domain.ScoringRule, 0) + for rows.Next() { + var r domain.ScoringRule + if err := rows.Scan(&r.Rank, &r.Points); err != nil { + _ = rows.Close() + return nil, internal(err) + } + rules = append(rules, r) + } + err = rows.Err() + _ = rows.Close() + if err != nil { + return nil, internal(err) + } + p.Rules = rules + out[id] = p + } + return out, nil +} + +func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) { + query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, + e.status, COALESCE(a.name, '') + FROM entries e + LEFT JOIN athletes a ON a.id = e.athlete_id + WHERE e.event_id = ?` + args := []any{eventID} + if categoryID > 0 { + query += ` AND e.category_id = ?` + args = append(args, categoryID) + } + query += ` ORDER BY e.id` + + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.Entry, 0) + for rows.Next() { + var ( + e domain.Entry + athleteID sql.NullInt64 + memberNames sql.NullString + ) + if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil { + return nil, internal(err) + } + if athleteID.Valid { + e.AthleteID = athleteID.Int64 + } + e.MemberNames = parseNames(memberNames) + out = append(out, e) + } + return out, rows.Err() +} + +func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) { + query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, + r.is_tie, r.points_override, r.status, r.state, r.note + FROM results r + JOIN entries e ON e.id = r.entry_id + WHERE r.event_id = ?` + args := []any{eventID} + if categoryID > 0 { + query += ` AND e.category_id = ?` + args = append(args, categoryID) + } + query += ` ORDER BY r.id` + + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.Result, 0) + for rows.Next() { + var ( + r domain.Result + rank sql.NullInt64 + text, unit, note sql.NullString + value, override sql.NullFloat64 + ) + if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, + &r.IsTie, &override, &r.Status, &r.State, ¬e); err != nil { + return nil, internal(err) + } + if rank.Valid { + r.Rank = int(rank.Int64) + } + r.ResultText = text.String + if value.Valid { + v := value.Float64 + r.ResultValue = &v + } + r.Unit = unit.String + if override.Valid { + v := override.Float64 + r.PointsOverride = &v + } + r.Note = note.String + out = append(out, r) + } + return out, rows.Err() +} + +func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) { + cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax} + values, err := loadSettingsMap(ctx, q) + if err != nil { + return cfg, err + } + cfg.Enabled = values["record_bonus.enabled"] == "true" + if values["record_bonus.mode"] == string(domain.RecordBonusSum) { + cfg.Mode = domain.RecordBonusSum + } + return cfg, nil +} + +func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) { + rows, err := q.QueryContext(ctx, + `SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state + FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]domain.RecordBreak, 0) + for rows.Next() { + var ( + b domain.RecordBreak + value sql.NullFloat64 + text sql.NullString + ) + if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil { + return nil, internal(err) + } + b.ResultValue = value.Float64 + b.ResultText = text.String + out = append(out, b) + } + return out, rows.Err() +} + +// ---------- 计分 ---------- + +// computeEventScore 计算某小项(或某组别)的流水与名次明细。 +func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) { + ev, err := loadEventQ(ctx, q, eventID) + if err != nil { + return domain.EventScore{}, err + } + cats, err := loadCategoriesQ(ctx, q, eventID) + if err != nil { + return domain.EventScore{}, err + } + channels, err := loadChannelsQ(ctx, q, eventID) + if err != nil { + return domain.EventScore{}, err + } + profileIDs := make(map[int64]bool) + for _, ch := range channels { + profileIDs[ch.ProfileID] = true + } + profiles, err := loadProfilesQ(ctx, q, profileIDs) + if err != nil { + return domain.EventScore{}, err + } + entries, err := loadEntriesQ(ctx, q, eventID, categoryID) + if err != nil { + return domain.EventScore{}, err + } + results, err := loadResultsQ(ctx, q, eventID, categoryID) + if err != nil { + return domain.EventScore{}, err + } + bonusCfg, err := loadRecordBonusQ(ctx, q) + if err != nil { + return domain.EventScore{}, err + } + breaks, err := loadRecordBreaksQ(ctx, q, eventID) + if err != nil { + return domain.EventScore{}, err + } + + var total domain.EventScore + for _, cat := range cats { + if categoryID > 0 && cat.ID != categoryID { + continue + } + score, err := domain.ScoreEvent(domain.EventScoreInput{ + Event: ev, + Category: cat, + Channels: channels, + Profiles: profiles, + Entries: entries, + Results: results, + RecordBonus: &bonusCfg, + RecordBreaks: breaks, + }) + if err != nil { + return domain.EventScore{}, mapDomainError(err) + } + total.Ledger = append(total.Ledger, score.Ledger...) + total.Ranked = append(total.Ranked, score.Ranked...) + } + return total, nil +} + +// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。 +func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { + scope := `source_type = 'result' AND event_id = ?` + args := []any{eventID} + if categoryID > 0 { + scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` + args = append(args, categoryID) + } + if _, err := tx.ExecContext(ctx, + `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? + WHERE voided = 0 AND `+scope, + append([]any{nullID(actorID), reason}, args...)...); err != nil { + return domain.EventScore{}, internal(err) + } + + score, err := computeEventScore(ctx, tx, eventID, categoryID) + if err != nil { + return domain.EventScore{}, err + } + for _, l := range score.Ledger { + if _, err := tx.ExecContext(ctx, + `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID), + l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil { + return domain.EventScore{}, internal(err) + } + } + return score, nil +} + +// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。 +// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。 +func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error { + best := make(map[int64]domain.RankedResult) + for _, rr := range ranked { + if rr.ResultID == 0 || rr.Rank <= 0 { + continue + } + cur, ok := best[rr.ResultID] + if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) { + best[rr.ResultID] = rr + } + } + for id, rr := range best { + if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = ? WHERE id = ?`, rr.Rank, id); err != nil { + return internal(err) + } + } + return nil +} + +// ---------- 发布 / 撤销 / 作废 ---------- + +func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { + if in.EventID == 0 { + return nil, badRequest("eventId 必填", nil) + } + catID := int64(0) + if in.CategoryID != nil { + catID = *in.CategoryID + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + + setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'), + updated_at = datetime('now') + WHERE event_id = ?` + args := []any{nullID(userID), in.EventID} + if catID > 0 { + setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` + args = append(args, catID) + } + if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil { + return nil, internal(err) + } + + score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算") + if err != nil { + return nil, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return nil, err + } + if catID == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil { + return nil, internal(err) + } + } + + after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil { + return nil, err + } + if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { + return nil, err + } + if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + + s.audit(ctx, "result.publish", "event", in.EventID, before, after) + return s.resultsByEvent(ctx, in.EventID) +} + +func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { + if in.EventID == 0 { + return nil, badRequest("eventId 必填", nil) + } + catID := int64(0) + if in.CategoryID != nil { + catID = *in.CategoryID + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now') + WHERE event_id = ? AND state = 'published'` + args := []any{in.EventID} + if catID > 0 { + query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` + args = append(args, catID) + } + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return nil, internal(err) + } + if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil { + return nil, err + } + if catID == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil { + return nil, internal(err) + } + } + + after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil { + return nil, err + } + if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { + return nil, err + } + if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + + s.audit(ctx, "result.unpublish", "event", in.EventID, before, after) + return s.resultsByEvent(ctx, in.EventID) +} + +func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) { + if reason == "" { + return resultDTO{}, badRequest("reason 必填", nil) + } + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return resultDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + var ( + before resultDTO + eventID int64 + catID int64 + ) + row := tx.QueryRowContext(ctx, + `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie, + r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note, + e.category_id + FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID) + var ( + rank, recordedBy, verifiedBy sql.NullInt64 + text, unit, recordedAt, verifiedAt, note sql.NullString + value, override sql.NullFloat64 + ) + if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie, + &override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return resultDTO{}, notFound("成绩不存在") + } + return resultDTO{}, internal(err) + } + before.EventID = eventID + before.Rank = intPtr(rank) + before.ResultText = text.String + before.ResultValue = f64Ptr(value) + before.Unit = unit.String + before.PointsOverride = f64Ptr(override) + before.RecordedBy = i64Ptr(recordedBy) + before.RecordedAt = strPtr(recordedAt) + before.VerifiedBy = i64Ptr(verifiedBy) + before.VerifiedAt = strPtr(verifiedAt) + before.Note = strPtr(note) + + if _, err := tx.ExecContext(ctx, + `UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`, + reason, resultID); err != nil { + return resultDTO{}, internal(err) + } + if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil { + return resultDTO{}, err + } + + after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID)) + if err != nil { + return resultDTO{}, internal(err) + } + if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil { + return resultDTO{}, err + } + if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil { + return resultDTO{}, err + } + if err := tx.Commit(); err != nil { + return resultDTO{}, internal(err) + } + + s.audit(ctx, "result.void", "result", resultID, before, after) + return s.getResult(ctx, resultID) +} + +// ---------- 变更日志与版本 ---------- + +func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error { + snapshot, err := json.Marshal(results) + if err != nil { + return internal(err) + } + _, err = tx.ExecContext(ctx, + `INSERT INTO result_revisions (event_id, action, snapshot_json, ledger_ids, actor_id, message) + VALUES (?, ?, ?, '[]', ?, ?)`, + eventID, action, string(snapshot), nullID(actorID), message) + if err != nil { + return internal(err) + } + return nil +} + +func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error { + for _, item := range items { + payload, err := json.Marshal(item) + if err != nil { + return internal(err) + } + if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil { + return err + } + } + return nil +} + +// bumpVersion 写一条 change_log;id 即实时协议的 version。 +func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error { + raw := []byte("{}") + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return internal(err) + } + raw = b + } + var eid any + if entityID != 0 { + eid = entityID + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id) + VALUES (?, ?, ?, ?, ?)`, + entityType, eid, action, string(raw), nullID(actorID)); err != nil { + return internal(err) + } + return nil +} + +// audit 登记审计(由 middleware.Auditor 在请求成功后落库)。 +func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) { + middleware.Record(ctx, middleware.AuditEntry{ + Action: action, + EntityType: entityType, + EntityID: entityID, + Before: before, + After: after, + }) +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go new file mode 100644 index 0000000..46a196b --- /dev/null +++ b/server/internal/httpapi/server.go @@ -0,0 +1,251 @@ +// Package httpapi 提供 REST 接口、静态资源托管与统一响应结构。 +package httpapi + +import ( + "database/sql" + "io/fs" + "log/slog" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" + "git.yeij.top/AskaEth/Scoreboard/server/internal/webui" +) + +// DisplaySender 把大屏控制命令推给实时层;未接入时命令只记审计、不影响接口可用性。 +type DisplaySender interface { + SendDisplayCommand(action string, payload any) error +} + +// Options 是构建路由所需的依赖。 +type Options struct { + Logger *slog.Logger + Version string + // System 是系统库连接(users/sessions)。 + System *sql.DB + // Event 是赛事库连接(teams/events/results/ledger/audit…)。 + Event *sql.DB + // Auth 可选;为空时用 System 自动构建。 + Auth *auth.Service + // DataDir 用于备份目录 data/backups。 + DataDir string + // Display 可选,实时层接入后传入。 + Display DisplaySender + // WS 可选,实时层的 WebSocket 处理器,挂到 /ws。 + WS http.Handler + // WebFS 可选,默认使用内嵌前端资源。 + WebFS fs.FS +} + +type server struct { + logger *slog.Logger + version string + sys *sql.DB + ev *sql.DB + auth *auth.Service + dataDir string + display DisplaySender + idem *idempotencyStore +} + +// New 组装并返回服务端的 HTTP 处理器。 +func New(opts Options) http.Handler { + logger := opts.Logger + if logger == nil { + logger = slog.Default() + } + svc := opts.Auth + if svc == nil { + svc = auth.New(opts.System, auth.Options{Logger: logger}) + } + + s := &server{ + logger: logger, + version: opts.Version, + sys: opts.System, + ev: opts.Event, + auth: svc, + dataDir: opts.DataDir, + display: opts.Display, + idem: newIdempotencyStore(10 * time.Minute), + } + + mux := http.NewServeMux() + s.routes(mux) + s.exportRoutes(mux) + s.rulesRoutes(mux) + if opts.WS != nil { + mux.Handle("/ws", opts.WS) + } + + assets := opts.WebFS + if assets == nil { + var err error + assets, err = webui.FS() + if err != nil { + logger.Error("前端资源不可用", "err", err) + } + } + if assets != nil { + mux.Handle("/", staticHandler(assets)) + } + + var h http.Handler = mux + h = middleware.NewAuditor(opts.Event, logger).Middleware(h) + h = middleware.RateLimitWrite(svc)(h) + h = middleware.CSRF(h) + h = middleware.Authenticate(svc)(h) + h = middleware.Log(logger)(h) + h = middleware.Recover(logger)(h) + h = middleware.RequestID(h) + return h +} + +func middlewareUserID(r *http.Request) int64 { + u := middleware.User(r.Context()) + if u.Role == auth.RoleGuest { + return 0 + } + return u.ID +} + +// public 注册公开接口(guest 可读)。 +func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) } + +// read 注册「guest 也不可读」的接口,权限不足一律 403。 +func (s *server) read(role auth.Role, fn handler) http.HandlerFunc { + return middleware.RequireRole(role)(s.handle(fn)).ServeHTTP +} + +// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。 +func (s *server) write(role auth.Role, fn handler) http.HandlerFunc { + return middleware.RequireWrite(role)(s.cached(s.handle(fn))).ServeHTTP +} + +// writeVoid 注册无返回体的写接口。 +func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { + return middleware.RequireWrite(role)(s.cached(s.handleVoid(fn))).ServeHTTP +} + +func (s *server) routes(mux *http.ServeMux) { + // 健康检查不走 {ok,data} 包装(05 第 7 节)。 + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version}) + }) + + // 认证 + mux.HandleFunc("POST /api/auth/login", s.handle(s.login)) + mux.HandleFunc("POST /api/auth/logout", s.handle(s.logout)) + mux.HandleFunc("GET /api/auth/me", s.handle(s.me)) + + // 系统配置(guest 一律 403) + mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler)) + mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler)) + + // 基础配置 + mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler)) + mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler)) + mux.HandleFunc("GET /api/teams/{id}", s.public(s.getTeamHandler)) + mux.HandleFunc("PUT /api/teams/{id}", s.write(auth.RoleRecorder, s.updateTeamHandler)) + mux.HandleFunc("DELETE /api/teams/{id}", s.writeVoid(auth.RoleRecorder, s.deleteTeamHandler)) + + mux.HandleFunc("GET /api/sports", s.public(s.listSportsHandler)) + mux.HandleFunc("POST /api/sports", s.write(auth.RoleRecorder, s.createSportHandler)) + mux.HandleFunc("GET /api/sports/{id}", s.public(s.getSportHandler)) + mux.HandleFunc("PUT /api/sports/{id}", s.write(auth.RoleRecorder, s.updateSportHandler)) + mux.HandleFunc("DELETE /api/sports/{id}", s.writeVoid(auth.RoleRecorder, s.deleteSportHandler)) + + mux.HandleFunc("GET /api/events", s.public(s.listEventsHandler)) + mux.HandleFunc("POST /api/events", s.write(auth.RoleRecorder, s.createEventHandler)) + mux.HandleFunc("GET /api/events/{id}", s.public(s.getEventHandler)) + mux.HandleFunc("PUT /api/events/{id}", s.write(auth.RoleRecorder, s.updateEventHandler)) + mux.HandleFunc("DELETE /api/events/{id}", s.writeVoid(auth.RoleRecorder, s.deleteEventHandler)) + mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler)) + mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler)) + mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler)) + mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler)) + mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler)) + + mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler)) + mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler)) + mux.HandleFunc("GET /api/scoring-profiles/{id}", s.public(s.getProfileHandler)) + mux.HandleFunc("PUT /api/scoring-profiles/{id}", s.write(auth.RoleRecorder, s.updateProfileHandler)) + + mux.HandleFunc("GET /api/athletes", s.public(s.listAthletesHandler)) + mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler)) + mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler)) + mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler)) + mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler)) + + mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler)) + + // 成绩 + mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) + mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler)) + mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler)) + mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler)) + mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler)) + + // 积分与排名 + mux.HandleFunc("GET /api/standings", s.public(s.standingsHandler)) + mux.HandleFunc("GET /api/standings/athletes", s.public(s.athleteStandingsHandler)) + mux.HandleFunc("GET /api/standings/teams/{id}/breakdown", s.public(s.breakdownHandler)) + mux.HandleFunc("GET /api/adjustments", s.public(s.listAdjustmentsHandler)) + mux.HandleFunc("POST /api/adjustments", s.write(auth.RoleRecorder, s.createAdjustmentHandler)) + mux.HandleFunc("POST /api/adjustments/{id}/void", s.write(auth.RoleRecorder, s.voidAdjustmentHandler)) + + // 纪录 + mux.HandleFunc("GET /api/records", s.public(s.listRecordsHandler)) + mux.HandleFunc("POST /api/record-breaks", s.write(auth.RoleRecorder, s.createRecordBreakHandler)) + mux.HandleFunc("POST /api/record-breaks/{id}/approve", s.write(auth.RoleRecorder, s.approveRecordBreakHandler)) + + // 大屏控制与运维 + mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler)) + 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("/api/export/", s.notWired("导出")) + mux.HandleFunc("/api/rules/", s.notWired("规则导入导出")) + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, s.logger, notFound("接口不存在")) + }) +} + +// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。 +func (s *server) notWired(name string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入")) + } +} + +// staticHandler 把 URL 映射到多页面前端产物。 +// +// / → index.html +// /display → display.html +// /admin → admin.html +// /其他 → 按文件路径查找 +func staticHandler(assets fs.FS) http.Handler { + files := http.FileServerFS(assets) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + + switch p { + case "", "index.html": + http.ServeFileFS(w, r, assets, "index.html") + case "display", "display/": + http.ServeFileFS(w, r, assets, "display.html") + case "admin", "admin/": + http.ServeFileFS(w, r, assets, "admin.html") + default: + if _, err := fs.Stat(assets, p); err != nil { + http.NotFound(w, r) + return + } + files.ServeHTTP(w, r) + } + }) +} diff --git a/server/internal/httpapi/testenv_test.go b/server/internal/httpapi/testenv_test.go new file mode 100644 index 0000000..72593c3 --- /dev/null +++ b/server/internal/httpapi/testenv_test.go @@ -0,0 +1,195 @@ +package httpapi_test + +import ( + "bytes" + "database/sql" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。 +type testEnv struct { + t *testing.T + server *httptest.Server + sys *sql.DB + ev *sql.DB + auth *auth.Service +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + dir := t.TempDir() + + st, err := store.Open(filepath.Join(dir, "data")) + if err != nil { + t.Fatalf("打开系统库: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(); err != nil { + t.Fatalf("迁移系统库: %v", err) + } + + evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db") + if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil { + t.Fatalf("创建赛事目录: %v", err) + } + ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + ev.SetMaxOpenConns(1) + t.Cleanup(func() { _ = ev.Close() }) + if err := store.MigrateEvent(ev); err != nil { + t.Fatalf("迁移赛事库: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost}) + handler := httpapi.New(httpapi.Options{ + Logger: logger, + Version: "test", + System: st.DB(), + Event: ev, + Auth: svc, + DataDir: filepath.Join(dir, "data"), + }) + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc} +} + +func (e *testEnv) createUser(username, password, role string) int64 { + e.t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + e.t.Fatalf("生成密码哈希: %v", err) + } + res, err := e.sys.Exec( + `INSERT INTO users (username, password_hash, display_name, role, active, must_change_password) + VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role) + if err != nil { + e.t.Fatalf("创建用户 %s: %v", username, err) + } + id, _ := res.LastInsertId() + return id +} + +func (e *testEnv) client() (*http.Client, *cookiejar.Jar) { + e.t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + e.t.Fatalf("创建 cookie jar: %v", err) + } + return &http.Client{Jar: jar}, jar +} + +type envelope struct { + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + Error *apiErrorBody `json:"error"` +} + +type apiErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Detail json.RawMessage `json:"detail"` +} + +// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。 +func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) { + return e.doH(client, method, path, body, csrf, nil) +} + +// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。 +func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) { + e.t.Helper() + + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + e.t.Fatalf("序列化请求体: %v", err) + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, e.server.URL+path, reader) + if err != nil { + e.t.Fatalf("构造请求: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if csrf { + token := e.csrfToken(client) + if token == "" { + e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path) + } + req.Header.Set("X-CSRF-Token", token) + } + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + e.t.Fatalf("请求 %s %s: %v", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + + var env envelope + raw, _ := io.ReadAll(resp.Body) + if len(bytes.TrimSpace(raw)) > 0 { + if err := json.Unmarshal(raw, &env); err != nil { + e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw) + } + } + return resp.StatusCode, env +} + +func (e *testEnv) csrfToken(client *http.Client) string { + u, err := url.Parse(e.server.URL) + if err != nil { + e.t.Fatalf("解析测试服务地址: %v", err) + } + for _, c := range client.Jar.Cookies(u) { + if c.Name == auth.CookieCSRF { + return c.Value + } + } + return "" +} + +func (e *testEnv) login(client *http.Client, username, password string) envelope { + e.t.Helper() + status, env := e.do(client, http.MethodPost, "/api/auth/login", + map[string]any{"username": username, "password": password}, false) + if status != http.StatusOK || !env.OK { + e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error) + } + return env +} + +func decodeData[T any](t *testing.T, env envelope) T { + t.Helper() + var out T + if err := json.Unmarshal(env.Data, &out); err != nil { + t.Fatalf("解析 data: %v (%s)", err, env.Data) + } + return out +} diff --git a/server/internal/middleware/audit.go b/server/internal/middleware/audit.go new file mode 100644 index 0000000..81f85cc --- /dev/null +++ b/server/internal/middleware/audit.go @@ -0,0 +1,104 @@ +package middleware + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// AuditEntry 是一条待落库的审计记录;Before/After 为任意可 JSON 序列化的快照。 +type AuditEntry struct { + // Action 用点号分层,关键动作单独成段,如 result.publish / result.void / user.create。 + Action string + EntityType string + EntityID int64 + Before any + After any +} + +type auditCollector struct { + entries []AuditEntry +} + +// Record 由 handler 调用,登记一条审计;仅在请求成功(2xx)时落库。 +func Record(ctx context.Context, e AuditEntry) { + if c, ok := ctx.Value(ctxKeyAudit).(*auditCollector); ok { + c.entries = append(c.entries, e) + } +} + +// Auditor 把成功写操作的审计记录写入赛事库 audit_logs(只追加、不修改)。 +type Auditor struct { + db *sql.DB + log *slog.Logger +} + +// NewAuditor 创建审计中间件;db 为赛事库(event.db)。 +func NewAuditor(db *sql.DB, logger *slog.Logger) *Auditor { + if logger == nil { + logger = slog.Default() + } + return &Auditor{db: db, log: logger} +} + +// Middleware 收集 handler 登记的审计并在成功后落库。 +func (a *Auditor) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + c := &auditCollector{} + ctx := context.WithValue(r.Context(), ctxKeyAudit, c) + next.ServeHTTP(sw, r.WithContext(ctx)) + + if sw.status >= http.StatusBadRequest || len(c.entries) == 0 { + return + } + a.flush(ctx, r, c.entries) + }) +} + +func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEntry) { + if a.db == nil { + return + } + u := User(ctx) + requestID := GetRequestID(ctx) + ip := ClientIP(r) + + for _, e := range entries { + var entityID any + if e.EntityID != 0 { + entityID = e.EntityID + } + _, err := a.db.ExecContext(ctx, + `INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + nullableID(u), e.Action, e.EntityType, entityID, + marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID) + if err != nil { + // 审计失败不能吞掉业务结果,但必须留下痕迹。 + a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID) + } + } +} + +func nullableID(u *auth.User) any { + if u == nil || u.Role == auth.RoleGuest || u.ID == 0 { + return nil + } + return u.ID +} + +func marshalOrNil(v any) any { + if v == nil { + return nil + } + raw, err := json.Marshal(v) + if err != nil { + return nil + } + return string(raw) +} diff --git a/server/internal/middleware/context.go b/server/internal/middleware/context.go new file mode 100644 index 0000000..abac3d9 --- /dev/null +++ b/server/internal/middleware/context.go @@ -0,0 +1,57 @@ +// Package middleware 提供请求日志、错误恢复、认证/授权与审计等 HTTP 中间件。 +package middleware + +import ( + "context" + "net" + "net/http" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +type ctxKey int + +const ( + ctxKeyUser ctxKey = iota + ctxKeyRequestID + ctxKeyAudit +) + +// User 返回当前请求的身份;未经 Authenticate 中间件时是 guest。 +func User(ctx context.Context) *auth.User { + if u, ok := ctx.Value(ctxKeyUser).(*auth.User); ok { + return u + } + return &auth.User{Role: auth.RoleGuest} +} + +// WithUser 把身份写入 context,供测试或非标准链路使用。 +func WithUser(ctx context.Context, u *auth.User) context.Context { + return context.WithValue(ctx, ctxKeyUser, u) +} + +// GetRequestID 返回本次请求的 request_id。 +func GetRequestID(ctx context.Context) string { + if v, ok := ctx.Value(ctxKeyRequestID).(string); ok { + return v + } + return "" +} + +// ClientIP 从代理头或连接地址解析客户端 IP。 +func ClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" { + return first + } + } + if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" { + return rip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go new file mode 100644 index 0000000..82f3fdd --- /dev/null +++ b/server/internal/middleware/middleware.go @@ -0,0 +1,232 @@ +package middleware + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "log/slog" + "net" + "net/http" + "runtime/debug" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// HeaderRequestID 是客户端可复用的幂等/追踪请求头。 +const HeaderRequestID = "X-Request-Id" + +// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。 +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(HeaderRequestID) + if id == "" { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err == nil { + id = hex.EncodeToString(buf) + } else { + id = time.Now().UTC().Format("20060102150405.000000") + } + } + w.Header().Set(HeaderRequestID, id) + next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id))) + }) +} + +func contextWithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, ctxKeyRequestID, id) +} + +// Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。 +func Recover(logger *slog.Logger) func(http.Handler) http.Handler { + if logger == nil { + logger = slog.Default() + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + logger.Error("请求处理 panic", + "err", rec, + "path", r.URL.Path, + "requestId", GetRequestID(r.Context()), + "stack", string(debug.Stack()), + ) + writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil) + } + }() + next.ServeHTTP(w, r) + }) + } +} + +// Log 记录访问日志(方法、路径、状态、耗时、request_id)。 +func Log(logger *slog.Logger) func(http.Handler) http.Handler { + if logger == nil { + logger = slog.Default() + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + logger.Debug("http", + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "dur", time.Since(start), + "requestId", GetRequestID(r.Context()), + "ip", ClientIP(r), + ) + }) + } +} + +// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。 +func Authenticate(svc *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := &auth.User{Role: auth.RoleGuest} + if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" { + if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil { + u = found + } + } + next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u))) + }) + } +} + +// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。 +// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。 +func RequireRole(need auth.Role) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !User(r.Context()).Role.Allows(need) { + writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()}) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。 +func RequireWrite(need auth.Role) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := User(r.Context()) + if u.Role == auth.RoleGuest { + writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil) + return + } + if !u.Role.Allows(need) { + writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()}) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。 +func CSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + if User(r.Context()).Role == auth.RoleGuest { + // 未登录请求由 RequireWrite 处理,无需 CSRF。 + next.ServeHTTP(w, r) + return + } + var cookie string + if c, err := r.Cookie(auth.CookieCSRF); err == nil { + cookie = c.Value + } + if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) { + writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil) + return + } + next.ServeHTTP(w, r) + }) +} + +// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。 +func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + u := User(r.Context()) + if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) { + writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// statusWriter 记录响应状态码,供日志与审计判断是否成功。 +type statusWriter struct { + http.ResponseWriter + status int + written bool +} + +func (w *statusWriter) WriteHeader(code int) { + if !w.written { + w.status = code + w.written = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + if !w.written { + w.written = true + } + return w.ResponseWriter.Write(b) +} + +// Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker, +// 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。 +func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hj, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + return hj.Hijack() +} + +// Flush 透传,保证流式响应不被缓冲。 +func (w *statusWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。 +func writeError(w http.ResponseWriter, status int, code, message string, detail any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + body := map[string]any{ + "ok": false, + "error": map[string]any{ + "code": code, + "message": message, + }, + } + if detail != nil { + body["error"].(map[string]any)["detail"] = detail + } + _ = json.NewEncoder(w).Encode(body) +} diff --git a/server/internal/realtime/changes.go b/server/internal/realtime/changes.go new file mode 100644 index 0000000..f0d0a69 --- /dev/null +++ b/server/internal/realtime/changes.go @@ -0,0 +1,107 @@ +package realtime + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。 +func (h *Hub) currentVersion(ctx context.Context) (int64, error) { + var v sql.NullInt64 + if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil { + return 0, fmt.Errorf("读取 change_log 版本号: %w", err) + } + if !v.Valid { + return 0, nil + } + return v.Int64, nil +} + +// currentVersionCached 在数据库不可用时退回内存里的版本,保证心跳与广播不会因一次查询失败而中断。 +func (h *Hub) currentVersionCached(ctx context.Context) int64 { + if h.db == nil { + return h.Version() + } + v, err := h.currentVersion(ctx) + if err != nil { + h.log.Warn("读取版本号失败,沿用内存版本", "err", err, "version", h.Version()) + return h.Version() + } + h.setVersion(v) + return v +} + +func (h *Hub) countChangesSince(ctx context.Context, from int64) (int, error) { + var n int + err := h.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM change_log WHERE id > ?`, from).Scan(&n) + if err != nil { + return 0, fmt.Errorf("统计增量变更: %w", err) + } + return n, nil +} + +// changesSince 按版本升序取增量;limit <= 0 表示不限量。 +func (h *Hub) changesSince(ctx context.Context, from int64, limit int) ([]Change, error) { + q := `SELECT id, entity_type, entity_id, action, payload_json + FROM change_log WHERE id > ? ORDER BY id` + args := []any{from} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + + rows, err := h.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("读取增量变更: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]Change, 0, 16) + for rows.Next() { + var ( + ch Change + entity string + entityID sql.NullInt64 + action string + payload string + ) + if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil { + return nil, fmt.Errorf("解析变更行: %w", err) + } + ch.Entity = entity + ch.ID = entityID.Int64 + ch.Action = action + ch.Data = payloadJSON(payload) + out = append(out, ch) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("遍历变更行: %w", err) + } + return out, nil +} + +// LoadChanges 供写操作完成后读取「自 sinceVersion 起的变更」,再交给 Broadcast 推送。 +func (h *Hub) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]Change, error) { + if h.db == nil { + return nil, nil + } + return h.changesSince(ctx, sinceVersion, limit) +} + +// payloadJSON 保证 data 永远是合法 JSON:change_log 允许写入任意文本, +// 空串或坏 JSON 一律降级成空对象,避免把坏数据传播给客户端。 +func payloadJSON(payload string) json.RawMessage { + if payload == "" { + return json.RawMessage(`{}`) + } + if !json.Valid([]byte(payload)) { + quoted, err := json.Marshal(payload) + if err != nil { + return json.RawMessage(`{}`) + } + return quoted + } + return json.RawMessage(payload) +} diff --git a/server/internal/realtime/client.go b/server/internal/realtime/client.go new file mode 100644 index 0000000..8f604ec --- /dev/null +++ b/server/internal/realtime/client.go @@ -0,0 +1,162 @@ +package realtime + +import ( + "context" + "encoding/json" + "sync" + "sync/atomic" + "time" + + "github.com/coder/websocket" +) + +// client 是一条 WebSocket 连接。读写各一个 goroutine, +// 写通过带缓冲的 send 通道串行化,通道满即视为慢客户端。 +type client struct { + hub *Hub + conn *websocket.Conn + remoteAddr string + + send chan []byte + done chan struct{} + + closeOnce sync.Once + closeCode websocket.StatusCode + closeReason string + + mu sync.Mutex + greeted bool + role string + userID int64 + instanceID string + + ackVersion atomic.Int64 // 客户端已确认应用到的版本 + lastSent atomic.Int64 // 服务端已发出的最大版本 + slow atomic.Bool +} + +func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client { + return &client{ + hub: h, + conn: conn, + remoteAddr: remoteAddr, + send: make(chan []byte, defaultSendBuffer), + done: make(chan struct{}), + } +} + +// stop 只负责让两条 goroutine 退出;真正的关闭动作由 serve 统一执行, +// 避免在持有广播锁时做可能阻塞的关闭握手。 +func (c *client) stop(code websocket.StatusCode, reason string) { + c.closeOnce.Do(func() { + c.mu.Lock() + c.closeCode = code + c.closeReason = reason + c.mu.Unlock() + close(c.done) + }) +} + +func (c *client) sendEvent(event string, data any) bool { + msg, err := encode(event, data) + if err != nil { + c.hub.log.Error("编码实时消息失败", "event", event, "err", err) + return false + } + return c.sendRaw(msg) +} + +func (c *client) sendRaw(msg []byte) bool { + select { + case <-c.done: + return false + default: + } + select { + case c.send <- msg: + return true + default: + c.hub.markSlow(c) + return false + } +} + +// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。 +func (c *client) readLoop(ctx context.Context) { + defer c.stop(websocket.StatusNormalClosure, "") + + for { + c.mu.Lock() + greeted := c.greeted + c.mu.Unlock() + + readCtx := ctx + var cancel context.CancelFunc + if !greeted { + readCtx, cancel = context.WithTimeout(ctx, c.hub.helloTimeout()) + } + _, data, err := c.conn.Read(readCtx) + if cancel != nil { + cancel() + } + if err != nil { + return + } + c.handleMessage(ctx, data) + } +} + +func (c *client) handleMessage(ctx context.Context, data []byte) { + var env envelope + if err := json.Unmarshal(data, &env); err != nil { + c.hub.log.Debug("忽略无法解析的实时消息", "remote", c.remoteAddr, "err", err) + return + } + + switch env.Event { + case EventHello: + var p helloPayload + if err := json.Unmarshal(env.Data, &p); err != nil { + c.hub.log.Debug("hello 载荷不合法", "remote", c.remoteAddr, "err", err) + return + } + c.hub.handleHello(ctx, c, p) + + case EventAck: + var p ackPayload + if err := json.Unmarshal(env.Data, &p); err != nil { + c.hub.log.Debug("ack 载荷不合法", "remote", c.remoteAddr, "err", err) + return + } + if p.Version > c.ackVersion.Load() { + c.ackVersion.Store(p.Version) + } + c.hub.touchInstance(c, time.Now()) + + case EventResync: + c.hub.sendInitial(ctx, c, nil) + + default: + c.hub.log.Debug("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr) + } +} + +// writeLoop 是唯一的写者,保证同一连接上的帧顺序。 +func (c *client) writeLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case <-c.done: + return + case msg := <-c.send: + writeCtx, cancel := context.WithTimeout(ctx, c.hub.writeTimeout()) + err := c.conn.Write(writeCtx, websocket.MessageText, msg) + cancel() + if err != nil { + c.stop(websocket.StatusInternalError, "写入失败") + return + } + } + } +} diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go new file mode 100644 index 0000000..2acacb6 --- /dev/null +++ b/server/internal/realtime/hub.go @@ -0,0 +1,542 @@ +package realtime + +import ( + "context" + "database/sql" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" +) + +// 默认参数;现场可通过 Set* 调整。 +const ( + defaultHeartbeatInterval = 10 * time.Second + defaultMaxCatchup = 500 + defaultSlowLag = 500 + defaultHelloTimeout = 15 * time.Second + defaultWriteTimeout = 10 * time.Second + defaultSendBuffer = 64 +) + +// Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。 +// +// 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更, +// 断线客户端用 hello.lastVersion 请求增量补齐。 +type Hub struct { + db *sql.DB + log *slog.Logger + + mu sync.RWMutex + rooms map[string]map[*client]struct{} + clients map[*client]struct{} + instances map[string]*instanceState + version int64 + closed bool + + // sendMu 串行化「版本推进 + 下发」,保证同一客户端收到的 change 与版本严格递增, + // 也避免初始快照与并发广播交错导致漏变更。 + sendMu sync.Mutex + + ctx context.Context + cancel context.CancelFunc + hbWG sync.WaitGroup // 心跳循环 + connWG sync.WaitGroup // 连接处理 + + heartbeatInterval time.Duration + heartbeatReset chan struct{} + maxCatchup int + slowLag int64 + helloTO time.Duration + writeTO time.Duration +} + +// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。 +// 调用方在服务关闭时应调用 Hub.Close。 +func NewHub(db *sql.DB, log *slog.Logger) *Hub { + if log == nil { + log = slog.Default() + } + ctx, cancel := context.WithCancel(context.Background()) + h := &Hub{ + db: db, + log: log, + rooms: make(map[string]map[*client]struct{}), + clients: make(map[*client]struct{}), + instances: make(map[string]*instanceState), + ctx: ctx, + cancel: cancel, + heartbeatInterval: defaultHeartbeatInterval, + heartbeatReset: make(chan struct{}, 1), + maxCatchup: defaultMaxCatchup, + slowLag: defaultSlowLag, + helloTO: defaultHelloTimeout, + writeTO: defaultWriteTimeout, + } + if db != nil { + if v, err := h.currentVersion(context.Background()); err == nil { + h.version = v + } else { + log.Warn("读取初始版本号失败", "err", err) + } + } + + h.hbWG.Add(1) + go h.heartbeatLoop() + return h +} + +// Close 停止心跳、断开所有连接并等待处理中的连接退出。 +func (h *Hub) Close() error { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil + } + h.closed = true + h.mu.Unlock() + + h.cancel() + for _, c := range h.roomClients("") { + c.stop(websocket.StatusGoingAway, "服务关闭") + } + h.connWG.Wait() + h.hbWG.Wait() + return nil +} + +// Version 返回内存中记录的当前版本号。 +func (h *Hub) Version() int64 { + h.mu.RLock() + defer h.mu.RUnlock() + return h.version +} + +func (h *Hub) setVersion(v int64) { + if v <= 0 { + return + } + h.mu.Lock() + if v > h.version { + h.version = v + } + h.mu.Unlock() +} + +// SetHeartbeatInterval 调整心跳间隔并立即生效;用于现场调优与测试。 +func (h *Hub) SetHeartbeatInterval(d time.Duration) { + if d <= 0 { + return + } + h.mu.Lock() + h.heartbeatInterval = d + h.mu.Unlock() + + select { + case h.heartbeatReset <- struct{}{}: + default: + } +} + +// SetMaxCatchup 设置增量补发的条数上限;超过就回落全量快照。 +func (h *Hub) SetMaxCatchup(n int) { + if n <= 0 { + return + } + h.mu.Lock() + h.maxCatchup = n + h.mu.Unlock() +} + +// SetSlowLag 设置「慢客户端」的版本落后阈值;<=0 表示不判定。 +func (h *Hub) SetSlowLag(n int64) { + h.mu.Lock() + h.slowLag = n + h.mu.Unlock() +} + +func (h *Hub) heartbeatIntervalValue() time.Duration { + h.mu.RLock() + defer h.mu.RUnlock() + return h.heartbeatInterval +} + +func (h *Hub) maxCatchupValue() int { + h.mu.RLock() + defer h.mu.RUnlock() + return h.maxCatchup +} + +func (h *Hub) slowLagValue() int64 { + h.mu.RLock() + defer h.mu.RUnlock() + return h.slowLag +} + +func (h *Hub) helloTimeout() time.Duration { + h.mu.RLock() + defer h.mu.RUnlock() + return h.helloTO +} + +func (h *Hub) writeTimeout() time.Duration { + h.mu.RLock() + defer h.mu.RUnlock() + return h.writeTO +} + +// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。 +func (h *Hub) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + // 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。 + InsecureSkipVerify: true, + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err) + return + } + + h.mu.Lock() + if h.closed { + h.mu.Unlock() + _ = conn.CloseNow() + return + } + h.connWG.Add(1) + h.mu.Unlock() + defer h.connWG.Done() + + h.serve(conn, r.RemoteAddr) + }) +} + +func (h *Hub) serve(conn *websocket.Conn, remoteAddr string) { + conn.SetReadLimit(1 << 20) + + c := newClient(h, conn, remoteAddr) + h.addClient(c) + + ctx, cancel := context.WithCancel(h.ctx) + defer cancel() + + var loops sync.WaitGroup + loops.Add(2) + go func() { defer loops.Done(); c.readLoop(ctx) }() + go func() { defer loops.Done(); c.writeLoop(ctx) }() + + <-c.done + cancel() + loops.Wait() + h.removeClient(c) + // 客户端会自动重连并走 catchup,这里直接断开,避免关闭握手阻塞。 + _ = conn.CloseNow() +} + +func (h *Hub) addClient(c *client) { + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() +} + +func (h *Hub) removeClient(c *client) { + h.mu.Lock() + delete(h.clients, c) + for room, set := range h.rooms { + delete(set, c) + if len(set) == 0 { + delete(h.rooms, room) + } + } + h.mu.Unlock() + + h.releaseDisplay(c) +} + +// join 把连接加入房间;房间用内存 map 维护,不引入实时框架。 +func (h *Hub) join(c *client, rooms ...string) { + h.mu.Lock() + defer h.mu.Unlock() + for _, room := range rooms { + if room == "" { + continue + } + set := h.rooms[room] + if set == nil { + set = make(map[*client]struct{}) + h.rooms[room] = set + } + set[c] = struct{}{} + } +} + +// roomClients 返回房间成员的快照;room 为空表示全部连接。 +func (h *Hub) roomClients(room string) []*client { + h.mu.RLock() + defer h.mu.RUnlock() + + if room == "" { + out := make([]*client, 0, len(h.clients)) + for c := range h.clients { + out = append(out, c) + } + return out + } + set := h.rooms[room] + out := make([]*client, 0, len(set)) + for c := range set { + out = append(out, c) + } + return out +} + +func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) { + if p.Role != RoleDisplay && p.Role != RoleAdmin { + h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr) + c.stop(websocket.StatusPolicyViolation, "role 非法") + return + } + + c.mu.Lock() + c.role = p.Role + c.userID = p.UserID + c.mu.Unlock() + + if p.Role == RoleAdmin { + rooms := []string{RoomAdmin} + if p.UserID > 0 { + rooms = append(rooms, AdminRoom(p.UserID)) + } + h.join(c, rooms...) + } else { + id := p.InstanceID + if id == "" { + id = p.DisplayKey + } + h.registerDisplay(c, id, p.Page) + h.join(c, RoomDisplay) + } + + h.sendInitial(ctx, c, p.LastVersion) +} + +// sendInitial 决定首包是 snapshot 还是 catchup: +// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。 +func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) { + h.sendMu.Lock() + defer h.sendMu.Unlock() + + c.mu.Lock() + c.greeted = true + c.mu.Unlock() + + cur, err := h.currentVersion(ctx) + if err != nil { + h.log.Error("读取版本号失败,无法下发首包", "err", err) + c.stop(websocket.StatusInternalError, "版本号不可用") + return + } + h.setVersion(cur) + + if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur { + h.sendSnapshotLocked(ctx, c) + return + } + + n, err := h.countChangesSince(ctx, *lastVersion) + if err != nil { + h.log.Warn("统计增量失败,回落快照", "err", err) + h.sendSnapshotLocked(ctx, c) + return + } + if n > h.maxCatchupValue() { + h.sendSnapshotLocked(ctx, c) + return + } + + changes, err := h.changesSince(ctx, *lastVersion, 0) + if err != nil { + h.log.Warn("读取增量失败,回落快照", "err", err) + h.sendSnapshotLocked(ctx, c) + return + } + if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) { + return + } + c.lastSent.Store(cur) + c.ackVersion.Store(cur) +} + +// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进 +// 必须原子完成,否则并发的 change 会插到快照前面。 +func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) { + snap, err := h.Snapshot(ctx) + if err != nil { + h.log.Error("构建快照失败", "err", err) + c.stop(websocket.StatusInternalError, "快照不可用") + return + } + if !c.sendEvent(EventSnapshot, snap) { + return + } + c.lastSent.Store(snap.Version) + c.ackVersion.Store(snap.Version) + h.setVersion(snap.Version) +} + +// Broadcast 把变更推给所有已握手的连接(大屏与管理端)。 +func (h *Hub) Broadcast(changes []Change) { h.fanout("", changes) } + +// BroadcastTo 把变更推给指定房间(如 RoomAdmin、AdminRoom(uid))。 +func (h *Hub) BroadcastTo(room string, changes []Change) { h.fanout(room, changes) } + +// fanout 在 sendMu 下按版本去重后下发:已经发过的版本不再重复投递。 +func (h *Hub) fanout(room string, changes []Change) { + if len(changes) == 0 { + return + } + + h.sendMu.Lock() + defer h.sendMu.Unlock() + + version := int64(0) + for _, ch := range changes { + if ch.Version > version { + version = ch.Version + } + } + if version == 0 { + version = h.currentVersionCached(h.ctx) + } + h.setVersion(version) + + for _, c := range h.roomClients(room) { + c.mu.Lock() + greeted := c.greeted + c.mu.Unlock() + if !greeted { + continue + } + + last := c.lastSent.Load() + filtered := make([]Change, 0, len(changes)) + for _, ch := range changes { + if ch.Version > 0 && ch.Version <= last { + continue + } + filtered = append(filtered, ch) + } + if len(filtered) == 0 { + continue + } + if !c.sendEvent(EventChange, changePayload{Version: version, Changes: filtered}) { + continue + } + c.lastSent.Store(version) + h.checkSlow(c, version) + } +} + +// SendCommand 向所有大屏下发 display:command。 +func (h *Hub) SendCommand(cmd DisplayCommand) { h.SendCommandTo(RoomDisplay, cmd) } + +// SendCommandTo 向指定房间下发 display:command;非法 action 直接丢弃并记录。 +func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) { + if !ValidDisplayAction(cmd.Action) { + h.log.Warn("忽略非法的大屏指令", "action", cmd.Action, "room", room) + return + } + if cmd.Action == CmdGoto && (room == "" || room == RoomDisplay) { + h.setInstancePage(pageFromPayload(cmd.Payload)) + } + + h.sendMu.Lock() + defer h.sendMu.Unlock() + for _, c := range h.roomClients(room) { + c.mu.Lock() + greeted := c.greeted + c.mu.Unlock() + if !greeted { + continue + } + c.sendEvent(EventDisplayCommand, cmd) + } +} + +// checkSlow 用 ack 版本差识别慢客户端;超阈值直接断开,由客户端重连后补齐。 +func (h *Hub) checkSlow(c *client, version int64) { + lag := h.slowLagValue() + if lag <= 0 { + return + } + if version-c.ackVersion.Load() <= lag { + return + } + h.markSlow(c) +} + +func (h *Hub) markSlow(c *client) { + if c.slow.Swap(true) { + return + } + c.mu.Lock() + id := c.instanceID + c.mu.Unlock() + + h.mu.Lock() + if st := h.instances[id]; st != nil { + st.slow = true + } + h.mu.Unlock() + + h.log.Warn("慢客户端:ack 长时间落后,断开后由重连补齐", + "instance", id, "ackVersion", c.ackVersion.Load(), "remote", c.remoteAddr) + c.stop(websocket.StatusPolicyViolation, "ack 落后") +} + +// heartbeatLoop 每 10s 下发一次心跳(version + serverTime)。 +func (h *Hub) heartbeatLoop() { + defer h.hbWG.Done() + + timer := time.NewTimer(h.heartbeatIntervalValue()) + defer timer.Stop() + + for { + select { + case <-h.ctx.Done(): + return + case <-timer.C: + h.beat() + timer.Reset(h.heartbeatIntervalValue()) + case <-h.heartbeatReset: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(h.heartbeatIntervalValue()) + } + } +} + +func (h *Hub) beat() { + version := h.currentVersionCached(h.ctx) + now := time.Now() + data := heartbeatPayload{Version: version, ServerTime: nowISO(now)} + + for _, c := range h.roomClients("") { + c.mu.Lock() + greeted := c.greeted + c.mu.Unlock() + if !greeted { + continue + } + c.sendEvent(EventHeartbeat, data) + h.touchInstance(c, now) + h.checkSlow(c, version) + } +} diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go new file mode 100644 index 0000000..d030452 --- /dev/null +++ b/server/internal/realtime/hub_test.go @@ -0,0 +1,502 @@ +package realtime + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http/httptest" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// ---------- 测试脚手架 ---------- + +type testEnv struct { + hub *Hub + db *sql.DB + srv *httptest.Server +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + db := newTestDB(t) + h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))) + srv := httptest.NewServer(h.Handler()) + + t.Cleanup(func() { + srv.Close() + _ = h.Close() + _ = db.Close() + }) + return &testEnv{hub: h, db: db, srv: srv} +} + +func newTestDB(t *testing.T) *sql.DB { + t.Helper() + + path := filepath.Join(t.TempDir(), "event.db") + db, err := sql.Open("sqlite", "file:"+path+ + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("打开测试库: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("迁移测试库: %v", err) + } + return db +} + +func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + url := "ws" + strings.TrimPrefix(e.srv.URL, "http") + "/ws" + conn, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + cancel() + t.Fatalf("连接 /ws: %v", err) + } + + c := &wsClient{t: t, ctx: ctx, cancel: cancel, conn: conn, msgs: make(chan wsMessage, 256)} + go c.readLoop() + t.Cleanup(c.close) + if hello != nil { + c.send(EventHello, *hello) + } + return c +} + +type wsMessage struct { + event string + data json.RawMessage + err error +} + +type wsClient struct { + t *testing.T + ctx context.Context + cancel context.CancelFunc + conn *websocket.Conn + msgs chan wsMessage +} + +// readLoop 单独读取:测试里等待「没有消息」时不能靠取消 Read 的 context, +// 那会把整条连接关掉,后续用例就没法继续读了。 +func (c *wsClient) readLoop() { + defer close(c.msgs) + for { + _, data, err := c.conn.Read(c.ctx) + if err != nil { + c.msgs <- wsMessage{err: err} + return + } + var env envelope + if err := json.Unmarshal(data, &env); err != nil { + c.msgs <- wsMessage{err: err} + return + } + c.msgs <- wsMessage{event: env.Event, data: env.Data} + } +} + +func (c *wsClient) close() { + _ = c.conn.CloseNow() + c.cancel() +} + +func (c *wsClient) send(event string, data any) { + c.t.Helper() + msg, err := encode(event, data) + if err != nil { + c.t.Fatalf("编码 %s: %v", event, err) + } + ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second) + defer cancel() + if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil { + c.t.Fatalf("发送 %s: %v", event, err) + } +} + +// recvWithin 在 d 内取一条消息;超时返回 ok=false(连接保持可用)。 +func (c *wsClient) recvWithin(d time.Duration) (string, json.RawMessage, bool) { + c.t.Helper() + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case msg, ok := <-c.msgs: + if !ok || msg.err != nil { + c.t.Fatalf("连接已断开: %v", msg.err) + } + return msg.event, msg.data, true + case <-timer.C: + return "", nil, false + } +} + +// recvEvent 等待指定事件,跳过心跳等无关消息。 +func (c *wsClient) recvEvent(want string) json.RawMessage { + c.t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + ev, data, ok := c.recvWithin(time.Until(deadline)) + if !ok { + break + } + if ev == want { + return data + } + } + c.t.Fatalf("等待事件 %s 超时", want) + return nil +} + +func decode[T any](t *testing.T, raw json.RawMessage) T { + t.Helper() + var v T + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("解析载荷 %s: %v", raw, err) + } + return v +} + +func insertChange(t *testing.T, db *sql.DB, entity string, entityID int64, action, payload string) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES (?, ?, ?, ?)`, + entity, entityID, action, payload) + if err != nil { + t.Fatalf("写入 change_log: %v", err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("读取 change_log id: %v", err) + } + return id +} + +func loadChanges(t *testing.T, h *Hub, since int64, limit int) []Change { + t.Helper() + changes, err := h.LoadChanges(context.Background(), since, limit) + if err != nil { + t.Fatalf("读取增量变更: %v", err) + } + return changes +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("等待条件超时: %s", what) +} + +func ptrInt64(v int64) *int64 { return &v } + +// ---------- 客户端状态归约(模拟前端按 version 应用变更) ---------- + +type clientState map[string]json.RawMessage + +func stateKey(ch Change) string { return fmt.Sprintf("%s/%d", ch.Entity, ch.ID) } + +func applyChanges(s clientState, changes []Change) clientState { + for _, ch := range changes { + switch ch.Action { + case ActionVoid: + delete(s, stateKey(ch)) + case ActionReplace: + s[ch.Entity] = ch.Data + default: + s[stateKey(ch)] = ch.Data + } + } + return s +} + +// ---------- 用例 ---------- + +func TestHelloSnapshotThenChange(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + + snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)) + if snap.Version != 0 { + t.Fatalf("空库快照版本应为 0,得到 %d", snap.Version) + } + + v := insertChange(t, env.db, "result", 42, ActionUpsert, `{"rank":1}`) + env.hub.Broadcast(loadChanges(t, env.hub, 0, 0)) + + got := decode[changePayload](t, c.recvEvent(EventChange)) + if got.Version != v { + t.Fatalf("change 版本应为 %d,得到 %d", v, got.Version) + } + if len(got.Changes) != 1 { + t.Fatalf("change 条数应为 1,得到 %d", len(got.Changes)) + } + ch := got.Changes[0] + if ch.Entity != "result" || ch.ID != 42 || ch.Action != ActionUpsert { + t.Fatalf("change 内容不符: %+v", ch) + } + if string(ch.Data) != `{"rank":1}` { + t.Fatalf("change data 不符: %s", ch.Data) + } + if env.hub.Version() != v { + t.Fatalf("Hub 版本应推进到 %d,得到 %d", v, env.hub.Version()) + } +} + +// 断线期间的变更通过 catchup 补齐,且终态与全量重放的终态一致。 +func TestReconnectCatchupTerminalState(t *testing.T) { + env := newTestEnv(t) + v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) + v2 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":10}]`) + + c1 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + snap := decode[Snapshot](t, c1.recvEvent(EventSnapshot)) + if snap.Version != v2 || snap.Version != v1+1 { + t.Fatalf("首次快照版本应为 %d,得到 %d", v2, snap.Version) + } + c1.close() + + v3 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`) + v4 := insertChange(t, env.db, "result", 1, ActionVoid, `{}`) + v5 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":12}]`) + if v4 != v3+1 || v5 != v4+1 { + t.Fatalf("change_log 版本应连续递增: %d %d %d", v3, v4, v5) + } + + c2 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version)}) + cp := decode[catchupPayload](t, c2.recvEvent(EventCatchup)) + + if cp.FromVersion != snap.Version { + t.Fatalf("fromVersion 应为 %d,得到 %d", snap.Version, cp.FromVersion) + } + if cp.Version != v5 { + t.Fatalf("catchup 版本应为 %d,得到 %d", v5, cp.Version) + } + // 版本号不上线路(协议只在消息层带 version),因此按内容核对补发的三条变更。 + wantChanges := []struct { + entity string + id int64 + action string + }{ + {"result", 2, ActionUpsert}, + {"result", 1, ActionVoid}, + {"standings", 0, ActionReplace}, + } + if len(cp.Changes) != len(wantChanges) { + t.Fatalf("catchup 条数应为 %d,得到 %d", len(wantChanges), len(cp.Changes)) + } + for i, want := range wantChanges { + got := cp.Changes[i] + if got.Entity != want.entity || got.ID != want.id || got.Action != want.action { + t.Fatalf("第 %d 条变更应为 %+v,得到 %+v", i, want, got) + } + } + + // 断线前已应用的变更 + catchup 的变更 == 从头全量重放,终态必须一致。 + base := applyChanges(clientState{}, loadChanges(t, env.hub, 0, int(snap.Version))) + got := applyChanges(base, cp.Changes) + want := applyChanges(clientState{}, loadChanges(t, env.hub, 0, 0)) + if !reflect.DeepEqual(got, want) { + t.Fatalf("catchup 终态与全量重放不一致:\n got=%v\nwant=%v", got, want) + } + + // 新连接的整包快照版本必须与 catchup 的终点一致。 + fresh, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if fresh.Version != cp.Version { + t.Fatalf("快照版本 %d 与 catchup 版本 %d 不一致", fresh.Version, cp.Version) + } +} + +// 增量超过阈值时回落全量快照。 +func TestCatchupFallsBackToSnapshotWhenTooOld(t *testing.T) { + env := newTestEnv(t) + env.hub.SetMaxCatchup(2) + first := insertChange(t, env.db, "result", 1, ActionUpsert, `{}`) + for i := int64(2); i <= 5; i++ { + insertChange(t, env.db, "result", i, ActionUpsert, `{}`) + } + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(first - 1)}) + snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)) + if snap.Version != 5 { + t.Fatalf("应回落全量快照且版本为 5,得到 %d", snap.Version) + } +} + +func TestDuplicateDeliveryIgnored(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + c.recvEvent(EventSnapshot) + + v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) + changes := loadChanges(t, env.hub, 0, 0) + env.hub.Broadcast(changes) + + got := decode[changePayload](t, c.recvEvent(EventChange)) + if got.Version != v1 { + t.Fatalf("首投版本应为 %d,得到 %d", v1, got.Version) + } + c.send(EventAck, ackPayload{Version: v1}) + + // 同一批变更再投一次,客户端不应再收到。 + env.hub.Broadcast(changes) + if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok { + t.Fatalf("重复投递未被忽略,收到事件 %s", ev) + } + + // 新变更仍能正常下发。 + v2 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`) + env.hub.Broadcast(loadChanges(t, env.hub, v1, 0)) + got = decode[changePayload](t, c.recvEvent(EventChange)) + if got.Version != v2 { + t.Fatalf("新变更版本应为 %d,得到 %d", v2, got.Version) + } +} + +// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。 +func TestHeartbeatAndResync(t *testing.T) { + env := newTestEnv(t) + env.hub.SetHeartbeatInterval(40 * time.Millisecond) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + c.recvEvent(EventSnapshot) + + // 只写库不广播:心跳版本会领先于客户端,客户端据此发起 resync。 + insertChange(t, env.db, "result", 1, ActionUpsert, `{}`) + insertChange(t, env.db, "result", 2, ActionUpsert, `{}`) + + hb := decode[heartbeatPayload](t, c.recvEvent(EventHeartbeat)) + if hb.Version != 2 { + t.Fatalf("心跳版本应为 2,得到 %d", hb.Version) + } + if _, err := time.Parse(time.RFC3339Nano, hb.ServerTime); err != nil { + t.Fatalf("serverTime 不是 RFC3339: %q (%v)", hb.ServerTime, err) + } + + c.send(EventResync, struct{}{}) + snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)) + if snap.Version != 2 { + t.Fatalf("resync 快照版本应为 2,得到 %d", snap.Version) + } +} + +// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。 +func TestDisplayInstancesRegistry(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"}) + c.recvEvent(EventSnapshot) + + insts := env.hub.Instances() + if len(insts) != 1 { + t.Fatalf("实例数应为 1,得到 %d", len(insts)) + } + if insts[0].ID != "screen-1" || !insts[0].Online || insts[0].Page != "standings" { + t.Fatalf("实例注册内容不符: %+v", insts[0]) + } + if insts[0].LastHeartbeat.IsZero() { + t.Fatalf("最后心跳不应为零值: %+v", insts[0]) + } + + // 重复 hello 不应把实例引用计数加两次,否则断开后仍会显示在线。 + c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"}) + c.recvEvent(EventSnapshot) + if insts := env.hub.Instances(); len(insts) != 1 || !insts[0].Online { + t.Fatalf("重复 hello 后实例应仍只有一个且在线: %+v", insts) + } + + gotoCmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"}) + if err != nil { + t.Fatalf("构造 goto: %v", err) + } + env.hub.SendCommand(gotoCmd) + cmd := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)) + if cmd.Action != CmdGoto { + t.Fatalf("指令 action 应为 goto,得到 %s", cmd.Action) + } + if insts := env.hub.Instances(); insts[0].Page != "medals" { + t.Fatalf("goto 后当前页应为 medals,得到 %s", insts[0].Page) + } + + detail, err := NewDisplayCommand(CmdShowDetail, map[string]any{"eventId": 7}) + if err != nil { + t.Fatalf("构造 show-detail: %v", err) + } + env.hub.SendCommand(detail) + if got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)); got.Action != CmdShowDetail { + t.Fatalf("指令 action 应为 show-detail,得到 %s", got.Action) + } + + c.close() + waitFor(t, "实例离线", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && !insts[0].Online + }) +} + +// 房间隔离:admin: 只发给对应管理端。 +func TestAdminRoomTargeting(t *testing.T) { + env := newTestEnv(t) + display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) + display.recvEvent(EventSnapshot) + admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7}) + admin.recvEvent(EventSnapshot) + + insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) + env.hub.BroadcastTo(AdminRoom(7), loadChanges(t, env.hub, 0, 0)) + + got := decode[changePayload](t, admin.recvEvent(EventChange)) + if got.Version != 1 { + t.Fatalf("定向 change 版本应为 1,得到 %d", got.Version) + } + if ev, _, ok := display.recvWithin(300 * time.Millisecond); ok { + t.Fatalf("大屏不应收到 admin: 房间的消息,收到 %s", ev) + } +} + +// 慢客户端:ack 落后超过阈值即被识别并断开。 +func TestSlowClientDetected(t *testing.T) { + env := newTestEnv(t) + env.hub.SetSlowLag(1) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "slow-1"}) + c.recvEvent(EventSnapshot) + + for i := int64(1); i <= 3; i++ { + insertChange(t, env.db, "result", i, ActionUpsert, `{}`) + } + env.hub.Broadcast(loadChanges(t, env.hub, 0, 0)) + + waitFor(t, "慢客户端被标记", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && insts[0].Slow + }) + waitFor(t, "慢客户端连接被断开", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && !insts[0].Online + }) +} diff --git a/server/internal/realtime/instances.go b/server/internal/realtime/instances.go new file mode 100644 index 0000000..871cbad --- /dev/null +++ b/server/internal/realtime/instances.go @@ -0,0 +1,158 @@ +package realtime + +import ( + "crypto/rand" + "encoding/hex" + "sort" + "strconv" + "time" +) + +// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。 +type Instance struct { + ID string `json:"id"` + Online bool `json:"online"` + Page string `json:"page,omitempty"` + LastHeartbeat time.Time `json:"lastHeartbeat"` + ConnectedAt time.Time `json:"connectedAt"` + RemoteAddr string `json:"remoteAddr,omitempty"` + AckVersion int64 `json:"ackVersion"` + Slow bool `json:"slow"` +} + +// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 +type instanceState struct { + id string + page string + connectedAt time.Time + lastHeartbeat time.Time + remoteAddr string + ackVersion int64 + slow bool + conns int +} + +// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成, +// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。 +func (h *Hub) registerDisplay(c *client, id, page string) string { + if id == "" { + id = "display-" + randomID() + } + now := time.Now() + + c.mu.Lock() + prev := c.instanceID + c.instanceID = id + c.mu.Unlock() + + // 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。 + if prev != "" && prev != id { + h.releaseDisplayID(prev, c) + } + + h.mu.Lock() + defer h.mu.Unlock() + st := h.instances[id] + if st == nil { + st = &instanceState{id: id, connectedAt: now} + h.instances[id] = st + } + if prev != id { + st.conns++ + } + if page != "" { + st.page = page + } + st.lastHeartbeat = now + st.remoteAddr = c.remoteAddr + return id +} + +// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查。 +func (h *Hub) releaseDisplay(c *client) { + c.mu.Lock() + id := c.instanceID + c.mu.Unlock() + if id == "" { + return + } + h.releaseDisplayID(id, c) +} + +func (h *Hub) releaseDisplayID(id string, c *client) { + h.mu.Lock() + defer h.mu.Unlock() + st := h.instances[id] + if st == nil { + return + } + if st.conns > 0 { + st.conns-- + } + st.lastHeartbeat = time.Now() + st.ackVersion = c.ackVersion.Load() + st.slow = c.slow.Load() +} + +// touchInstance 在收到客户端消息或下发心跳时刷新「最后心跳」。 +func (h *Hub) touchInstance(c *client, now time.Time) { + c.mu.Lock() + id := c.instanceID + c.mu.Unlock() + if id == "" { + return + } + + h.mu.Lock() + defer h.mu.Unlock() + if st := h.instances[id]; st != nil { + st.lastHeartbeat = now + st.ackVersion = c.ackVersion.Load() + st.slow = c.slow.Load() + } +} + +// setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。 +func (h *Hub) setInstancePage(page string) { + if page == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + for _, st := range h.instances { + if st.conns > 0 { + st.page = page + } + } +} + +// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。 +func (h *Hub) Instances() []Instance { + h.mu.RLock() + defer h.mu.RUnlock() + + out := make([]Instance, 0, len(h.instances)) + for _, st := range h.instances { + out = append(out, Instance{ + ID: st.id, + Online: st.conns > 0, + Page: st.page, + LastHeartbeat: st.lastHeartbeat, + ConnectedAt: st.connectedAt, + RemoteAddr: st.remoteAddr, + AckVersion: st.ackVersion, + Slow: st.slow, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func randomID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + // 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。 + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(b[:]) +} diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go new file mode 100644 index 0000000..69f0d68 --- /dev/null +++ b/server/internal/realtime/protocol.go @@ -0,0 +1,171 @@ +// Package realtime 实现 /ws 实时同步中心。 +// +// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范: +// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。 +package realtime + +import ( + "encoding/json" + "fmt" + "time" +) + +// 事件名与协议第 3 节的表格一一对应。 +const ( + EventHello = "hello" + EventSnapshot = "snapshot" + EventCatchup = "catchup" + EventChange = "change" + EventResync = "resync" + EventAck = "ack" + EventHeartbeat = "heartbeat" + EventDisplayCommand = "display:command" +) + +// 房间名。admin: 由 AdminRoom 生成,用于定向通知。 +const ( + RoomDisplay = "display" + RoomAdmin = "admin" +) + +// AdminRoom 返回某个管理端用户的定向房间名。 +func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) } + +// 连接角色。 +const ( + RoleDisplay = "display" + RoleAdmin = "admin" +) + +// change.action 取值。 +const ( + ActionUpsert = "upsert" + ActionVoid = "void" + ActionReplace = "replace" +) + +// display:command 支持的 action。 +const ( + CmdGoto = "goto" + CmdLock = "lock" + CmdUnlock = "unlock" + CmdReload = "reload" + CmdAnnounce = "announce" + CmdShowDetail = "show-detail" + CmdHideDetail = "hide-detail" +) + +var displayActions = map[string]bool{ + CmdGoto: true, + CmdLock: true, + CmdUnlock: true, + CmdReload: true, + CmdAnnounce: true, + CmdShowDetail: true, + CmdHideDetail: true, +} + +// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。 +func ValidDisplayAction(action string) bool { return displayActions[action] } + +// envelope 是所有消息的统一外壳:{ "event": "...", "data": { ... } }。 +type envelope struct { + Event string `json:"event"` + Data json.RawMessage `json:"data,omitempty"` +} + +// encode 把事件名与载荷打包成一条文本帧。 +func encode(event string, data any) ([]byte, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("编码 %s 载荷: %w", event, err) + } + return json.Marshal(envelope{Event: event, Data: raw}) +} + +// helloPayload 是 C→S 的 hello 载荷。 +// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」, +// 老客户端不带这两个字段也能正常工作。 +type helloPayload struct { + Role string `json:"role"` + LastVersion *int64 `json:"lastVersion,omitempty"` + DisplayKey string `json:"displayKey,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + UserID int64 `json:"userId,omitempty"` + Page string `json:"page,omitempty"` +} + +// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。 +type ackPayload struct { + Version int64 `json:"version"` +} + +// Change 是变更的最小单元;Version 只用于服务端排序与去重,不上线路。 +type Change struct { + Entity string `json:"entity"` + ID int64 `json:"id,omitempty"` + Action string `json:"action"` + Data json.RawMessage `json:"data,omitempty"` + Version int64 `json:"-"` +} + +// changePayload 是 change 事件的载荷。 +type changePayload struct { + Version int64 `json:"version"` + Changes []Change `json:"changes"` +} + +// catchupPayload 是 catchup 事件的载荷。 +type catchupPayload struct { + FromVersion int64 `json:"fromVersion"` + Version int64 `json:"version"` + Changes []Change `json:"changes"` +} + +// heartbeatPayload 是 heartbeat 事件的载荷。 +type heartbeatPayload struct { + Version int64 `json:"version"` + ServerTime string `json:"serverTime"` +} + +// DisplayCommand 是下发给大屏的 display:command 载荷。 +type DisplayCommand struct { + Action string `json:"action"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// NewDisplayCommand 构造一条大屏指令;action 不在协议清单内时返回错误。 +func NewDisplayCommand(action string, payload any) (DisplayCommand, error) { + if !ValidDisplayAction(action) { + return DisplayCommand{}, fmt.Errorf("未知的大屏指令 %q", action) + } + cmd := DisplayCommand{Action: action} + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return DisplayCommand{}, fmt.Errorf("编码大屏指令 %s 的 payload: %w", action, err) + } + cmd.Payload = raw + } + return cmd, nil +} + +// pageFromPayload 从 goto 指令的 payload 里取目标页名,兼容字符串与对象两种写法。 +func pageFromPayload(payload json.RawMessage) string { + if len(payload) == 0 { + return "" + } + var s string + if err := json.Unmarshal(payload, &s); err == nil { + return s + } + var obj struct { + Page string `json:"page"` + } + if err := json.Unmarshal(payload, &obj); err == nil { + return obj.Page + } + return "" +} + +func nowISO(t time.Time) string { return t.Format(time.RFC3339Nano) } diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go new file mode 100644 index 0000000..8adf2ad --- /dev/null +++ b/server/internal/realtime/snapshot.go @@ -0,0 +1,348 @@ +package realtime + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。 +const latestResultsLimit = 20 + +// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 +// 字段名与协议第 3 节一致。 +type Snapshot struct { + Version int64 `json:"version"` + Settings map[string]json.RawMessage `json:"settings"` + Teams []domain.Team `json:"teams"` + Events []domain.Event `json:"events"` + Standings []domain.StandingsRow `json:"standings"` + Records []Record `json:"records"` + LatestResults []LatestResult `json:"latestResults"` +} + +// Record 是纪录榜的一行。 +type Record struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Level string `json:"level"` + ResultValue *float64 `json:"resultValue,omitempty"` + ResultText string `json:"resultText,omitempty"` + HolderName string `json:"holderName,omitempty"` + HolderTeamID int64 `json:"holderTeamId,omitempty"` + AchievedAt string `json:"achievedAt,omitempty"` + IsCurrent bool `json:"isCurrent"` + Source string `json:"source"` +} + +// LatestResult 是已发布成绩的展示行,供大屏滚动与详情页使用。 +type LatestResult struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + TeamID int64 `json:"teamId"` + AthleteID int64 `json:"athleteId,omitempty"` + Rank int `json:"rank,omitempty"` + ResultText string `json:"resultText,omitempty"` + ResultValue *float64 `json:"resultValue,omitempty"` + Unit string `json:"unit,omitempty"` + IsTie bool `json:"isTie,omitempty"` + Status string `json:"status"` + State string `json:"state"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// Snapshot 读取一份一致的完整状态。 +// +// 全部查询放在同一个只读事务里,且版本号取自同一事务, +// 否则快照里的数据与版本号会错位,重连补齐时就会漏变更。 +func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) { + if h.db == nil { + return nil, fmt.Errorf("实时中心未绑定赛事库") + } + + tx, err := h.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("开启快照事务: %w", err) + } + defer func() { _ = tx.Rollback() }() + + snap := &Snapshot{ + Settings: map[string]json.RawMessage{}, + Teams: []domain.Team{}, + Events: []domain.Event{}, + Standings: []domain.StandingsRow{}, + Records: []Record{}, + LatestResults: []LatestResult{}, + } + + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil { + return nil, fmt.Errorf("读取快照版本号: %w", err) + } + if err := loadSettings(ctx, tx, snap); err != nil { + return nil, err + } + if err := loadTeams(ctx, tx, snap); err != nil { + return nil, err + } + if err := loadEvents(ctx, tx, snap); err != nil { + return nil, err + } + if err := loadStandings(ctx, tx, snap); err != nil { + return nil, err + } + if err := loadRecords(ctx, tx, snap); err != nil { + return nil, err + } + if err := loadLatestResults(ctx, tx, snap); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("提交快照事务: %w", err) + } + return snap, nil +} + +func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`) + if err != nil { + return fmt.Errorf("读取赛事配置: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return fmt.Errorf("解析赛事配置: %w", err) + } + snap.Settings[key] = rawJSONOrString(value) + } + return rows.Err() +} + +func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + rows, err := tx.QueryContext(ctx, ` + SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), + COALESCE(color, ''), sort_order, active + FROM teams WHERE active = 1 + ORDER BY sort_order, code`) + if err != nil { + return fmt.Errorf("读取单位: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + t domain.Team + active int + ) + if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil { + return fmt.Errorf("解析单位: %w", err) + } + t.Active = active != 0 + snap.Teams = append(snap.Teams, t) + } + return rows.Err() +} + +func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + rows, err := tx.QueryContext(ctx, ` + SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, + COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '') + FROM events + ORDER BY sort_order, id`) + if err != nil { + return fmt.Errorf("读取小项: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + e domain.Event + eventType string + resultMode string + resultType string + compare string + status string + recordEligible int + scored int + ) + if err := rows.Scan( + &e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare, + &recordEligible, &scored, &e.Multiplier, &status, + &e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark, + ); err != nil { + return fmt.Errorf("解析小项: %w", err) + } + e.EventType = domain.EventType(eventType) + e.ResultMode = domain.ResultMode(resultMode) + e.ResultType = domain.ResultType(resultType) + e.Compare = domain.CompareMode(compare) + e.Status = domain.EventStatus(status) + e.RecordEligible = recordEligible != 0 + e.Scored = scored != 0 + snap.Events = append(snap.Events, e) + } + return rows.Err() +} + +// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次, +// 保证大屏看到的榜单与 REST /api/standings 一致。 +func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + ledger, err := loadLedger(ctx, tx) + if err != nil { + return err + } + ranked, err := loadRanked(ctx, tx) + if err != nil { + return err + } + snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{ + Teams: snap.Teams, + Ledger: ledger, + Ranked: ranked, + }) + return nil +} + +func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0), + COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points + FROM score_ledger WHERE voided = 0 ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("读取积分流水: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.LedgerEntry + for rows.Next() { + var ( + e domain.LedgerEntry + sourceType string + ) + if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil { + return nil, fmt.Errorf("解析积分流水: %w", err) + } + e.SourceType = domain.SourceType(sourceType) + out = append(out, e) + } + return out, rows.Err() +} + +// loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。 +func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank + FROM results r JOIN entries e ON e.id = r.entry_id + WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3 + ORDER BY r.id`) + if err != nil { + return nil, fmt.Errorf("读取奖牌名次: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []domain.RankedResult + for rows.Next() { + var ( + teamID, athleteID int64 + rank int + ) + if err := rows.Scan(&teamID, &athleteID, &rank); err != nil { + return nil, fmt.Errorf("解析奖牌名次: %w", err) + } + out = append(out, domain.RankedResult{ + TeamID: teamID, + AthleteID: athleteID, + Rank: rank, + Scope: domain.ScopeTeam, + Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}}, + }) + } + return out, rows.Err() +} + +func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + rows, err := tx.QueryContext(ctx, ` + SELECT id, event_id, level, result_value, COALESCE(result_text, ''), + COALESCE(holder_name, ''), COALESCE(holder_team_id, 0), + COALESCE(achieved_at, ''), is_current, source + FROM records WHERE is_current = 1 + ORDER BY event_id, level, id`) + if err != nil { + return fmt.Errorf("读取纪录榜: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + r Record + value sql.NullFloat64 + isCurrent int + ) + if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &value, &r.ResultText, + &r.HolderName, &r.HolderTeamID, &r.AchievedAt, &isCurrent, &r.Source); err != nil { + return fmt.Errorf("解析纪录: %w", err) + } + if value.Valid { + v := value.Float64 + r.ResultValue = &v + } + r.IsCurrent = isCurrent != 0 + snap.Records = append(snap.Records, r) + } + return rows.Err() +} + +func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { + rows, err := tx.QueryContext(ctx, ` + SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0), + COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value, + COALESCE(r.unit, ''), r.is_tie, r.status, r.state, + COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') + FROM results r JOIN entries e ON e.id = r.entry_id + WHERE r.state = 'published' + ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC + LIMIT ?`, latestResultsLimit) + if err != nil { + return fmt.Errorf("读取最新成绩: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + lr LatestResult + value sql.NullFloat64 + isTie int + ) + if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID, + &lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil { + return fmt.Errorf("解析最新成绩: %w", err) + } + if value.Valid { + v := value.Float64 + lr.ResultValue = &v + } + lr.IsTie = isTie != 0 + snap.LatestResults = append(snap.LatestResults, lr) + } + return rows.Err() +} + +// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传, +// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。 +func rawJSONOrString(value string) json.RawMessage { + if json.Valid([]byte(value)) { + return json.RawMessage(value) + } + quoted, err := json.Marshal(value) + if err != nil { + return json.RawMessage(`null`) + } + return quoted +} diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go new file mode 100644 index 0000000..a86b932 --- /dev/null +++ b/server/internal/realtime/snapshot_test.go @@ -0,0 +1,103 @@ +package realtime + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 快照必须与数据库同源:版本号取自 change_log,各列表取真实表, +// 且空库时序列化为 [] 而不是 null,避免前端判空逻辑踩坑。 +func TestSnapshotContents(t *testing.T) { + env := newTestEnv(t) + db := env.db + + mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `"测试运动会"`) + mustExec(t, db, `INSERT INTO settings(key, value) VALUES ('display.theme', 'dark')`) + mustExec(t, db, `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一单位', '一队', 1, 1)`) + mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('S1', '田径')`) + mustExec(t, db, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '100米', 'individual', 'auto', 'time', 'asc', 'published')`) + mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`) + mustExec(t, db, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`) + mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`) + mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status) + VALUES (1, 1, 1, '11.58', 11.58, 's', 'published', 'ok')`) + mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'result', 1, 9)`) + mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source) + VALUES (1, 'meet', 11.2, '11.20', '李四', 1, '2025-09-01', 1, 'history')`) + version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`) + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if snap.Version != version { + t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version) + } + if got := string(snap.Settings["event.name"]); got != `"测试运动会"` { + t.Fatalf("settings JSON 值应透传,得到 %s", got) + } + if got := string(snap.Settings["display.theme"]); got != `"dark"` { + t.Fatalf("settings 纯文本应编码为 JSON 字符串,得到 %s", got) + } + + if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active { + t.Fatalf("单位不符: %+v", snap.Teams) + } + if len(snap.Events) != 1 { + t.Fatalf("小项数应为 1,得到 %d", len(snap.Events)) + } + ev := snap.Events[0] + if ev.Name != "100米" || ev.EventType != domain.EventTypeIndividual || !ev.Scored || ev.Compare != domain.CompareAsc { + t.Fatalf("小项不符: %+v", ev) + } + + if len(snap.Standings) != 1 { + t.Fatalf("榜单行数应为 1,得到 %d", len(snap.Standings)) + } + row := snap.Standings[0] + if row.TeamID != 1 || row.Points != 9 || row.Gold != 1 || row.Rank != 1 { + t.Fatalf("榜单不符: %+v", row) + } + + if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil { + t.Fatalf("纪录不符: %+v", snap.Records) + } + if len(snap.LatestResults) != 1 { + t.Fatalf("最新成绩数应为 1,得到 %d", len(snap.LatestResults)) + } + lr := snap.LatestResults[0] + if lr.ResultText != "11.58" || lr.TeamID != 1 || lr.AthleteID != 1 || lr.Rank != 1 || lr.State != "published" { + t.Fatalf("最新成绩不符: %+v", lr) + } +} + +func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) { + env := newTestEnv(t) + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建空快照: %v", err) + } + raw, err := json.Marshal(snap) + if err != nil { + t.Fatalf("序列化快照: %v", err) + } + for _, want := range []string{`"teams":[]`, `"events":[]`, `"standings":[]`, `"records":[]`, `"latestResults":[]`} { + if !strings.Contains(string(raw), want) { + t.Fatalf("空快照应包含 %s,实际 %s", want, raw) + } + } +} + +func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { + t.Helper() + if _, err := db.Exec(q, args...); err != nil { + t.Fatalf("执行 SQL %q: %v", q, err) + } +} diff --git a/server/internal/rules/apply.go b/server/internal/rules/apply.go new file mode 100644 index 0000000..743d3f6 --- /dev/null +++ b/server/internal/rules/apply.go @@ -0,0 +1,248 @@ +package rules + +import ( + "database/sql" + "fmt" + "sort" + "strings" +) + +// ApplyResult 汇总一次导入实际落库的行数。 +type ApplyResult struct { + ProfilesWritten int `json:"profilesWritten"` + ProfilesDeleted int `json:"profilesDeleted"` + ChannelsWritten int `json:"channelsWritten"` + EventsUpdated int `json:"eventsUpdated"` + SettingsWritten int `json:"settingsWritten"` + EventsSkipped []string `json:"eventsSkipped,omitempty"` +} + +// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步, +// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。 +// 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。 +// +// 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。 +// 通道先改完再删模板,避免「模板被换掉」时误判引用。 +// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。 +func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { + if cs == nil || cs.Next == nil { + return nil, ErrNilChangeSet + } + next := cs.Next.Canonical() + if err := next.Validate(); err != nil { + return nil, err + } + + cur, err := Load(db) + if err != nil { + return nil, err + } + targets, unknown, err := resolveTargets(cur, &next) + if err != nil { + return nil, err + } + + tx, err := db.Begin() + if err != nil { + return nil, fmt.Errorf("开启事务: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res := &ApplyResult{EventsSkipped: unknown} + + // 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。 + profileIDs := map[string]int64{} + for _, prof := range next.Profiles { + id, err := upsertProfile(tx, prof) + if err != nil { + return nil, err + } + profileIDs[prof.Code] = id + res.ProfilesWritten++ + } + + // 2. 通道:只覆盖规则包明确提到的项目。 + eventIDs := map[string]int64{} + for _, ev := range cur.Events { + eventIDs[ev.Code] = ev.ID + } + codes := make([]string, 0, len(targets)) + for code := range targets { + codes = append(codes, code) + } + sort.Strings(codes) + + for _, code := range codes { + eventID, ok := eventIDs[code] + if !ok { + continue + } + if _, err := tx.Exec(`DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { + return nil, fmt.Errorf("清理项目 %s 的计分通道: %w", code, err) + } + for i, ch := range targets[code] { + profileID, ok := profileIDs[ch.Profile] + if !ok { + return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile) + } + if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i); err != nil { + return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err) + } + } + res.ChannelsWritten++ + } + + // 3. 删除目标规则包里已没有的模板;仍被引用就报错,绝不静默拆掉别人的配置。 + for _, prof := range cur.Pack.Profiles { + if _, keep := profileIDs[prof.Code]; keep { + continue + } + if err := deleteProfile(tx, prof.Code); err != nil { + return nil, err + } + res.ProfilesDeleted++ + } + + // 4. 项目开关:只改 override 里显式给出的字段。 + for _, ov := range next.EventOverrides { + eventID, ok := eventIDs[ov.EventCode] + if !ok || (ov.Scored == nil && ov.Multiplier == nil) { + continue + } + scored, err := currentScored(tx, eventID) + if err != nil { + return nil, err + } + if ov.Scored != nil { + scored = *ov.Scored + } + multiplier, err := currentMultiplier(tx, eventID) + if err != nil { + return nil, err + } + if ov.Multiplier != nil { + multiplier = *ov.Multiplier + } + if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`, + boolToInt(scored), multiplier, eventID); err != nil { + return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err) + } + res.EventsUpdated++ + } + + // 5. 赛事设置。 + settings := settingsFromPack(&next) + for _, key := range settingsKeys() { + if _, err := tx.Exec(`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + key, settings[key]); err != nil { + return nil, fmt.Errorf("写入赛事设置 %s: %w", key, err) + } + res.SettingsWritten++ + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("提交导入事务: %w", err) + } + return res, nil +} + +func upsertProfile(tx *sql.Tx, prof Profile) (int64, error) { + if _, err := tx.Exec(`INSERT INTO scoring_profiles + (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + name = excluded.name, + tie_mode = excluded.tie_mode, + record_bonus_mode = excluded.record_bonus_mode, + award_ranks = excluded.award_ranks, + last_place_zero = excluded.last_place_zero, + updated_at = datetime('now')`, + prof.Code, prof.Name, string(prof.TieMode), string(prof.RecordBonusMode), prof.AwardRanks, boolToInt(prof.LastPlaceZero)); err != nil { + return 0, fmt.Errorf("写入积分模板 %s: %w", prof.Code, err) + } + + var id int64 + if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, prof.Code).Scan(&id); err != nil { + return 0, fmt.Errorf("读取积分模板 %s 的 id: %w", prof.Code, err) + } + if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil { + return 0, fmt.Errorf("清理积分模板 %s 的明细: %w", prof.Code, err) + } + for _, r := range prof.Rules { + if _, err := tx.Exec(`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, + id, r.Rank, r.Points); err != nil { + return 0, fmt.Errorf("写入积分模板 %s 的名次 %d: %w", prof.Code, r.Rank, err) + } + } + return id, nil +} + +func deleteProfile(tx *sql.Tx, code string) error { + var id int64 + if err := tx.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil { + if err == sql.ErrNoRows { + return nil + } + return fmt.Errorf("读取积分模板 %s: %w", code, err) + } + + refs, err := profileRefs(tx, id) + if err != nil { + return err + } + if len(refs) > 0 { + return fmt.Errorf("%w: 模板 %s 仍被 %s 引用", ErrProfileInUse, code, strings.Join(refs, "、")) + } + + if _, err := tx.Exec(`DELETE FROM scoring_profile_rules WHERE profile_id = ?`, id); err != nil { + return fmt.Errorf("删除积分模板 %s 的明细: %w", code, err) + } + if _, err := tx.Exec(`DELETE FROM scoring_profiles WHERE id = ?`, id); err != nil { + return fmt.Errorf("删除积分模板 %s: %w", code, err) + } + return nil +} + +func profileRefs(tx *sql.Tx, profileID int64) ([]string, error) { + var refs []string + var channels int + if err := tx.QueryRow(`SELECT COUNT(*) FROM event_score_channels WHERE profile_id = ?`, profileID).Scan(&channels); err != nil { + return nil, fmt.Errorf("统计模板引用: %w", err) + } + if channels > 0 { + refs = append(refs, fmt.Sprintf("%d 个计分通道", channels)) + } + var sports int + if err := tx.QueryRow(`SELECT COUNT(*) FROM sports WHERE default_profile_id = ?`, profileID).Scan(&sports); err != nil { + return nil, fmt.Errorf("统计模板引用: %w", err) + } + if sports > 0 { + refs = append(refs, fmt.Sprintf("%d 个大项的默认模板", sports)) + } + return refs, nil +} + +func currentScored(tx *sql.Tx, eventID int64) (bool, error) { + var v int + if err := tx.QueryRow(`SELECT scored FROM events WHERE id = ?`, eventID).Scan(&v); err != nil { + return false, fmt.Errorf("读取项目计分开关: %w", err) + } + return v != 0, nil +} + +func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) { + var v float64 + if err := tx.QueryRow(`SELECT multiplier FROM events WHERE id = ?`, eventID).Scan(&v); err != nil { + return 0, fmt.Errorf("读取项目权重: %w", err) + } + return v, nil +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/server/internal/rules/apply_test.go b/server/internal/rules/apply_test.go new file mode 100644 index 0000000..48b24c3 --- /dev/null +++ b/server/internal/rules/apply_test.go @@ -0,0 +1,381 @@ +package rules + +import ( + "database/sql" + "errors" + "path/filepath" + "reflect" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +const applyPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo-rules, name: 导入目标 } +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] + medalCountsTeamEvents: true + showIndividualBoard: true + showIndividualMedals: true +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 12 } + - { rank: 2, points: 9 } + - { rank: 3, points: 7 } + - { rank: 4, points: 4 } + - { rank: 5, points: 3 } + - { rank: 6, points: 2 } + - code: team_top6 + name: 团体项目前六名 + awardRanks: 6 + tieMode: skip + 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 } + - code: special_top3 + name: 特别项目前三名 + awardRanks: 3 + tieMode: higher + rules: + - { rank: 1, points: 5 } + - { rank: 2, points: 3 } + - { rank: 3, points: 1 } +recordBonus: + enabled: false + mode: max + levels: + - { code: meet, name: 赛会纪录, points: 2 } +manualAdjustment: + enabled: false + reasonRequired: true +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - match: { eventType: team } + channels: + - { scope: team, basis: manual, profile: team_top6 } +eventOverrides: + - eventCode: EVT_IND + name: 个人示例 + multiplier: 2 + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - { scope: team, basis: manual, profile: team_top6 } + - eventCode: EVT_TEAM + channels: [] +` + +func openEventDB(t *testing.T) *sql.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "event.db") + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("迁移赛事库: %v", err) + } + return db +} + +// seedEvents 建一个示例大项与两个项目,并给团体项目挂一条通道,用于验证通道删除。 +func seedEvents(t *testing.T, db *sql.DB) { + t.Helper() + res, err := db.Exec(`INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`) + if err != nil { + t.Fatalf("插入大项: %v", err) + } + sportID, err := res.LastInsertId() + if err != nil { + t.Fatalf("读取大项 id: %v", err) + } + + for _, ev := range []struct { + code, name, eventType, resultType, compare string + }{ + {"EVT_IND", "个人示例", "individual", "time", "asc"}, + {"EVT_TEAM", "团体示例", "team", "none", "none"}, + } { + if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_type, compare, scored, multiplier) + VALUES (?, ?, ?, ?, ?, ?, 1, 1)`, sportID, ev.code, ev.name, ev.eventType, ev.resultType, ev.compare); err != nil { + t.Fatalf("插入项目 %s: %v", ev.code, err) + } + } + + var teamProfileID int64 + if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfileID); err != nil { + t.Fatalf("查询团体模板: %v", err) + } + var teamEventID int64 + if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_TEAM'`).Scan(&teamEventID); err != nil { + t.Fatalf("查询团体项目: %v", err) + } + if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'manual', ?, 0)`, teamEventID, teamProfileID); err != nil { + t.Fatalf("插入团体通道: %v", err) + } +} + +func TestApplyWritesChangesAndIsIdempotent(t *testing.T) { + db := openEventDB(t) + seedEvents(t, db) + + next, err := Parse([]byte(applyPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + cur, err := Load(db) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cur.Pack.Profiles) != 2 { + t.Fatalf("迁移后应有 2 个模板,实际 %d", len(cur.Pack.Profiles)) + } + + cs, err := Diff(cur, next) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if cs.Empty() { + t.Fatal("差异不应为空") + } + // 预览阶段不落库。 + if got := countRows(t, db, "scoring_profiles"); got != 2 { + t.Fatalf("Diff 不应写库,模板数 = %d", got) + } + + res, err := Apply(db, cs) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if res.ProfilesWritten != 3 || res.ProfilesDeleted != 0 || res.ChannelsWritten != 2 { + t.Errorf("落库统计异常: %+v", res) + } + + if got := countRows(t, db, "scoring_profiles"); got != 3 { + t.Errorf("模板数 = %d,期望 3", got) + } + if got := queryFloat(t, db, `SELECT points FROM scoring_profile_rules r + JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6' AND r.rank = 1`); got != 12 { + t.Errorf("个人模板第 1 名分值 = %v,期望 12", got) + } + if got := countRows(t, db, "event_score_channels"); got != 2 { + t.Errorf("通道数 = %d,期望 2(EVT_IND 双通道、EVT_TEAM 清空)", got) + } + if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c + JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 2 { + t.Errorf("EVT_IND 通道数 = %d,期望 2", got) + } + if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c + JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_TEAM'`); got != 0 { + t.Errorf("EVT_TEAM 通道数 = %d,期望 0", got) + } + if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_IND'`); got != 2 { + t.Errorf("EVT_IND 权重 = %v,期望 2", got) + } + if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'record_bonus.levels'`); got == "" { + t.Error("破纪录档位应写入 settings") + } + + // 重复导入必须幂等:第二次差异为空,Apply 不再写任何东西。 + cur2, err := Load(db) + if err != nil { + t.Fatalf("再次 Load: %v", err) + } + cs2, err := Diff(cur2, next) + if err != nil { + t.Fatalf("再次 Diff: %v", err) + } + if !cs2.Empty() { + t.Fatalf("重复导入应无差异: %+v", cs2) + } + if _, err := Apply(db, cs2); err != nil { + t.Fatalf("重复 Apply: %v", err) + } +} + +func TestExportRoundTripThroughDB(t *testing.T) { + db := openEventDB(t) + seedEvents(t, db) + + next, err := Parse([]byte(applyPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cur, err := Load(db) + if err != nil { + t.Fatalf("Load: %v", err) + } + cs, err := Diff(cur, next) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if _, err := Apply(db, cs); err != nil { + t.Fatalf("Apply: %v", err) + } + + meta := Meta{Code: "demo-rules", Name: "导入目标", Version: "1.0.0", UpdatedAt: "2026-01-01", Author: "tester"} + exported, err := Export(db, meta) + if err != nil { + t.Fatalf("Export: %v", err) + } + if exported.Meta != meta { + t.Errorf("导出 meta 错误: %+v", exported.Meta) + } + if len(exported.Profiles) != 3 { + t.Fatalf("导出模板数 = %d,期望 3", len(exported.Profiles)) + } + if exported.Profiles[0].Code != "individual_top6" || exported.Profiles[0].Rules[0].Points != 12 { + t.Errorf("导出模板内容错误: %+v", exported.Profiles[0]) + } + + var indOverride *EventOverride + for i := range exported.EventOverrides { + if exported.EventOverrides[i].EventCode == "EVT_IND" { + indOverride = &exported.EventOverrides[i] + } + } + if indOverride == nil { + t.Fatal("导出的项目例外里应包含 EVT_IND") + } + if indOverride.Multiplier == nil || *indOverride.Multiplier != 2 { + t.Errorf("EVT_IND 权重导出错误: %+v", indOverride.Multiplier) + } + if indOverride.Channels == nil || len(*indOverride.Channels) != 2 { + t.Errorf("EVT_IND 通道导出错误: %+v", indOverride.Channels) + } + + raw, err := Marshal(exported) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + reparsed, err := Parse(raw) + if err != nil { + t.Fatalf("重新 Parse 导出结果: %v", err) + } + if !reflect.DeepEqual(exported, reparsed) { + t.Errorf("导出结果往返不一致:\n%+v\n%+v", exported, reparsed) + } +} + +func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) { + db := openEventDB(t) + seedEvents(t, db) + + // 这份规则包没有 team_top6,而 EVT_TEAM 的通道仍引用它,删除必须被拒绝。 + const packYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 精简规则包 } +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 1 + tieMode: skip + rules: + - { rank: 1, points: 1 } +` + next, err := Parse([]byte(packYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + cur, err := Load(db) + if err != nil { + t.Fatalf("Load: %v", err) + } + cs, err := Diff(cur, next) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if _, err := Apply(db, cs); !errors.Is(err, ErrProfileInUse) { + t.Fatalf("期望 ErrProfileInUse,实际 %v", err) + } + if got := countRows(t, db, "scoring_profiles"); got != 2 { + t.Errorf("事务应回滚,模板数 = %d,期望 2", got) + } +} + +func TestApplyNilChangeSet(t *testing.T) { + db := openEventDB(t) + if _, err := Apply(db, nil); !errors.Is(err, ErrNilChangeSet) { + t.Fatalf("期望 ErrNilChangeSet,实际 %v", err) + } +} + +// 确认导入时通常只剩目标规则包(预览的 ChangeSet 不跨请求),这条路要能走通。 +func TestApplyAcceptsChangeSetWithOnlyNext(t *testing.T) { + db := openEventDB(t) + + next, err := Parse([]byte(applyPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + res, err := Apply(db, &ChangeSet{Next: next}) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if res.ProfilesWritten != 3 { + t.Errorf("模板写入数 = %d,期望 3", res.ProfilesWritten) + } + if got := countRows(t, db, "scoring_profiles"); got != 3 { + t.Errorf("模板数 = %d,期望 3", got) + } +} + +func countRows(t *testing.T, db *sql.DB, table string) int { + t.Helper() + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil { + t.Fatalf("统计 %s: %v", table, err) + } + return n +} + +func queryInt(t *testing.T, db *sql.DB, query string) int { + t.Helper() + var n int + if err := db.QueryRow(query).Scan(&n); err != nil { + t.Fatalf("查询 %q: %v", query, err) + } + return n +} + +func queryFloat(t *testing.T, db *sql.DB, query string) float64 { + t.Helper() + var v float64 + if err := db.QueryRow(query).Scan(&v); err != nil { + t.Fatalf("查询 %q: %v", query, err) + } + return v +} + +func queryString(t *testing.T, db *sql.DB, query string) string { + t.Helper() + var v string + if err := db.QueryRow(query).Scan(&v); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "" + } + t.Fatalf("查询 %q: %v", query, err) + } + return v +} diff --git a/server/internal/rules/convert.go b/server/internal/rules/convert.go new file mode 100644 index 0000000..ea9230e --- /dev/null +++ b/server/internal/rules/convert.go @@ -0,0 +1,120 @@ +package rules + +import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + +// 本文件是规则包类型与 domain 类型之间的唯一转换入口; +// 转换只搬字段、不填默认值(默认值统一由 Canonical 负责),避免两处口径打架。 + +// ToDomain 把模板转成 domain.ScoringProfile;id 由调用方(仓储层)决定。 +func (p Profile) ToDomain(id int64) domain.ScoringProfile { + tie := p.TieMode + if tie == "" { + tie = domain.TieSkip + } + bonus := p.RecordBonusMode + if bonus == "" { + bonus = domain.RecordBonusMax + } + rules := make([]domain.ScoringRule, 0, len(p.Rules)) + for _, r := range p.Rules { + rules = append(rules, domain.ScoringRule{Rank: r.Rank, Points: r.Points}) + } + return domain.ScoringProfile{ + ID: id, + Code: p.Code, + Name: p.Name, + AwardRanks: p.AwardRanks, + LastPlaceZero: p.LastPlaceZero, + TieMode: tie, + RecordBonusMode: bonus, + Rules: rules, + } +} + +// ProfileFromDomain 把 domain 模板转回规则包类型。 +func ProfileFromDomain(p domain.ScoringProfile) Profile { + rules := make([]Rule, 0, len(p.Rules)) + for _, r := range p.Rules { + rules = append(rules, Rule{Rank: r.Rank, Points: r.Points}) + } + return Profile{ + Code: p.Code, + Name: p.Name, + AwardRanks: p.AwardRanks, + LastPlaceZero: p.LastPlaceZero, + TieMode: p.TieMode, + RecordBonusMode: p.RecordBonusMode, + Rules: rules, + } +} + +// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。 +func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel { + return domain.ScoreChannel{ + EventID: eventID, + Scope: c.Scope, + Basis: c.Basis, + ProfileID: profileID, + Aggregate: c.Aggregate, + SortOrder: sortOrder, + } +} + +// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。 +func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel { + return Channel{ + Scope: c.Scope, + Basis: c.Basis, + Profile: profileCode, + Aggregate: c.Aggregate, + } +} + +// ProfileIndex 按模板编码建索引,便于通道引用解析。 +func (p *RulePack) ProfileIndex() map[string]Profile { + out := make(map[string]Profile, len(p.Profiles)) + for _, prof := range p.Profiles { + out[prof.Code] = prof + } + return out +} + +// ToStandingsInput 把榜单口径映射成 domain 聚合输入。 +func (s Standings) ToStandingsInput() domain.StandingsInput { + return domain.StandingsInput{ + IndividualToTeamWeight: s.IndividualToTeamWeight, + ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(), + } +} + +// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。 +func (r RecordBonus) ToDomain() domain.RecordBonusConfig { + mode := r.Mode + if mode == "" { + mode = domain.RecordBonusMax + } + levels := make([]domain.RecordLevel, 0, len(r.Levels)) + for _, lv := range r.Levels { + levels = append(levels, domain.RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points}) + } + return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels} +} + +// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。 +func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus { + levels := make([]RecordLevel, 0, len(c.Levels)) + for _, lv := range c.Levels { + levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points}) + } + return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels} +} + +// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。 +func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig { + return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired} +} + +// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。 +func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment { + return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired} +} diff --git a/server/internal/rules/diff.go b/server/internal/rules/diff.go new file mode 100644 index 0000000..7e58f86 --- /dev/null +++ b/server/internal/rules/diff.go @@ -0,0 +1,256 @@ +package rules + +import ( + "errors" + "fmt" + "sort" +) + +// ChangeKind 是差异类型。 +type ChangeKind string + +const ( + ChangeAdded ChangeKind = "added" + ChangeModified ChangeKind = "modified" + ChangeRemoved ChangeKind = "removed" +) + +// ProfileChange 是一个积分模板的增/改/删。 +type ProfileChange struct { + Code string `json:"code"` + Kind ChangeKind `json:"kind"` + Before *Profile `json:"before,omitempty"` + After *Profile `json:"after,omitempty"` + // Fields 列出被修改的字段,便于前端只高亮变化项。 + Fields []string `json:"fields,omitempty"` +} + +// ChannelChange 是一个项目计分通道的变化。 +type ChannelChange struct { + EventCode string `json:"eventCode"` + EventName string `json:"eventName,omitempty"` + Kind ChangeKind `json:"kind"` + Before []Channel `json:"before,omitempty"` + After []Channel `json:"after,omitempty"` +} + +// SettingChange 是一条赛事设置的变化。 +type SettingChange struct { + Key string `json:"key"` + Before string `json:"before"` + After string `json:"after"` +} + +// ChangeSet 是导入预览的结果,也是 Apply 的输入。 +type ChangeSet struct { + Profiles []ProfileChange `json:"profiles,omitempty"` + Channels []ChannelChange `json:"channels,omitempty"` + Settings []SettingChange `json:"settings,omitempty"` + // UnknownEvents 是规则包里出现、但赛事库中不存在的项目编码;Apply 会跳过它们。 + UnknownEvents []string `json:"unknownEvents,omitempty"` + // Next 是目标规则包;Apply 以它为完整目标状态落库。不参与 JSON 序列化。 + Next *RulePack `json:"-"` +} + +// Empty 判断这次导入是否真的会改动任何东西。 +// 只用于预览展示;Apply 始终以 Next 为目标全量同步,不依赖它做短路。 +func (cs *ChangeSet) Empty() bool { + return cs == nil || (len(cs.Profiles) == 0 && len(cs.Channels) == 0 && len(cs.Settings) == 0) +} + +// Diff 计算「当前赛事库 → 目标规则包」的差异,只读不落库。 +func Diff(cur *Snapshot, next *RulePack) (*ChangeSet, error) { + if cur == nil || cur.Pack == nil { + return nil, errors.New("rules: 当前快照为空,无法比对差异") + } + if next == nil { + return nil, fmt.Errorf("%w: 目标规则包为空", ErrNotRulePack) + } + + target := next.Canonical() + if err := target.Validate(); err != nil { + return nil, err + } + + cs := &ChangeSet{Next: &target} + cs.Profiles = diffProfiles(cur.Pack, &target) + + targets, unknown, err := resolveTargets(cur, &target) + if err != nil { + return nil, err + } + cs.UnknownEvents = unknown + cs.Channels = diffChannels(cur, targets) + cs.Settings = diffSettings(cur.Pack, &target) + return cs, nil +} + +func diffProfiles(cur, next *RulePack) []ProfileChange { + curIndex := cur.ProfileIndex() + nextIndex := next.ProfileIndex() + + codes := make([]string, 0, len(curIndex)+len(nextIndex)) + seen := map[string]bool{} + for code := range curIndex { + if !seen[code] { + seen[code] = true + codes = append(codes, code) + } + } + for code := range nextIndex { + if !seen[code] { + seen[code] = true + codes = append(codes, code) + } + } + sort.Strings(codes) + + var out []ProfileChange + for _, code := range codes { + before, hasBefore := curIndex[code] + after, hasAfter := nextIndex[code] + switch { + case !hasBefore: + out = append(out, ProfileChange{Code: code, Kind: ChangeAdded, After: &after}) + case !hasAfter: + out = append(out, ProfileChange{Code: code, Kind: ChangeRemoved, Before: &before}) + default: + if fields := profileDiffFields(before, after); len(fields) > 0 { + out = append(out, ProfileChange{Code: code, Kind: ChangeModified, Before: &before, After: &after, Fields: fields}) + } + } + } + return out +} + +func profileDiffFields(a, b Profile) []string { + var fields []string + if a.Name != b.Name { + fields = append(fields, "name") + } + if a.AwardRanks != b.AwardRanks { + fields = append(fields, "awardRanks") + } + if a.LastPlaceZero != b.LastPlaceZero { + fields = append(fields, "lastPlaceZero") + } + if a.TieMode != b.TieMode { + fields = append(fields, "tieMode") + } + if a.RecordBonusMode != b.RecordBonusMode { + fields = append(fields, "recordBonusMode") + } + if !rulesEqual(a.Rules, b.Rules) { + fields = append(fields, "rules") + } + return fields +} + +func rulesEqual(a, b []Rule) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// resolveTargets 把规则包展开成「项目编码 → 目标通道」。 +// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。 +// 没被规则包提到的项目不出现在结果里,表示「保持原样」。 +func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) { + overrideByCode := make(map[string]EventOverride, len(next.EventOverrides)) + for _, o := range next.EventOverrides { + overrideByCode[o.EventCode] = o + } + + targets := make(map[string][]Channel) + for _, ev := range cur.Events { + if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil { + targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...) + continue + } + for _, t := range next.ChannelTemplates { + if matchEvent(t.Match, ev) { + targets[ev.Code] = append([]Channel(nil), t.Channels...) + break + } + } + } + + var unknown []string + for _, o := range next.EventOverrides { + if _, ok := cur.EventByCode(o.EventCode); !ok { + unknown = append(unknown, o.EventCode) + } + } + sort.Strings(unknown) + return targets, unknown, nil +} + +func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange { + curByCode := make(map[string]EventInfo, len(cur.Events)) + for _, ev := range cur.Events { + curByCode[ev.Code] = ev + } + + codes := make([]string, 0, len(targets)) + for code := range targets { + codes = append(codes, code) + } + sort.Strings(codes) + + var out []ChannelChange + for _, code := range codes { + ev := curByCode[code] + before := ev.Channels + after := targets[code] + if channelsEqual(before, after) { + continue + } + kind := ChangeModified + switch { + case len(before) == 0: + kind = ChangeAdded + case len(after) == 0: + kind = ChangeRemoved + } + out = append(out, ChannelChange{ + EventCode: code, + EventName: ev.Name, + Kind: kind, + Before: append([]Channel(nil), before...), + After: append([]Channel(nil), after...), + }) + } + return out +} + +func channelsEqual(a, b []Channel) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func diffSettings(cur, next *RulePack) []SettingChange { + curSettings := settingsFromPack(cur) + nextSettings := settingsFromPack(next) + + var out []SettingChange + for _, key := range settingsKeys() { + before, after := curSettings[key], nextSettings[key] + if before != after { + out = append(out, SettingChange{Key: key, Before: before, After: after}) + } + } + return out +} diff --git a/server/internal/rules/diff_test.go b/server/internal/rules/diff_test.go new file mode 100644 index 0000000..01a39f7 --- /dev/null +++ b/server/internal/rules/diff_test.go @@ -0,0 +1,203 @@ +package rules + +import ( + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +func testSnapshot(t *testing.T, profiles []Profile, events []EventInfo) *Snapshot { + t.Helper() + pack := RulePack{ + Format: FormatName, + FormatVersion: FormatVersion, + Meta: Meta{Code: "demo", Name: "当前规则"}, + Standings: Standings{IndividualToTeamWeight: 1, TieBreak: DefaultTieBreak}, + Profiles: profiles, + } + canonical := pack.Canonical() + return &Snapshot{Pack: &canonical, Events: events} +} + +func currentSnapshot(t *testing.T) *Snapshot { + t.Helper() + return testSnapshot(t, + []Profile{ + { + Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 6, TieMode: domain.TieSkip, + Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}}, + }, + { + Code: "team_top6", Name: "团体项目前六名", AwardRanks: 2, TieMode: domain.TieSkip, + Rules: []Rule{{1, 15}, {2, 10}}, + }, + }, + []EventInfo{ + {ID: 1, Code: "EVT_IND", Name: "个人示例", EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime, + Scored: true, Multiplier: 1, + Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}}, + {ID: 2, Code: "EVT_TEAM", Name: "团体示例", EventType: domain.EventTypeTeam, ResultType: domain.ResultTypeNone, + Scored: true, Multiplier: 1, + Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}}, + {ID: 3, Code: "EVT_MATCH", Name: "对抗示例", EventType: domain.EventTypeMatch, ResultType: domain.ResultTypeNone, + Scored: true, Multiplier: 1, + Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}}, + }) +} + +const nextPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 目标规则包 } +standings: + individualToTeamWeight: 2 + tieBreak: [points, gold, silver, bronze, teamCode] + medalCountsTeamEvents: true + showIndividualBoard: true + showIndividualMedals: true +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 12 } + - { rank: 2, points: 9 } + - { rank: 3, points: 7 } + - { rank: 4, points: 4 } + - { rank: 5, points: 3 } + - { rank: 6, points: 2 } + - code: special_top3 + name: 特别项目前三名 + awardRanks: 3 + tieMode: higher + rules: + - { rank: 1, points: 5 } + - { rank: 2, points: 3 } + - { rank: 3, points: 1 } +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - match: { eventType: team } + channels: + - { scope: team, basis: manual, profile: special_top3 } +eventOverrides: + - eventCode: EVT_MATCH + channels: [] + - eventCode: EVT_GHOST + channels: + - { scope: team, basis: manual, profile: special_top3 } +` + +func TestDiffDetectsAddModifyRemove(t *testing.T) { + cur := currentSnapshot(t) + next, err := Parse([]byte(nextPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + cs, err := Diff(cur, next) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if cs.Empty() { + t.Fatal("差异不应为空") + } + + kinds := map[string]ChangeKind{} + for _, p := range cs.Profiles { + kinds[p.Code] = p.Kind + } + if kinds["individual_top6"] != ChangeModified { + t.Errorf("individual_top6 应为 modified,实际 %q", kinds["individual_top6"]) + } + if kinds["team_top6"] != ChangeRemoved { + t.Errorf("team_top6 应为 removed,实际 %q", kinds["team_top6"]) + } + if kinds["special_top3"] != ChangeAdded { + t.Errorf("special_top3 应为 added,实际 %q", kinds["special_top3"]) + } + for _, p := range cs.Profiles { + if p.Code == "individual_top6" && len(p.Fields) == 0 { + t.Error("修改的模板应列出变化字段") + } + if p.Code == "special_top3" && (p.Before != nil || p.After == nil) { + t.Error("新增模板的 Before 应为空、After 应有值") + } + } + + channels := map[string]ChannelChange{} + for _, c := range cs.Channels { + channels[c.EventCode] = c + } + if c, ok := channels["EVT_TEAM"]; !ok || c.Kind != ChangeModified { + t.Errorf("EVT_TEAM 通道应变为 special_top3: %+v", c) + } + if c, ok := channels["EVT_MATCH"]; !ok || c.Kind != ChangeRemoved || len(c.After) != 0 { + t.Errorf("EVT_MATCH 通道应被清空: %+v", c) + } + if _, ok := channels["EVT_IND"]; ok { + t.Error("EVT_IND 通道未变化,不应出现在差异里") + } + if len(cs.UnknownEvents) != 1 || cs.UnknownEvents[0] != "EVT_GHOST" { + t.Errorf("未知项目识别错误: %v", cs.UnknownEvents) + } + + if len(cs.Settings) != 1 || cs.Settings[0].Key != settingIndividualToTeamWeight { + t.Errorf("设置差异错误: %+v", cs.Settings) + } + if cs.Settings[0].Before != "1" || cs.Settings[0].After != "2" { + t.Errorf("权重差异值错误: %+v", cs.Settings[0]) + } +} + +func TestDiffIdenticalIsEmpty(t *testing.T) { + cur := currentSnapshot(t) + same, err := Diff(cur, cur.Pack) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if !same.Empty() { + t.Errorf("同一份规则包不应产生差异: %+v", same) + } +} + +func TestDiffRejectsInvalidPack(t *testing.T) { + cur := currentSnapshot(t) + bad, err := Parse([]byte(minimalPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + bad.FormatVersion = 99 + if _, err := Diff(cur, bad); err == nil { + t.Fatal("非法规则包应报错") + } +} + +func TestResolveTargetsPrecedence(t *testing.T) { + cur := currentSnapshot(t) + next, err := Parse([]byte(nextPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + targets, unknown, err := resolveTargets(cur, next) + if err != nil { + t.Fatalf("resolveTargets: %v", err) + } + + if got := targets["EVT_IND"]; len(got) != 1 || got[0].Profile != "individual_top6" { + t.Errorf("模板展开错误: %+v", got) + } + if got := targets["EVT_TEAM"]; len(got) != 1 || got[0].Profile != "special_top3" { + t.Errorf("模板展开错误: %+v", got) + } + if got, ok := targets["EVT_MATCH"]; !ok || len(got) != 0 { + t.Errorf("override 的 channels: [] 应清空通道: %+v", got) + } + if _, ok := targets["EVT_GHOST"]; ok { + t.Error("库里不存在的项目不应出现在目标状态里") + } + if len(unknown) != 1 || unknown[0] != "EVT_GHOST" { + t.Errorf("未知项目识别错误: %v", unknown) + } +} diff --git a/server/internal/rules/doc.go b/server/internal/rules/doc.go new file mode 100644 index 0000000..74e1e11 --- /dev/null +++ b/server/internal/rules/doc.go @@ -0,0 +1,18 @@ +// Package rules 实现积分规则包(YAML)的解析、校验、导出与导入落地。 +// +// 规则包是积分的唯一权威来源:分值、录取名次、并列口径、计分通道全部来自规则包, +// 代码里不写死任何数字(见 devdocs/02-积分与排名规则.md 第 7 节)。 +// +// 典型用法: +// +// pack, err := rules.Parse(raw) // 解析 + 校验 +// cur, err := rules.Load(db) // 读取赛事库当前规则 +// cs, err := rules.Diff(cur, pack) // 预览差异,不落库 +// if !cs.Empty() { +// res, err := rules.Apply(db, cs) // 确认后写入;全量重算由调用方触发 +// } +// out, err := rules.Export(db, meta) // 导出当前规则 +// yaml, err := rules.Marshal(out) +// +// 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。 +package rules diff --git a/server/internal/rules/parse.go b/server/internal/rules/parse.go new file mode 100644 index 0000000..59c4fda --- /dev/null +++ b/server/internal/rules/parse.go @@ -0,0 +1,71 @@ +package rules + +import ( + "bytes" + "errors" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +// 导出文件的注释头:规则包可能含赛事敏感信息,不进版本库(rules/README.md)。 +const header = `# 积分规则包 · scoreboard-rules v1 +# 由系统导出,可手工编辑后再导入;导入前请先预览差异。 +# 注意:本文件可能含赛事敏感信息,请勿提交到版本库。 +` + +// Parse 解析并校验规则包 YAML。未知字段一律报错,避免拼错的配置被静默忽略。 +func Parse(data []byte) (*RulePack, error) { + if len(bytes.TrimSpace(data)) == 0 { + return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack) + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + var pack RulePack + if err := dec.Decode(&pack); err != nil { + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%w: 内容为空", ErrNotRulePack) + } + return nil, fmt.Errorf("解析规则包 YAML: %w", err) + } + + // 规则包只允许一个文档;多文档容易让人以为后面的也会生效。 + var extra any + if err := dec.Decode(&extra); err == nil { + return nil, fmt.Errorf("%w: 只允许一个 YAML 文档", ErrNotRulePack) + } else if !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("解析规则包 YAML: %w", err) + } + + canonical := pack.Canonical() + if err := canonical.Validate(); err != nil { + return nil, err + } + return &canonical, nil +} + +// Marshal 把规则包序列化成带注释头的 YAML;输出是规范化后的结果,可安全往返。 +func Marshal(pack *RulePack) ([]byte, error) { + if pack == nil { + return nil, fmt.Errorf("%w: 规则包为空", ErrNotRulePack) + } + canonical := pack.Canonical() + if err := canonical.Validate(); err != nil { + return nil, err + } + + var buf bytes.Buffer + buf.WriteString(header) + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&canonical); err != nil { + return nil, fmt.Errorf("序列化规则包 YAML: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("序列化规则包 YAML: %w", err) + } + return buf.Bytes(), nil +} diff --git a/server/internal/rules/parse_test.go b/server/internal/rules/parse_test.go new file mode 100644 index 0000000..afccf6a --- /dev/null +++ b/server/internal/rules/parse_test.go @@ -0,0 +1,294 @@ +package rules + +import ( + "errors" + "reflect" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 测试一律用内联 YAML,不读 rules/ 目录(该目录含敏感信息且不进版本库)。 +const validPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: + code: demo-rules + name: 示例规则包 + version: 1.0.0 + updatedAt: "2026-01-01" + author: tester +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] + medalCountsTeamEvents: true + showIndividualBoard: true + showIndividualMedals: true +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + lastPlaceZero: false + tieMode: skip + 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 } + - code: team_top6 + name: 团体项目前六名 + awardRanks: 2 + tieMode: average + rules: + - { rank: 1, points: 15 } + - { rank: 2, points: 10 } +recordBonus: + enabled: false + mode: max + levels: + - { code: meet, name: 赛会纪录, points: 2 } +manualAdjustment: + enabled: false + reasonRequired: true +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - match: { eventType: team } + channels: + - { scope: team, basis: manual, profile: team_top6 } +eventOverrides: + - eventCode: EVT_DEMO + name: 示例项目 + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - { scope: team, basis: manual, profile: team_top6 } + - eventCode: EVT_NOSCORE + name: 只排名不计分 + scored: false +` + +const minimalPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 示例 } +profiles: + - { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] } +` + +func TestParseValidPack(t *testing.T) { + pack, err := Parse([]byte(validPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if pack.Format != FormatName || pack.FormatVersion != FormatVersion { + t.Fatalf("格式字段解析错误: %+v", pack) + } + if pack.Meta.Code != "demo-rules" || pack.Meta.Name != "示例规则包" { + t.Errorf("meta 解析错误: %+v", pack.Meta) + } + if len(pack.Profiles) != 2 { + t.Fatalf("模板数量 = %d,期望 2", len(pack.Profiles)) + } + if got := pack.Profiles[0].Rules[0]; got != (Rule{Rank: 1, Points: 10}) { + t.Errorf("名次 1 分值 = %+v,期望 10", got) + } + if pack.Profiles[0].TieMode != domain.TieSkip || pack.Profiles[1].TieMode != domain.TieAverage { + t.Errorf("并列口径解析错误: %q / %q", pack.Profiles[0].TieMode, pack.Profiles[1].TieMode) + } + // 未配置 recordBonusMode 时按 max 补齐(与 domain 默认一致)。 + if pack.Profiles[0].RecordBonusMode != domain.RecordBonusMax { + t.Errorf("recordBonusMode 缺省值 = %q,期望 max", pack.Profiles[0].RecordBonusMode) + } + if len(pack.ChannelTemplates) != 2 || len(pack.EventOverrides) != 2 { + t.Errorf("通道模板/项目例外数量错误: %d / %d", len(pack.ChannelTemplates), len(pack.EventOverrides)) + } + if pack.EventOverrides[1].Scored == nil || *pack.EventOverrides[1].Scored { + t.Errorf("EVT_NOSCORE 的 scored 应为 false: %+v", pack.EventOverrides[1].Scored) + } + if !pack.Standings.MedalCountsTeamEventsEnabled() || !pack.Standings.ShowIndividualBoardEnabled() { + t.Error("榜单开关缺省值应为 true") + } +} + +func TestParseFillsDefaults(t *testing.T) { + pack, err := Parse([]byte(minimalPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if pack.Standings.IndividualToTeamWeight != 1 { + t.Errorf("未配置权重时应补 1,实际 %v", pack.Standings.IndividualToTeamWeight) + } + if !reflect.DeepEqual(pack.Standings.TieBreak, DefaultTieBreak) { + t.Errorf("未配置 tieBreak 时应补默认值,实际 %v", pack.Standings.TieBreak) + } + if pack.RecordBonus.Mode != domain.RecordBonusMax { + t.Errorf("未配置 recordBonus.mode 时应补 max,实际 %q", pack.RecordBonus.Mode) + } +} + +func TestMarshalRoundTripStable(t *testing.T) { + pack, err := Parse([]byte(validPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + first, err := Marshal(pack) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.HasPrefix(string(first), "# 积分规则包") { + t.Errorf("导出缺少注释头:\n%s", first) + } + + again, err := Parse(first) + if err != nil { + t.Fatalf("重新 Parse 导出结果: %v", err) + } + second, err := Marshal(again) + if err != nil { + t.Fatalf("再次 Marshal: %v", err) + } + if string(first) != string(second) { + t.Errorf("往返序列化不稳定:\n--- 第一次 ---\n%s\n--- 第二次 ---\n%s", first, second) + } + if !reflect.DeepEqual(pack, again) { + t.Errorf("往返后规则包内容不一致:\n%+v\n%+v", pack, again) + } +} + +func TestParseRejectsBadInput(t *testing.T) { + cases := []struct { + name string + yaml string + sentinel error + }{ + {"空文件", " \n", ErrNotRulePack}, + {"格式标识不符", strings.Replace(validPackYAML, "format: scoreboard-rules", "format: something-else", 1), ErrNotRulePack}, + {"版本不兼容", strings.Replace(validPackYAML, "formatVersion: 1", "formatVersion: 2", 1), ErrIncompatibleVersion}, + {"未知字段", validPackYAML + "\nbogusField: 1\n", nil}, + {"缺 meta.code", strings.Replace(validPackYAML, " code: demo-rules\n", "", 1), ErrValidation}, + {"缺 meta.name", strings.Replace(validPackYAML, " name: 示例规则包\n", "", 1), ErrValidation}, + {"模板编码重复", strings.Replace(validPackYAML, " - code: team_top6", " - code: individual_top6", 1), ErrValidation}, + {"录取名次为负", strings.Replace(validPackYAML, "awardRanks: 6", "awardRanks: -1", 1), ErrValidation}, + {"并列口径非法", strings.Replace(validPackYAML, "tieMode: skip", "tieMode: random", 1), ErrValidation}, + {"分值非负", strings.Replace(validPackYAML, " - { rank: 1, points: 10 }", " - { rank: 1, points: -10 }", 1), ErrValidation}, + {"名次重复", strings.Replace(validPackYAML, " - { rank: 2, points: 8 }", " - { rank: 1, points: 8 }", 1), ErrValidation}, + {"通道 scope 非法", strings.Replace(validPackYAML, "{ scope: athlete, basis: result, profile: individual_top6 }", "{ scope: unit, basis: result, profile: individual_top6 }", 1), ErrValidation}, + {"通道 basis 非法", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: guess, profile: team_top6 }", 1), ErrValidation}, + {"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation}, + {"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation}, + {"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse([]byte(tc.yaml)) + if err == nil { + t.Fatal("期望报错,实际解析成功") + } + if tc.sentinel != nil && !errors.Is(err, tc.sentinel) { + t.Fatalf("期望 %v,实际 %v", tc.sentinel, err) + } + if tc.sentinel == nil && errors.Is(err, ErrIncompatibleVersion) { + t.Fatalf("未知字段不应报版本错误: %v", err) + } + }) + } +} + +func TestParseRejectsStructuralProblems(t *testing.T) { + cases := map[string]string{ + "没有模板": `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 示例 } +profiles: [] +`, + "模板没有通道": `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 示例 } +profiles: + - { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] } +channelTemplates: + - match: { eventType: team } + channels: [] +`, + "模板匹配条件为空": `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 示例 } +profiles: + - { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] } +channelTemplates: + - match: {} + channels: + - { scope: team, basis: manual, profile: p1 } +`, + "同一项目通道重复": `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 示例 } +profiles: + - { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] } +eventOverrides: + - eventCode: EVT_X + channels: + - { scope: team, basis: manual, profile: p1 } + - { scope: team, basis: manual, profile: p1 } +`, + } + for name, yaml := range cases { + t.Run(name, func(t *testing.T) { + if _, err := Parse([]byte(yaml)); !errors.Is(err, ErrValidation) { + t.Fatalf("期望 ErrValidation,实际 %v", err) + } + }) + } +} + +func TestParseRejectsMultipleDocuments(t *testing.T) { + if _, err := Parse([]byte(validPackYAML + "---\nformat: scoreboard-rules\n")); !errors.Is(err, ErrNotRulePack) { + t.Fatalf("多文档应报 ErrNotRulePack,实际 %v", err) + } +} + +func TestProfileDomainConversion(t *testing.T) { + pack, err := Parse([]byte(validPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + dp := pack.Profiles[0].ToDomain(7) + if dp.ID != 7 || dp.Code != "individual_top6" || dp.TieMode != domain.TieSkip { + t.Errorf("ToDomain 结果错误: %+v", dp) + } + if len(dp.Rules) != 6 || dp.Rules[0] != (domain.ScoringRule{Rank: 1, Points: 10}) { + t.Errorf("ToDomain 明细错误: %+v", dp.Rules) + } + + back := ProfileFromDomain(dp) + if !reflect.DeepEqual(back, pack.Profiles[0]) { + t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0]) + } + + ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0) + if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult { + t.Errorf("通道 ToDomain 结果错误: %+v", ch) + } + if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] { + t.Errorf("通道往返不一致: %+v", got) + } + + in := pack.Standings.ToStandingsInput() + if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals { + t.Errorf("榜单口径转换错误: %+v", in) + } + + rb := pack.RecordBonus.ToDomain() + if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 { + t.Errorf("破纪录配置转换错误: %+v", rb) + } + if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) { + t.Errorf("破纪录配置往返不一致: %+v", got) + } +} diff --git a/server/internal/rules/settings.go b/server/internal/rules/settings.go new file mode 100644 index 0000000..0d48127 --- /dev/null +++ b/server/internal/rules/settings.go @@ -0,0 +1,155 @@ +package rules + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 赛事库 settings 表里的键名;与 migrations/event/0011_seed.sql 保持一致。 +const ( + settingIndividualToTeamWeight = "standings.individual_to_team_weight" + settingTieBreak = "standings.tie_break" + settingMedalCountsTeamEvents = "standings.medal_counts_team_events" + settingShowIndividualBoard = "standings.show_individual_board" + settingShowIndividualMedals = "standings.show_individual_medals" + settingRecordBonusEnabled = "record_bonus.enabled" + settingRecordBonusMode = "record_bonus.mode" + settingRecordBonusLevels = "record_bonus.levels" + settingManualEnabled = "manual_adjustment.enabled" + settingManualReasonRequired = "manual_adjustment.reason_required" +) + +// settingsFromPack 把规则包里能落到 settings 表的部分拍平成键值对。 +// 破纪录档位库里没有表,用 JSON 存在 record_bonus.levels 里,避免导入时丢配置。 +func settingsFromPack(pack *RulePack) map[string]string { + levels, err := json.Marshal(pack.RecordBonus.Levels) + if err != nil { + levels = []byte("[]") + } + return map[string]string{ + settingIndividualToTeamWeight: formatFloat(pack.Standings.IndividualToTeamWeight), + settingTieBreak: strings.Join(pack.Standings.TieBreak, ","), + settingMedalCountsTeamEvents: formatBool(pack.Standings.MedalCountsTeamEventsEnabled()), + settingShowIndividualBoard: formatBool(pack.Standings.ShowIndividualBoardEnabled()), + settingShowIndividualMedals: formatBool(pack.Standings.ShowIndividualMedalsEnabled()), + settingRecordBonusEnabled: formatBool(pack.RecordBonus.Enabled), + settingRecordBonusMode: string(pack.RecordBonus.Mode), + settingRecordBonusLevels: string(levels), + settingManualEnabled: formatBool(pack.ManualAdjustment.Enabled), + settingManualReasonRequired: formatBool(pack.ManualAdjustment.ReasonRequired), + } +} + +// settingsKeys 返回固定顺序的键名,保证差异预览与落库顺序稳定。 +func settingsKeys() []string { + keys := []string{ + settingIndividualToTeamWeight, + settingTieBreak, + settingMedalCountsTeamEvents, + settingShowIndividualBoard, + settingShowIndividualMedals, + settingRecordBonusEnabled, + settingRecordBonusMode, + settingRecordBonusLevels, + settingManualEnabled, + settingManualReasonRequired, + } + sort.Strings(keys) + return keys +} + +func standingsFromSettings(m map[string]string) Standings { + s := Standings{ + IndividualToTeamWeight: settingFloat(m, settingIndividualToTeamWeight, 1), + TieBreak: splitList(m[settingTieBreak]), + MedalCountsTeamEvents: BoolPtr(settingBool(m, settingMedalCountsTeamEvents, true)), + ShowIndividualBoard: BoolPtr(settingBool(m, settingShowIndividualBoard, true)), + ShowIndividualMedals: BoolPtr(settingBool(m, settingShowIndividualMedals, true)), + } + if len(s.TieBreak) == 0 { + s.TieBreak = append([]string(nil), DefaultTieBreak...) + } + return s +} + +func recordBonusFromSettings(m map[string]string) RecordBonus { + rb := RecordBonus{ + Enabled: settingBool(m, settingRecordBonusEnabled, false), + Mode: modeOrDefault(m[settingRecordBonusMode]), + } + if raw := strings.TrimSpace(m[settingRecordBonusLevels]); raw != "" { + var levels []RecordLevel + if err := json.Unmarshal([]byte(raw), &levels); err == nil { + rb.Levels = levels + } + } + return rb +} + +func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment { + return ManualAdjustment{ + Enabled: settingBool(m, settingManualEnabled, false), + ReasonRequired: settingBool(m, settingManualReasonRequired, true), + } +} + +func settingFloat(m map[string]string, key string, def float64) float64 { + raw, ok := m[key] + if !ok { + return def + } + v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return def + } + return v +} + +func settingBool(m map[string]string, key string, def bool) bool { + raw, ok := m[key] + if !ok { + return def + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + default: + return def + } +} + +func formatFloat(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) } + +func formatBool(v bool) string { + if v { + return "true" + } + return "false" +} + +func splitList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func modeOrDefault(raw string) domain.RecordBonusMode { + if strings.TrimSpace(raw) == "" { + return domain.RecordBonusMax + } + return domain.RecordBonusMode(strings.TrimSpace(raw)) +} diff --git a/server/internal/rules/snapshot.go b/server/internal/rules/snapshot.go new file mode 100644 index 0000000..f0f2e4e --- /dev/null +++ b/server/internal/rules/snapshot.go @@ -0,0 +1,279 @@ +package rules + +import ( + "database/sql" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// EventInfo 是展开通道模板所需的最小项目信息。 +type EventInfo struct { + ID int64 + Code string + Name string + SportCode string + EventType domain.EventType + ResultType domain.ResultType + Scored bool + Multiplier float64 + Channels []Channel +} + +// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。 +// 差异预览需要项目目录才能把 channelTemplates 展开到具体项目。 +type Snapshot struct { + Pack *RulePack + Events []EventInfo +} + +// EventByCode 按项目编码查找项目。 +func (s *Snapshot) EventByCode(code string) (EventInfo, bool) { + for _, e := range s.Events { + if e.Code == code { + return e, true + } + } + return EventInfo{}, false +} + +// Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。 +func Load(db *sql.DB) (*Snapshot, error) { + pack := &RulePack{ + Format: FormatName, + FormatVersion: FormatVersion, + } + + profileByID, profiles, err := loadProfiles(db) + if err != nil { + return nil, err + } + pack.Profiles = profiles + + settings, err := loadSettings(db) + if err != nil { + return nil, err + } + pack.Standings = standingsFromSettings(settings) + pack.RecordBonus = recordBonusFromSettings(settings) + pack.ManualAdjustment = manualAdjustmentFromSettings(settings) + + events, err := loadEvents(db, profileByID) + if err != nil { + return nil, err + } + + // 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目 + // 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。 + for _, e := range events { + needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1 + if !needOverride { + continue + } + ov := EventOverride{EventCode: e.Code, Name: e.Name} + if !e.Scored { + ov.Scored = BoolPtr(false) + } + if e.Multiplier != 1 { + ov.Multiplier = Float64Ptr(e.Multiplier) + } + if len(e.Channels) > 0 { + ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...)) + } + pack.EventOverrides = append(pack.EventOverrides, ov) + } + + canonical := pack.Canonical() + return &Snapshot{Pack: &canonical, Events: events}, nil +} + +// Export 从赛事库生成规则包;meta 由调用方提供(库里不存规则包元数据)。 +func Export(db *sql.DB, meta Meta) (*RulePack, error) { + snap, err := Load(db) + if err != nil { + return nil, err + } + pack := *snap.Pack + pack.Meta = meta + if strings.TrimSpace(pack.Meta.Code) == "" { + pack.Meta.Code = "exported-rules" + } + if strings.TrimSpace(pack.Meta.Name) == "" { + if name, err := settingByKey(db, "event.name"); err == nil && strings.TrimSpace(name) != "" { + pack.Meta.Name = name + } else { + pack.Meta.Name = "未命名赛事 · 积分规则" + } + } + canonical := pack.Canonical() + return &canonical, nil +} + +func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) { + rows, err := db.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero + FROM scoring_profiles ORDER BY code`) + if err != nil { + return nil, nil, fmt.Errorf("查询积分模板: %w", err) + } + defer func() { _ = rows.Close() }() + + byID := map[int64]string{} + byCode := map[string]int{} + var profiles []Profile + for rows.Next() { + var ( + id, awardRanks int64 + code, name string + tie, bonus string + lastPlaceZeroRaw int + ) + if err := rows.Scan(&id, &code, &name, &tie, &bonus, &awardRanks, &lastPlaceZeroRaw); err != nil { + return nil, nil, fmt.Errorf("读取积分模板: %w", err) + } + byID[id] = code + byCode[code] = len(profiles) + profiles = append(profiles, Profile{ + Code: code, + Name: name, + AwardRanks: int(awardRanks), + LastPlaceZero: lastPlaceZeroRaw != 0, + TieMode: domain.TieMode(tie), + RecordBonusMode: domain.RecordBonusMode(bonus), + }) + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("读取积分模板: %w", err) + } + + ruleRows, err := db.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`) + if err != nil { + return nil, nil, fmt.Errorf("查询积分模板明细: %w", err) + } + defer func() { _ = ruleRows.Close() }() + + for ruleRows.Next() { + var ( + profileID int64 + rank int + points float64 + ) + if err := ruleRows.Scan(&profileID, &rank, &points); err != nil { + return nil, nil, fmt.Errorf("读取积分模板明细: %w", err) + } + code, ok := byID[profileID] + if !ok { + continue + } + idx := byCode[code] + profiles[idx].Rules = append(profiles[idx].Rules, Rule{Rank: rank, Points: points}) + } + if err := ruleRows.Err(); err != nil { + return nil, nil, fmt.Errorf("读取积分模板明细: %w", err) + } + return byID, profiles, nil +} + +func loadSettings(db *sql.DB) (map[string]string, error) { + rows, err := db.Query(`SELECT key, value FROM settings`) + if err != nil { + return nil, fmt.Errorf("查询赛事设置: %w", err) + } + defer func() { _ = rows.Close() }() + + out := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("读取赛事设置: %w", err) + } + out[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取赛事设置: %w", err) + } + return out, nil +} + +func settingByKey(db *sql.DB, key string) (string, error) { + var value string + if err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value); err != nil { + return "", err + } + return value, nil +} + +func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { + rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier + FROM events e JOIN sports s ON s.id = e.sport_id + ORDER BY e.sort_order, e.code`) + if err != nil { + return nil, fmt.Errorf("查询项目: %w", err) + } + defer func() { _ = rows.Close() }() + + events := []EventInfo{} + index := map[int64]int{} + for rows.Next() { + var ( + id int64 + code, name string + sportCode string + eventType string + resultType string + scoredRaw int + multiplier float64 + ) + if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil { + return nil, fmt.Errorf("读取项目: %w", err) + } + index[id] = len(events) + events = append(events, EventInfo{ + ID: id, + Code: code, + Name: name, + SportCode: sportCode, + EventType: domain.EventType(eventType), + ResultType: domain.ResultType(resultType), + Scored: scoredRaw != 0, + Multiplier: multiplier, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取项目: %w", err) + } + + chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order + FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id + ORDER BY c.event_id, c.sort_order, c.id`) + if err != nil { + return nil, fmt.Errorf("查询计分通道: %w", err) + } + defer func() { _ = chRows.Close() }() + + for chRows.Next() { + var ( + eventID int64 + scope string + basis string + code string + sortOrd int + ) + if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd); err != nil { + return nil, fmt.Errorf("读取计分通道: %w", err) + } + idx, ok := index[eventID] + if !ok { + continue + } + events[idx].Channels = append(events[idx].Channels, Channel{ + Scope: domain.Scope(scope), + Basis: domain.Basis(basis), + Profile: code, + }) + } + if err := chRows.Err(); err != nil { + return nil, fmt.Errorf("读取计分通道: %w", err) + } + return events, nil +} diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go new file mode 100644 index 0000000..024b947 --- /dev/null +++ b/server/internal/rules/types.go @@ -0,0 +1,221 @@ +package rules + +import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + +// 格式标识与版本:不符即报错,不做隐式转换(devdocs 02 §7.3)。 +const ( + FormatName = "scoreboard-rules" + FormatVersion = 1 +) + +// 榜单排序键;规则包里写错键名要报错,而不是静默忽略。 +const ( + TieBreakPoints = "points" + TieBreakGold = "gold" + TieBreakSilver = "silver" + TieBreakBronze = "bronze" + TieBreakTeamCode = "teamCode" + TieBreakName = "name" +) + +// DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。 +var DefaultTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode} + +var knownTieBreakKeys = map[string]bool{ + TieBreakPoints: true, + TieBreakGold: true, + TieBreakSilver: true, + TieBreakBronze: true, + TieBreakTeamCode: true, + TieBreakName: true, +} + +// RulePack 是一个完整的积分规则包,对应 rules/<赛事>.yaml。 +type RulePack struct { + Format string `yaml:"format" json:"format"` + FormatVersion int `yaml:"formatVersion" json:"formatVersion"` + Meta Meta `yaml:"meta" json:"meta"` + Standings Standings `yaml:"standings" json:"standings"` + Profiles []Profile `yaml:"profiles" json:"profiles"` + RecordBonus RecordBonus `yaml:"recordBonus" json:"recordBonus"` + ManualAdjustment ManualAdjustment `yaml:"manualAdjustment" json:"manualAdjustment"` + ChannelTemplates []ChannelTemplate `yaml:"channelTemplates,omitempty" json:"channelTemplates,omitempty"` + EventOverrides []EventOverride `yaml:"eventOverrides,omitempty" json:"eventOverrides,omitempty"` +} + +// Meta 是规则包的标识信息;库中不存规则包元数据,导出时由调用方提供。 +type Meta struct { + Code string `yaml:"code" json:"code"` + Name string `yaml:"name" json:"name"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` + UpdatedAt string `yaml:"updatedAt,omitempty" json:"updatedAt,omitempty"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` + Note string `yaml:"note,omitempty" json:"note,omitempty"` +} + +// Standings 是榜单口径。三个开关用指针,未配置时才能区分「缺省」与「显式 false」。 +type Standings struct { + IndividualToTeamWeight float64 `yaml:"individualToTeamWeight" json:"individualToTeamWeight"` + TieBreak []string `yaml:"tieBreak" json:"tieBreak"` + MedalCountsTeamEvents *bool `yaml:"medalCountsTeamEvents,omitempty" json:"medalCountsTeamEvents,omitempty"` + ShowIndividualBoard *bool `yaml:"showIndividualBoard,omitempty" json:"showIndividualBoard,omitempty"` + ShowIndividualMedals *bool `yaml:"showIndividualMedals,omitempty" json:"showIndividualMedals,omitempty"` +} + +// MedalCountsTeamEventsEnabled 缺省为 true(团体奖牌榜计入团体项目奖牌)。 +func (s Standings) MedalCountsTeamEventsEnabled() bool { + return boolOrDefault(s.MedalCountsTeamEvents, true) +} + +// ShowIndividualBoardEnabled 缺省为 true。 +func (s Standings) ShowIndividualBoardEnabled() bool { + return boolOrDefault(s.ShowIndividualBoard, true) +} + +// ShowIndividualMedalsEnabled 缺省为 true。 +func (s Standings) ShowIndividualMedalsEnabled() bool { + return boolOrDefault(s.ShowIndividualMedals, true) +} + +// Profile 是积分模板;rules 是「名次 → 分值」明细,分值一律来自规则包。 +type Profile struct { + Code string `yaml:"code" json:"code"` + Name string `yaml:"name" json:"name"` + AwardRanks int `yaml:"awardRanks" json:"awardRanks"` + LastPlaceZero bool `yaml:"lastPlaceZero,omitempty" json:"lastPlaceZero,omitempty"` + TieMode domain.TieMode `yaml:"tieMode" json:"tieMode"` + RecordBonusMode domain.RecordBonusMode `yaml:"recordBonusMode,omitempty" json:"recordBonusMode,omitempty"` + Rules []Rule `yaml:"rules" json:"rules"` +} + +// Rule 是积分模板的一条明细。 +type Rule struct { + Rank int `yaml:"rank" json:"rank"` + Points float64 `yaml:"points" json:"points"` +} + +// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。 +type Channel struct { + Scope domain.Scope `yaml:"scope" json:"scope"` + Basis domain.Basis `yaml:"basis" json:"basis"` + Profile string `yaml:"profile" json:"profile"` + Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"` +} + +// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。 +type ChannelMatch struct { + EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"` + ResultType domain.ResultType `yaml:"resultType,omitempty" json:"resultType,omitempty"` + SportCode string `yaml:"sportCode,omitempty" json:"sportCode,omitempty"` +} + +// ChannelTemplate 是按项目类型套用的计分通道。 +type ChannelTemplate struct { + Match ChannelMatch `yaml:"match" json:"match"` + Channels []Channel `yaml:"channels" json:"channels"` +} + +// EventOverride 是单个项目的例外配置。 +// +// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分 +// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。 +type EventOverride struct { + EventCode string `yaml:"eventCode" json:"eventCode"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"` + Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"` + Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"` +} + +// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。 +type RecordBonus struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Mode domain.RecordBonusMode `yaml:"mode,omitempty" json:"mode,omitempty"` + Levels []RecordLevel `yaml:"levels,omitempty" json:"levels,omitempty"` +} + +// RecordLevel 是破纪录的一个档位及其加分值。 +type RecordLevel struct { + Code string `yaml:"code" json:"code"` + Name string `yaml:"name" json:"name"` + Points float64 `yaml:"points" json:"points"` +} + +// ManualAdjustment 是人工调整开关。 +type ManualAdjustment struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ReasonRequired bool `yaml:"reasonRequired" json:"reasonRequired"` +} + +// BoolPtr 便于构造三态布尔(导出时写显式值)。 +func BoolPtr(v bool) *bool { return &v } + +// Float64Ptr 便于构造三态数值。 +func Float64Ptr(v float64) *float64 { return &v } + +// ChannelListPtr 便于构造三态通道列表。 +func ChannelListPtr(v []Channel) *[]Channel { return &v } + +// Canonical 返回补齐缺省值后的副本。缺省口径集中在这里,保证 Parse / Marshal / Apply 三处一致。 +func (p RulePack) Canonical() RulePack { + out := p + out.Standings.TieBreak = append([]string(nil), p.Standings.TieBreak...) + if len(out.Standings.TieBreak) == 0 { + out.Standings.TieBreak = append([]string(nil), DefaultTieBreak...) + } + // 权重 0 与未填等价:引擎对 <=0 按 1 处理,这里先对齐,避免导出与解析来回变。 + if out.Standings.IndividualToTeamWeight == 0 { + out.Standings.IndividualToTeamWeight = 1 + } + + out.Profiles = make([]Profile, len(p.Profiles)) + for i, prof := range p.Profiles { + out.Profiles[i] = prof + out.Profiles[i].Rules = append([]Rule{}, prof.Rules...) + if out.Profiles[i].TieMode == "" { + out.Profiles[i].TieMode = domain.TieSkip + } + if out.Profiles[i].RecordBonusMode == "" { + out.Profiles[i].RecordBonusMode = domain.RecordBonusMax + } + } + + if out.RecordBonus.Mode == "" { + out.RecordBonus.Mode = domain.RecordBonusMax + } + out.RecordBonus.Levels = append([]RecordLevel{}, p.RecordBonus.Levels...) + + out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates)) + for i, t := range p.ChannelTemplates { + out.ChannelTemplates[i] = t + out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels) + } + + out.EventOverrides = make([]EventOverride, len(p.EventOverrides)) + for i, o := range p.EventOverrides { + out.EventOverrides[i] = o + if o.Channels != nil { + cs := canonicalChannels(*o.Channels) + out.EventOverrides[i].Channels = &cs + } + } + return out +} + +func canonicalChannels(in []Channel) []Channel { + out := make([]Channel, len(in)) + for i, ch := range in { + out[i] = ch + if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" { + out[i].Aggregate = domain.AggregateSum + } + } + return out +} + +func boolOrDefault(p *bool, def bool) bool { + if p == nil { + return def + } + return *p +} diff --git a/server/internal/rules/validate.go b/server/internal/rules/validate.go new file mode 100644 index 0000000..b365afa --- /dev/null +++ b/server/internal/rules/validate.go @@ -0,0 +1,272 @@ +package rules + +import ( + "errors" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 哨兵错误:调用方据此区分「格式不对」「版本不兼容」「内容非法」。 +var ( + ErrNotRulePack = errors.New("rules: 不是积分规则包") + ErrIncompatibleVersion = errors.New("rules: 规则包格式版本不兼容") + ErrValidation = errors.New("rules: 规则包校验失败") + ErrProfileInUse = errors.New("rules: 积分模板仍被引用,不能删除") + ErrNilChangeSet = errors.New("rules: 变更集或目标规则包为空,无法导入") +) + +// Issue 是一条校验问题;Path 用点号路径定位到 YAML 中的位置。 +type Issue struct { + Path string `json:"path"` + Msg string `json:"msg"` +} + +// ValidationError 汇总一次校验中的全部问题,避免「改一个报一个」。 +type ValidationError struct { + Issues []Issue `json:"issues"` +} + +func (e *ValidationError) Error() string { + if len(e.Issues) == 0 { + return ErrValidation.Error() + } + parts := make([]string, 0, len(e.Issues)) + for _, is := range e.Issues { + parts = append(parts, is.Path+": "+is.Msg) + } + return ErrValidation.Error() + ": " + strings.Join(parts, "; ") +} + +// Unwrap 让 errors.Is(err, ErrValidation) 成立。 +func (e *ValidationError) Unwrap() error { return ErrValidation } + +type issueList struct { + issues []Issue +} + +func (l *issueList) addf(path, format string, args ...any) { + l.issues = append(l.issues, Issue{Path: path, Msg: fmt.Sprintf(format, args...)}) +} + +// Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。 +func (p *RulePack) Validate() error { + if p.Format != FormatName { + return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName) + } + if p.FormatVersion != FormatVersion { + return fmt.Errorf("%w: formatVersion=%d,本程序只支持 %d,不做隐式转换", + ErrIncompatibleVersion, p.FormatVersion, FormatVersion) + } + + var l issueList + if strings.TrimSpace(p.Meta.Code) == "" { + l.addf("meta.code", "不能为空") + } + if strings.TrimSpace(p.Meta.Name) == "" { + l.addf("meta.name", "不能为空") + } + + if p.Standings.IndividualToTeamWeight < 0 { + l.addf("standings.individualToTeamWeight", "不能为负,当前 %v", p.Standings.IndividualToTeamWeight) + } + validateTieBreak(&l, p.Standings.TieBreak) + + profiles := make(map[string]bool, len(p.Profiles)) + if len(p.Profiles) == 0 { + l.addf("profiles", "至少要有一个积分模板") + } + for i, prof := range p.Profiles { + path := fmt.Sprintf("profiles[%d]", i) + validateProfile(&l, path, prof, profiles) + } + + validateRecordBonus(&l, p.RecordBonus) + validateTemplates(&l, p.ChannelTemplates, profiles) + validateOverrides(&l, p.EventOverrides, profiles) + + if len(l.issues) > 0 { + return &ValidationError{Issues: l.issues} + } + return nil +} + +func validateTieBreak(l *issueList, tieBreak []string) { + seen := make(map[string]bool, len(tieBreak)) + for i, key := range tieBreak { + path := fmt.Sprintf("standings.tieBreak[%d]", i) + if !knownTieBreakKeys[key] { + l.addf(path, "未知的排序键 %q", key) + continue + } + if seen[key] { + l.addf(path, "排序键 %q 重复", key) + } + seen[key] = true + } +} + +func validateProfile(l *issueList, path string, prof Profile, seen map[string]bool) { + if strings.TrimSpace(prof.Code) == "" { + l.addf(path+".code", "不能为空") + } else if seen[prof.Code] { + l.addf(path+".code", "模板编码 %q 重复", prof.Code) + } else { + seen[prof.Code] = true + } + if strings.TrimSpace(prof.Name) == "" { + l.addf(path+".name", "不能为空") + } + if prof.AwardRanks < 0 { + l.addf(path+".awardRanks", "不能为负,当前 %d", prof.AwardRanks) + } + if prof.TieMode != domain.TieSkip && prof.TieMode != domain.TieAverage && prof.TieMode != domain.TieHigher { + l.addf(path+".tieMode", "只能是 skip / average / higher,当前 %q", prof.TieMode) + } + if prof.RecordBonusMode != "" && prof.RecordBonusMode != domain.RecordBonusMax && prof.RecordBonusMode != domain.RecordBonusSum { + l.addf(path+".recordBonusMode", "只能是 max / sum,当前 %q", prof.RecordBonusMode) + } + if prof.AwardRanks > 0 && len(prof.Rules) == 0 { + l.addf(path+".rules", "awardRanks=%d 却没有分值明细", prof.AwardRanks) + } + ranks := make(map[int]bool, len(prof.Rules)) + for j, r := range prof.Rules { + rpath := fmt.Sprintf("%s.rules[%d]", path, j) + if r.Rank < 1 { + l.addf(rpath+".rank", "名次必须 >= 1,当前 %d", r.Rank) + } else if ranks[r.Rank] { + l.addf(rpath+".rank", "名次 %d 重复", r.Rank) + } + ranks[r.Rank] = true + if r.Points < 0 { + l.addf(rpath+".points", "分值不能为负,当前 %v", r.Points) + } + } +} + +func validateRecordBonus(l *issueList, rb RecordBonus) { + if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum { + l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode) + } + if rb.Enabled && len(rb.Levels) == 0 { + l.addf("recordBonus.levels", "启用破纪录加分却没有配置档位") + } + seen := make(map[string]bool, len(rb.Levels)) + for i, lv := range rb.Levels { + path := fmt.Sprintf("recordBonus.levels[%d]", i) + if strings.TrimSpace(lv.Code) == "" { + l.addf(path+".code", "不能为空") + } else if seen[lv.Code] { + l.addf(path+".code", "档位编码 %q 重复", lv.Code) + } else { + seen[lv.Code] = true + } + if strings.TrimSpace(lv.Name) == "" { + l.addf(path+".name", "不能为空") + } + if lv.Points < 0 { + l.addf(path+".points", "分值不能为负,当前 %v", lv.Points) + } + } +} + +func validateTemplates(l *issueList, templates []ChannelTemplate, profiles map[string]bool) { + seen := make(map[string]bool, len(templates)) + for i, t := range templates { + path := fmt.Sprintf("channelTemplates[%d]", i) + key := matchKey(t.Match) + if key == "" { + l.addf(path+".match", "至少要指定 eventType / resultType / sportCode 之一") + } else if seen[key] { + l.addf(path+".match", "匹配条件 %s 重复", key) + } + seen[key] = true + if len(t.Channels) == 0 { + l.addf(path+".channels", "模板至少要有一个计分通道") + } + validateChannels(l, path+".channels", t.Channels, profiles) + } +} + +func validateOverrides(l *issueList, overrides []EventOverride, profiles map[string]bool) { + seen := make(map[string]bool, len(overrides)) + for i, o := range overrides { + path := fmt.Sprintf("eventOverrides[%d]", i) + if strings.TrimSpace(o.EventCode) == "" { + l.addf(path+".eventCode", "不能为空") + } else if seen[o.EventCode] { + l.addf(path+".eventCode", "项目编码 %q 重复", o.EventCode) + } else { + seen[o.EventCode] = true + } + if o.Multiplier != nil && *o.Multiplier < 0 { + l.addf(path+".multiplier", "不能为负,当前 %v", *o.Multiplier) + } + if o.Channels != nil { + validateChannels(l, path+".channels", *o.Channels, profiles) + } + } +} + +func validateChannels(l *issueList, path string, channels []Channel, profiles map[string]bool) { + seen := make(map[string]bool, len(channels)) + for i, ch := range channels { + cpath := fmt.Sprintf("%s[%d]", path, i) + if ch.Scope != domain.ScopeAthlete && ch.Scope != domain.ScopeTeam { + l.addf(cpath+".scope", "只能是 athlete / team,当前 %q", ch.Scope) + } + switch ch.Basis { + case domain.BasisResult, domain.BasisManual, domain.BasisAggregate: + default: + l.addf(cpath+".basis", "只能是 result / manual / aggregate,当前 %q", ch.Basis) + } + if strings.TrimSpace(ch.Profile) == "" { + l.addf(cpath+".profile", "不能为空") + } else if !profiles[ch.Profile] { + l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile) + } + switch ch.Aggregate { + case "": + case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount: + if ch.Basis != domain.BasisAggregate { + l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate") + } + default: + l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate) + } + // 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。 + key := string(ch.Scope) + "/" + string(ch.Basis) + if seen[key] { + l.addf(cpath, "同一组配置里出现重复通道 %s", key) + } + seen[key] = true + } +} + +func matchKey(m ChannelMatch) string { + var parts []string + if m.EventType != "" { + parts = append(parts, "eventType="+string(m.EventType)) + } + if m.ResultType != "" { + parts = append(parts, "resultType="+string(m.ResultType)) + } + if m.SportCode != "" { + parts = append(parts, "sportCode="+m.SportCode) + } + return strings.Join(parts, ",") +} + +func matchEvent(m ChannelMatch, e EventInfo) bool { + if m.EventType != "" && m.EventType != e.EventType { + return false + } + if m.ResultType != "" && m.ResultType != e.ResultType { + return false + } + if m.SportCode != "" && m.SportCode != e.SportCode { + return false + } + return true +} diff --git a/server/main.go b/server/main.go index a358389..d0c029f 100644 --- a/server/main.go +++ b/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" + "database/sql" "errors" "flag" "fmt" @@ -10,11 +11,17 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/config" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) @@ -22,7 +29,16 @@ import ( var version = "dev" func main() { + if len(os.Args) > 1 && os.Args[1] == "backup" { + if err := runBackup(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "备份失败: %v\n", err) + os.Exit(1) + } + return + } + configPath := flag.String("config", "config.yaml", "配置文件路径") + project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)") flag.Parse() cfg, err := config.Load(*configPath) @@ -36,20 +52,58 @@ func main() { st, err := store.Open(cfg.DataDir) if err != nil { - logger.Error("打开数据库失败", "err", err) + logger.Error("打开系统库失败", "err", err) os.Exit(1) } defer func() { _ = st.Close() }() if err := st.Migrate(); err != nil { - logger.Error("数据库迁移失败", "err", err) + logger.Error("系统库迁移失败", "err", err) os.Exit(1) } - logger.Info("数据库就绪", "dataDir", cfg.DataDir) + + eventDB, err := openEventDB(cfg.DataDir, *project) + if err != nil { + logger.Error("打开赛事库失败", "err", err) + os.Exit(1) + } + defer func() { _ = eventDB.Close() }() + if err := store.MigrateEvent(eventDB); err != nil { + logger.Error("赛事库迁移失败", "err", err) + os.Exit(1) + } + logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", *project) + + authSvc := auth.New(st.DB(), auth.Options{Logger: logger}) + hub := realtime.NewHub(eventDB, logger) + defer func() { _ = hub.Close() }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if username, password, created, err := authSvc.EnsureDefaultAdmin(ctx); err != nil { + logger.Error("初始化默认管理员失败", "err", err) + os.Exit(1) + } else if created { + // 初始密码只打印这一次;数据库里只有 bcrypt 哈希。 + fmt.Printf("\n========================================\n"+ + " 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+ + "========================================\n\n", username, password) + logger.Warn("已创建默认管理员", "username", username, "password", password) + } srv := &http.Server{ - Addr: cfg.Server.Addr(), - Handler: httpapi.New(httpapi.Options{Logger: logger, Version: version}), + Addr: cfg.Server.Addr(), + Handler: httpapi.New(httpapi.Options{ + Logger: logger, + Version: version, + System: st.DB(), + Event: eventDB, + Auth: authSvc, + DataDir: cfg.DataDir, + Display: displaySender{hub: hub}, + WS: hub.Handler(), + }), ReadHeaderTimeout: 10 * time.Second, } @@ -61,8 +115,6 @@ func main() { } }() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() <-ctx.Done() logger.Info("正在关闭…") @@ -72,3 +124,91 @@ func main() { logger.Error("关闭超时", "err", err) } } + +// displaySender 把 httpapi 的大屏命令接口适配到实时层。 +type displaySender struct{ hub *realtime.Hub } + +func (d displaySender) SendDisplayCommand(action string, payload any) error { + cmd, err := realtime.NewDisplayCommand(action, payload) + if err != nil { + return err + } + d.hub.SendCommand(cmd) + return nil +} + +// runBackup 实现 `scoreboard backup` 子命令:内置 VACUUM INTO,不依赖外部 sqlite3.exe。 +func runBackup(args []string) error { + fs := flag.NewFlagSet("backup", flag.ExitOnError) + configPath := fs.String("config", "config.yaml", "配置文件路径") + project := fs.String("project", "default", "赛事项目名") + reason := fs.String("reason", "manual", "备份原因:manual / publish / auto") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + dir := filepath.Join(cfg.DataDir, "backups") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + sysDB, err := openSQLite(filepath.Join(cfg.DataDir, "system.db")) + if err != nil { + return err + } + defer func() { _ = sysDB.Close() }() + if _, err := export.Backup(sysDB, export.BackupOptions{Dir: dir, Prefix: "system", Reason: *reason}); err != nil { + return err + } + + evPath := filepath.Join(cfg.DataDir, "event_projs", *project, "event.db") + if _, err := os.Stat(evPath); err == nil { + evDB, err := openSQLite(evPath) + if err != nil { + return err + } + defer func() { _ = evDB.Close() }() + if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: *project, Reason: *reason}); err != nil { + return err + } + } + + items, err := export.ListBackups(dir, "") + if err != nil { + return err + } + fmt.Printf("备份完成:%s(现有 %d 份)\n", dir, len(items)) + return nil +} + +// openEventDB 打开(必要时创建)赛事库;store 包不持有赛事库连接, +// 由 main 负责生命周期,再交给 httpapi 与实时层使用。 +func openEventDB(dataDir, project string) (*sql.DB, error) { + dir := filepath.Join(dataDir, "event_projs", project) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("创建赛事目录 %s: %w", dir, err) + } + return openSQLite(filepath.Join(dir, "event.db")) +} + +func openSQLite(path string) (*sql.DB, error) { + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("打开数据库 %s: %w", path, err) + } + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("连接数据库 %s: %w", path, err) + } + return db, nil +} diff --git a/vendor/fonts/README.md b/vendor/fonts/README.md new file mode 100644 index 0000000..d4711f1 --- /dev/null +++ b/vendor/fonts/README.md @@ -0,0 +1,90 @@ +# vendor/fonts —— 本地字体(离线部署,禁止 CDN) + +现场部署无外网,所有中文字体必须随仓库分发、从本目录本地加载。本目录只放**子集化后的 woff2**, +原始大字体(每套 8–12 MB)不入库。 + +## 字体清单 + +| id | 显示名 | font-family | 字重 | 单套合计 | +| --- | --- | --- | --- | --- | +| `noto-sans` | Noto Sans SC | `Noto Sans SC` | 400 / 700 | 2.72 MB | +| `noto-serif` | Noto Serif SC | `Noto Serif SC` | 400 / 700 | 3.71 MB | +| `misans` | MiSans | `MiSans` | 400 / 700 | 1.76 MB | +| `source-han-sans` | 思源黑体 Source Han Sans CN | `Source Han Sans CN` | 400 / 700 | 2.72 MB | + +合计 8 个 woff2、10.91 MB。逐文件体积与 sha256 见 [`manifest.json`](./manifest.json), +每个文件均 ≤ 4 MB / 套的目标(最大为 Noto Serif SC 的 3.71 MB)。 + +## 来源与授权 + +| 字体 | 来源 | 授权 | +| --- | --- | --- | +| Noto Sans SC | Google Noto(`~/Fonts/noto-sans/sc`) | SIL Open Font License 1.1 | +| Noto Serif SC | Google Noto(`~/Fonts/noto-serif/sc/SubsetOTF/SC`) | SIL Open Font License 1.1 | +| MiSans | 小米(`~/Fonts/MiSans/ttf`) | MiSans 字体知识产权许可协议 | +| 思源黑体 Source Han Sans CN | Adobe(`~/Fonts/思源黑体`) | SIL Open Font License 1.1 | + +> **授权说明**:本项目为**内部赛事**使用,不对外公开发布,字体授权不构成障碍 +> (见 `devdocs/06-大屏端设计.md` 9.1、`devdocs/07-管理面板设计.md` 9)。 +> 若将来对外分发,需逐字体复核许可:Noto / 思源黑体为 OFL,可自由分发; +> **MiSans 为厂商许可,公开分发前必须另行确认**。OFL 字体保留其原始版权声明, +> 本目录未附 LICENSE 副本,需要时从 `~/Fonts/<字体目录>/LICENSE` 取回。 + +## 子集化命令 + +依赖 `fonttools` + `brotli`(生成 woff2 必需): + +```bash +pip install fonttools brotli +# 系统 Python 受 PEP 668 保护时,用带系统包的虚拟环境: +python3 -m venv --system-site-packages ~/.tmp/fontenv +~/.tmp/fontenv/bin/pip install brotli +PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh +``` + +脚本会:生成字符集 → 逐字体跑 `fontTools.subset` → 重新生成 `manifest.json`。 + +**子集字符集**:ASCII 可打印字符 + GB2312 全部合法码位(一级 3755 + 二级 3008 汉字, +含全角标点、希腊/俄文、全角数字)+ 常用中文标点与数字符号,共 **7553 字符**。 + +**子集化参数**: + +```bash +python -m fontTools.subset <源字体> \ + --flavor=woff2 \ + --layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk' \ + --name-IDs='*' --name-languages='*' \ + --recommended-glyphs --drop-tables+=DSIG \ + --text="$(字符集)" --output-file=-.woff2 +``` + +去掉 CJK 竖排(`vert`/`vrt2`)等用不到的 OpenType 特性以压体积;保留 `kern`/`liga` 等排版特性。 + +## 如何使用 + +`web/src/shared/styles/fonts.css` 为每套字体写好 `@font-face`(`font-display: swap`) +与 `--font-` 变量。在全局样式入口加一行即可生效: + +```css +@import './fonts.css'; +``` + +CSS 里的 `url()` 是相对 `web/src/shared/styles/` 的 `../../../../vendor/fonts/...`。 +Vite 构建时会把这些 woff2 作为资源打进产物(输出到 `server/internal/webui/assets/`), +运行时不再依赖仓库外的路径。 + +## 如何新增字体 + +1. 把源字体(`.otf`/`.ttf`)放到 `~/Fonts/<目录>/`(不要直接复制进仓库)。 +2. 在 `tools/fonts.conf` 追加一行:`id|显示名|font-family|分类|授权|来源说明|Regular 源文件|Bold 源文件`。 +3. 重跑 `PYTHON=... vendor/fonts/tools/subset-fonts.sh`,它会生成 `-regular.woff2`、 + `-bold.woff2` 并更新 `manifest.json`。 +4. 在 `web/src/shared/styles/fonts.css` 补对应 `@font-face` 和 `--font-` 变量。 +5. 检查单套合计 ≤ 4 MB;超了就精简字符集或换更轻的字重。 + +## 已知限制 + +- MiSans 缺 `・`(U+30FB,日文片假名中点,GB2312 内含),其余 7552 个字符齐全; + 中文场景无影响。其余三套字符集全覆盖。 +- 只做 Regular + Bold 两档;如需 Medium/Light,按上节流程加字重(需扩展 `fonts.conf` 的字段格式)。 +- 字体文件已子集化,**不要**用原始 `~/Fonts` 大字体覆盖本目录文件。 diff --git a/vendor/fonts/manifest.json b/vendor/fonts/manifest.json new file mode 100644 index 0000000..27c2b6e --- /dev/null +++ b/vendor/fonts/manifest.json @@ -0,0 +1,139 @@ +{ + "version": 1, + "generatedAt": "2026-09-09", + "generator": "vendor/fonts/tools/subset-fonts.sh", + "subset": { + "charset": "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号", + "charCount": 7553, + "format": "woff2", + "layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk" + }, + "totalBytes": 11434920, + "totalSize": "10.91 MB", + "fonts": [ + { + "id": "noto-sans", + "displayName": "Noto Sans SC", + "fontFamily": "Noto Sans SC", + "cssVar": "--font-noto-sans", + "category": "sans-serif", + "license": "OFL-1.1", + "source": "Google Noto Sans SC (noto-sans/sc)", + "totalBytes": 2849388, + "totalSize": "2.72 MB", + "weights": [ + { + "weight": 400, + "style": "normal", + "file": "noto-sans-regular.woff2", + "path": "vendor/fonts/noto-sans-regular.woff2", + "bytes": 1399176, + "size": "1.33 MB", + "sha256": "a126c29405324acddf2cf3d47ba76762f1fa39cd7b1c0be6dc98f4fd26cfafb8" + }, + { + "weight": 700, + "style": "normal", + "file": "noto-sans-bold.woff2", + "path": "vendor/fonts/noto-sans-bold.woff2", + "bytes": 1450212, + "size": "1.38 MB", + "sha256": "206e958255a9e961243fbbd44c52ba05fb166e452de315fa3967e3c78bb8d499" + } + ] + }, + { + "id": "noto-serif", + "displayName": "Noto Serif SC", + "fontFamily": "Noto Serif SC", + "cssVar": "--font-noto-serif", + "category": "serif", + "license": "OFL-1.1", + "source": "Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)", + "totalBytes": 3887184, + "totalSize": "3.71 MB", + "weights": [ + { + "weight": 400, + "style": "normal", + "file": "noto-serif-regular.woff2", + "path": "vendor/fonts/noto-serif-regular.woff2", + "bytes": 1892872, + "size": "1.81 MB", + "sha256": "666a88ddc3f50f8ae999991d9ebd5eeb58ad267f6f9b38b75a580599e0fe72c2" + }, + { + "weight": 700, + "style": "normal", + "file": "noto-serif-bold.woff2", + "path": "vendor/fonts/noto-serif-bold.woff2", + "bytes": 1994312, + "size": "1.90 MB", + "sha256": "7a8fc67787af8d4a8ff88b63f672b888ff29746ad08af0c4b4b31273d15ffce3" + } + ] + }, + { + "id": "misans", + "displayName": "MiSans", + "fontFamily": "MiSans", + "cssVar": "--font-misans", + "category": "sans-serif", + "license": "MiSans Font License", + "source": "小米 MiSans (MiSans/ttf)", + "totalBytes": 1841888, + "totalSize": "1.76 MB", + "weights": [ + { + "weight": 400, + "style": "normal", + "file": "misans-regular.woff2", + "path": "vendor/fonts/misans-regular.woff2", + "bytes": 910956, + "size": "890 KB", + "sha256": "ca7c73c33dde913833012f16334e59d8f6a3cdea856e537a52ff27b95ab01493" + }, + { + "weight": 700, + "style": "normal", + "file": "misans-bold.woff2", + "path": "vendor/fonts/misans-bold.woff2", + "bytes": 930932, + "size": "909 KB", + "sha256": "18119cd03ed6931c339d8f7cfc4bcf2c7258ee6a9e2191b43d75dcc26ef64f41" + } + ] + }, + { + "id": "source-han-sans", + "displayName": "思源黑体 Source Han Sans CN", + "fontFamily": "Source Han Sans CN", + "cssVar": "--font-source-han-sans", + "category": "sans-serif", + "license": "OFL-1.1", + "source": "Adobe Source Han Sans CN (思源黑体)", + "totalBytes": 2856460, + "totalSize": "2.72 MB", + "weights": [ + { + "weight": 400, + "style": "normal", + "file": "source-han-sans-regular.woff2", + "path": "vendor/fonts/source-han-sans-regular.woff2", + "bytes": 1399540, + "size": "1.33 MB", + "sha256": "f85482af4eb07d66df271e475f00484f764326c1cb4fb9b65c90589bc4588875" + }, + { + "weight": 700, + "style": "normal", + "file": "source-han-sans-bold.woff2", + "path": "vendor/fonts/source-han-sans-bold.woff2", + "bytes": 1456920, + "size": "1.39 MB", + "sha256": "3e2e3f54161b8a96c59001d40855835539ef97f6c80c58cdb93fcc4f2baae4dd" + } + ] + } + ] +} diff --git a/vendor/fonts/misans-bold.woff2 b/vendor/fonts/misans-bold.woff2 new file mode 100644 index 0000000..1f6a9f3 Binary files /dev/null and b/vendor/fonts/misans-bold.woff2 differ diff --git a/vendor/fonts/misans-regular.woff2 b/vendor/fonts/misans-regular.woff2 new file mode 100644 index 0000000..fb49c1c Binary files /dev/null and b/vendor/fonts/misans-regular.woff2 differ diff --git a/vendor/fonts/noto-sans-bold.woff2 b/vendor/fonts/noto-sans-bold.woff2 new file mode 100644 index 0000000..39cf551 Binary files /dev/null and b/vendor/fonts/noto-sans-bold.woff2 differ diff --git a/vendor/fonts/noto-sans-regular.woff2 b/vendor/fonts/noto-sans-regular.woff2 new file mode 100644 index 0000000..014c695 Binary files /dev/null and b/vendor/fonts/noto-sans-regular.woff2 differ diff --git a/vendor/fonts/noto-serif-bold.woff2 b/vendor/fonts/noto-serif-bold.woff2 new file mode 100644 index 0000000..7513456 Binary files /dev/null and b/vendor/fonts/noto-serif-bold.woff2 differ diff --git a/vendor/fonts/noto-serif-regular.woff2 b/vendor/fonts/noto-serif-regular.woff2 new file mode 100644 index 0000000..eccdec7 Binary files /dev/null and b/vendor/fonts/noto-serif-regular.woff2 differ diff --git a/vendor/fonts/source-han-sans-bold.woff2 b/vendor/fonts/source-han-sans-bold.woff2 new file mode 100644 index 0000000..3aa7af4 Binary files /dev/null and b/vendor/fonts/source-han-sans-bold.woff2 differ diff --git a/vendor/fonts/source-han-sans-regular.woff2 b/vendor/fonts/source-han-sans-regular.woff2 new file mode 100644 index 0000000..0c8e0e1 Binary files /dev/null and b/vendor/fonts/source-han-sans-regular.woff2 differ diff --git a/vendor/fonts/tools/fonts.conf b/vendor/fonts/tools/fonts.conf new file mode 100644 index 0000000..77c7dad --- /dev/null +++ b/vendor/fonts/tools/fonts.conf @@ -0,0 +1,12 @@ +# 字体清单(subset-fonts.sh 与 gen-manifest.py 的唯一数据源) +# +# 字段(| 分隔): +# id | 显示名 | font-family | 分类 | 授权 | 来源说明 | Regular 源文件 | Bold 源文件 +# +# 源文件相对 $FONT_SRC(默认 ~/Fonts)。 +# 新增字体:在下面加一行,然后重跑 +# PYTHON=<带 fonttools+brotli 的 python> vendor/fonts/tools/subset-fonts.sh +noto-sans|Noto Sans SC|Noto Sans SC|sans-serif|OFL-1.1|Google Noto Sans SC (noto-sans/sc)|noto-sans/sc/NotoSansSC-Regular.otf|noto-sans/sc/NotoSansSC-Bold.otf +noto-serif|Noto Serif SC|Noto Serif SC|serif|OFL-1.1|Google Noto Serif SC (noto-serif/sc/SubsetOTF/SC)|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Regular.otf|noto-serif/sc/SubsetOTF/SC/NotoSerifSC-Bold.otf +misans|MiSans|MiSans|sans-serif|MiSans Font License|小米 MiSans (MiSans/ttf)|MiSans/ttf/MiSans-Regular.ttf|MiSans/ttf/MiSans-Bold.ttf +source-han-sans|思源黑体 Source Han Sans CN|Source Han Sans CN|sans-serif|OFL-1.1|Adobe Source Han Sans CN (思源黑体)|思源黑体/SourceHanSansCN-Regular.otf|思源黑体/SourceHanSansCN-Bold.otf diff --git a/vendor/fonts/tools/gen-manifest.py b/vendor/fonts/tools/gen-manifest.py new file mode 100644 index 0000000..12c0f3e --- /dev/null +++ b/vendor/fonts/tools/gen-manifest.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""生成 vendor/fonts/manifest.json。 + +数据源:vendor/fonts/tools/fonts.conf(字体元数据)+ vendor/fonts/*.woff2(实际产物)。 +每次跑完 subset-fonts.sh 会自动调用;也可单独运行: + python3 vendor/fonts/tools/gen-manifest.py +""" +from __future__ import annotations + +import hashlib +import json +import subprocess +import sys +from datetime import date +from pathlib import Path + +HERE = Path(__file__).resolve().parent +OUT_DIR = HERE.parent +ROOT = OUT_DIR.parent.parent # 仓库根,用于生成 vendor/fonts/... 形式的相对路径 +CONF = HERE / "fonts.conf" + +WEIGHTS = (("regular", 400), ("bold", 700)) +SUBSET_DESC = "ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号" + + +def charset_count() -> int: + """复用 subset-fonts.sh 的字符集生成逻辑,保证清单与产物一致。""" + code = ( + "chars=set()\n" + "for c in range(0x20,0x7F): chars.add(chr(c))\n" + "for hi in range(0xA1,0xFF):\n" + " for lo in range(0xA1,0xFF):\n" + " try: chars.add(bytes((hi,lo)).decode('gb2312'))\n" + " except UnicodeDecodeError: pass\n" + "chars.update(' ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇')\n" + "chars.update('「」『』【】〔〕〖〗《》〈〉')\n" + "chars.update('0123456789')\n" + "chars.update('!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~')\n" + "print(len(chars))\n" + ) + return int(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout) + + +def human(n: int) -> str: + return f"{n / 1024 / 1024:.2f} MB" if n >= 1024 * 1024 else f"{n / 1024:.0f} KB" + + +def read_conf() -> list[dict[str, str]]: + rows = [] + for line in CONF.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + fid, name, family, category, license_, source, reg, bold = (p.strip() for p in line.split("|")) + rows.append( + { + "id": fid, + "displayName": name, + "fontFamily": family, + "category": category, + "license": license_, + "source": source, + "regular": reg, + "bold": bold, + } + ) + return rows + + +def main() -> int: + fonts = [] + grand_total = 0 + for row in read_conf(): + weights = [] + font_total = 0 + for weight_name, weight_class in WEIGHTS: + path = OUT_DIR / f"{row['id']}-{weight_name}.woff2" + if not path.is_file(): + print(f"缺少产物: {path},请先运行 subset-fonts.sh", file=sys.stderr) + return 1 + data = path.read_bytes() + size = len(data) + font_total += size + weights.append( + { + "weight": weight_class, + "style": "normal", + "file": path.name, + "path": str(path.relative_to(ROOT)), + "bytes": size, + "size": human(size), + "sha256": hashlib.sha256(data).hexdigest(), + } + ) + grand_total += font_total + fonts.append( + { + "id": row["id"], + "displayName": row["displayName"], + "fontFamily": row["fontFamily"], + "cssVar": f"--font-{row['id']}", + "category": row["category"], + "license": row["license"], + "source": row["source"], + "totalBytes": font_total, + "totalSize": human(font_total), + "weights": weights, + } + ) + + manifest = { + "version": 1, + "generatedAt": date.today().isoformat(), + "generator": "vendor/fonts/tools/subset-fonts.sh", + "subset": { + "charset": SUBSET_DESC, + "charCount": charset_count(), + "format": "woff2", + "layoutFeatures": "kern,liga,clig,calt,ccmp,locl,mark,mkmk", + }, + "totalBytes": grand_total, + "totalSize": human(grand_total), + "fonts": fonts, + } + out = OUT_DIR / "manifest.json" + out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"manifest.json: {len(fonts)} 套字体,{len(fonts) * 2} 个文件,合计 {human(grand_total)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/fonts/tools/normalize-names.py b/vendor/fonts/tools/normalize-names.py new file mode 100644 index 0000000..1c63740 --- /dev/null +++ b/vendor/fonts/tools/normalize-names.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""把子集字体内部的 name 表 / OS2 字重统一成 CSS 期望的规范值。 + +为什么需要:思源黑体的老版本 OTF 把每个字重写成独立族名 +(`Source Han Sans CN Regular` / `Source Han Sans CN Bold`), +MiSans 的 usWeightClass 是 330/630 这类非标准值。 +@font-face 的 `font-family` 描述符虽然能屏蔽内部族名,但保持一致更省心, +也避免字体被单独安装/引用时族名对不上。 + +用法(由 subset-fonts.sh 调用): + python3 vendor/fonts/tools/normalize-names.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +from fontTools.ttLib import TTFont + +HERE = Path(__file__).resolve().parent +OUT_DIR = HERE.parent +CONF = HERE / "fonts.conf" + +WEIGHTS = (("regular", "Regular", 400), ("bold", "Bold", 700)) +STALE_NAME_IDS = (1, 2, 3, 4, 6, 16, 17, 21, 22) +PLATFORMS = ((3, 1, 0x409), (1, 0, 0)) # Windows / Mac,英文 + + +def set_name(name, name_id: int, value: str) -> None: + for platform_id, plat_enc_id, lang_id in PLATFORMS: + name.setName(value, name_id, platform_id, plat_enc_id, lang_id) + + +def normalize(path: Path, family: str, style: str, weight: int) -> None: + font = TTFont(path) + name = font["name"] + for record in list(name.names): + if record.nameID in STALE_NAME_IDS: + name.removeNames(record.nameID, record.platformID, record.platEncID, record.langID) + + full_name = family if style == "Regular" else f"{family} {style}" + set_name(name, 1, family) # family + set_name(name, 2, style) # subfamily + set_name(name, 3, f"{full_name}:subset") # unique id + set_name(name, 4, full_name) # full name + set_name(name, 6, full_name.replace(" ", "")) # postscript name + set_name(name, 16, family) # typographic family + set_name(name, 17, style) # typographic subfamily + + os2 = font["OS/2"] + os2.usWeightClass = weight + if weight >= 700: + os2.fsSelection = (os2.fsSelection & ~0x40) | 0x20 # BOLD + font["head"].macStyle |= 0x01 + else: + os2.fsSelection = (os2.fsSelection & ~0x20) | 0x40 # REGULAR + font["head"].macStyle &= ~0x01 + + font.flavor = "woff2" + font.save(path) + font.close() + + +def main() -> int: + for line in CONF.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + fid, _, family = [p.strip() for p in line.split("|")][:3] + for weight_name, style, weight_class in WEIGHTS: + path = OUT_DIR / f"{fid}-{weight_name}.woff2" + if not path.is_file(): + print(f"缺少产物: {path}", file=sys.stderr) + return 1 + normalize(path, family, style, weight_class) + print(f" {path.name:32s} -> {family} / {style} ({weight_class})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/fonts/tools/subset-fonts.sh b/vendor/fonts/tools/subset-fonts.sh new file mode 100755 index 0000000..e9a689c --- /dev/null +++ b/vendor/fonts/tools/subset-fonts.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# 字体子集化脚本 —— 把 ~/Fonts 下的中文字体裁剪成 woff2 并写入 vendor/fonts/ +# +# 用途:离线部署,字体必须随仓库分发,禁止 CDN。 +# 产物:vendor/fonts/-.woff2 + vendor/fonts/manifest.json +# +# 依赖:python3 + fonttools + brotli +# pip install fonttools brotli +# # 系统 Python 受 PEP 668 保护时用虚拟环境: +# # python3 -m venv --system-site-packages ~/.tmp/fontenv +# # ~/.tmp/fontenv/bin/pip install brotli +# # PYTHON=~/.tmp/fontenv/bin/python vendor/fonts/tools/subset-fonts.sh +# +# 用法: +# vendor/fonts/tools/subset-fonts.sh +# FONT_SRC=~/Fonts PYTHON=python3 vendor/fonts/tools/subset-fonts.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +FONT_SRC="${FONT_SRC:-$HOME/Fonts}" +PYTHON="${PYTHON:-python3}" +FONTS_CONF="$SCRIPT_DIR/fonts.conf" + +# 子集字符集:ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字/全角符号 +CHARSET="$("$PYTHON" - <<'PY' +import sys + +chars = set() +# 1) ASCII 可打印字符 +for c in range(0x20, 0x7F): + chars.add(chr(c)) +# 2) GB2312 全部合法双字节码位(一级 3755 + 二级 3008 汉字,及全角标点/希腊/俄文等) +for hi in range(0xA1, 0xFF): + for lo in range(0xA1, 0xFF): + try: + chars.add(bytes((hi, lo)).decode("gb2312")) + except UnicodeDecodeError: + pass +# 3) 常用中文标点 / 数字符号补充 +chars.update( + " ·—…‘’“”※→←↑↓√×÷±≈≠≤≥∞°℃℉¥€£©®™№§¶†‡•‰′″〇" + "「」『』【】〔〕〖〗《》〈〉" + "0123456789" + "!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" +) +sys.stdout.write("".join(sorted(chars))) +PY +)" +CHAR_COUNT="$(printf '%s' "$CHARSET" | "$PYTHON" -c 'import sys; print(len(sys.stdin.read()))')" + +# 子集化参数: +# --flavor=woff2 输出 woff2(需 brotli) +# --layout-features 保留字距/连字等,丢弃 CJK 竖排等无用特性以压体积 +# --name-IDs='*' 保留完整 name 表(含本地化族名) +# --recommended-glyphs 保留 .notdef/.null 等推荐字形 +# --drop-tables+=DSIG 丢弃无用的数字签名表 +SUBSET_OPTS=( + --flavor=woff2 + --layout-features='kern,liga,clig,calt,ccmp,locl,mark,mkmk' + --name-IDs='*' + --name-languages='*' + --recommended-glyphs + --drop-tables+=DSIG +) + +mkdir -p "$OUT_DIR" +echo "charset: ${CHAR_COUNT} 个字符(ASCII + GB2312 一级/二级 + 中文标点 + 数字符号)" + +while IFS='|' read -r id name family category license source_note reg_src bold_src; do + case "$id" in ''|'#'*) continue ;; esac + for pair in "regular:400:$reg_src" "bold:700:$bold_src"; do + IFS=':' read -r weight_name weight_class src_rel <<<"$pair" + src="$FONT_SRC/$src_rel" + out="$OUT_DIR/$id-$weight_name.woff2" + [ -f "$src" ] || { echo "缺少源字体: $src" >&2; exit 1; } + printf ' %-26s %s -> %s\n' "$id-$weight_name" "$(basename "$src")" "$(basename "$out")" + "$PYTHON" -m fontTools.subset "$src" "${SUBSET_OPTS[@]}" \ + --text="$CHARSET" --output-file="$out" + done +done < "$FONTS_CONF" + +"$PYTHON" "$SCRIPT_DIR/normalize-names.py" +"$PYTHON" "$SCRIPT_DIR/gen-manifest.py" + +echo "完成。产物目录:$OUT_DIR" +ls -la "$OUT_DIR"/*.woff2 diff --git a/vendor/miuix-console/components/accordion.css b/vendor/miuix-console/components/accordion.css new file mode 100644 index 0000000..c70ae11 --- /dev/null +++ b/vendor/miuix-console/components/accordion.css @@ -0,0 +1,81 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Accordion / Collapse Component + ═══════════════════════════════════════════ */ + +.mx-accordion{ + display:flex;flex-direction:column; + border:1px solid var(--mx-divider); + border-radius:var(--mx-r); + overflow:hidden; +} + +/* Item */ +.mx-accordion-item{ + border-bottom:1px solid var(--mx-divider); +} +.mx-accordion-item:last-child{border-bottom:none} + +/* Header / Trigger */ +.mx-accordion-header{ + display:flex;align-items:center;gap:10px; + padding:14px 16px; + cursor:pointer; + font-size:14px;font-weight:600; + color:var(--mx-text); + transition:all var(--mx-d1) var(--mx-ease); + -webkit-tap-highlight-color:transparent; +} +.mx-accordion-header:hover{background:var(--mx-s-hover)} +.mx-accordion-header:active{background:var(--mx-s-active)} + +/* Icon slot */ +.mx-accordion-header svg{flex-shrink:0;color:var(--mx-t2)} +.mx-accordion-header .mx-accordion-title{flex:1} + +/* Arrow */ +.mx-accordion-arrow{ + color:var(--mx-t3);flex-shrink:0; + transition:transform var(--mx-d2) var(--mx-ease); +} +.mx-accordion-item.open .mx-accordion-arrow{transform:rotate(180deg)} + +/* Content */ +.mx-accordion-content{ + max-height:0;overflow:hidden; + transition:max-height var(--mx-d3) var(--mx-ease); +} +.mx-accordion-item.open .mx-accordion-content{ + max-height:500px; +} +.mx-accordion-body{ + padding:0 16px 16px; + font-size:13px;line-height:1.6; + color:var(--mx-text-on-surface); +} + +/* Card variant — separate cards */ +.mx-accordion-card{border:none;gap:8px;background:none} +.mx-accordion-card .mx-accordion-item{ + background:var(--mx-s2); + border:1px solid var(--mx-divider); + border-radius:var(--mx-r); + overflow:hidden; +} +.mx-accordion-card .mx-accordion-item:last-child{border-bottom:1px solid var(--mx-divider)} + +/* Flush variant — no borders */ +.mx-accordion-flush{border:none;border-radius:0} +.mx-accordion-flush .mx-accordion-item{border:none} +.mx-accordion-flush .mx-accordion-header{padding:12px 0} + +/* Disabled */ +.mx-accordion-header.disabled{ + opacity:0.4;cursor:not-allowed;pointer-events:none; +} + +/* Mobile */ +@media(max-width:767px){ + .mx-accordion-header{padding:14px 14px;min-height:48px} + .mx-accordion-body{font-size:14px} + .mx-accordion-card .mx-accordion-item{border-radius:var(--mx-r-sm)} +} diff --git a/vendor/miuix-console/components/activity.css b/vendor/miuix-console/components/activity.css new file mode 100644 index 0000000..46433d5 --- /dev/null +++ b/vendor/miuix-console/components/activity.css @@ -0,0 +1,53 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Activity List Component + ═══════════════════════════════════════════ */ + +.mx-activity-item{ + display:flex;align-items:flex-start;gap:12px; + padding:13px 0; + border-bottom:1px solid var(--mx-divider); + transition:all var(--mx-d1); +} +.mx-activity-item:last-child{border-bottom:none} +.mx-activity-item:active{ + background:var(--mx-s-active); + margin:0 -16px;padding:13px 16px;border-radius:var(--mx-r-sm); +} + +.mx-activity-dot{ + width:8px;height:8px;border-radius:50%; + margin-top:6px;flex-shrink:0; + transition:transform var(--mx-d2) var(--mx-spring); +} +.mx-activity-item:hover .mx-activity-dot{transform:scale(1.5)} + +.mx-activity-text{font-size:13px;line-height:1.5} +.mx-activity-text b{font-weight:600;color:var(--mx-text)} +.mx-activity-time{font-size:11px;color:var(--mx-t3);margin-top:3px} + +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Chart Bar Component + ═══════════════════════════════════════════ */ + +.mx-chart{display:flex;align-items:flex-end;gap:5px;height:160px} +.mx-chart-bar-col{ + flex:1;display:flex;flex-direction:column; + align-items:center;gap:6px;min-width:0; +} +.mx-chart-bar{ + width:100%;border-radius:7px 7px 3px 3px; + background:var(--mx-primary); + transform-origin:bottom; + transition:filter var(--mx-d1),transform var(--mx-d1); +} +.mx-chart-bar.alt{background:var(--mx-s4)} +.mx-chart-bar:active{filter:brightness(1.2);transform:scaleY(1.04)} + +.mx-chart-label{ + font-size:10px;color:var(--mx-t3); + font-variant-numeric:tabular-nums; +} + +/* Responsive chart height */ +@media(min-width:768px){.mx-chart{height:180px}} +@media(min-width:1024px){.mx-chart{height:200px}} diff --git a/vendor/miuix-console/components/avatar.css b/vendor/miuix-console/components/avatar.css new file mode 100644 index 0000000..76e2860 --- /dev/null +++ b/vendor/miuix-console/components/avatar.css @@ -0,0 +1,70 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Avatar Component + ═══════════════════════════════════════════ */ + +/* Base avatar (extend existing .mx-avatar) */ +.mx-avatar-img{ + width:32px;height:32px;border-radius:50%; + object-fit:cover;flex-shrink:0; + border:2px solid var(--mx-s2); +} + +/* Sizes */ +.mx-avatar-xs{width:24px;height:24px;font-size:10px} +.mx-avatar-sm{width:28px;height:28px;font-size:11px} +.mx-avatar-md{width:36px;height:36px;font-size:14px} +.mx-avatar-lg{width:44px;height:44px;font-size:16px} +.mx-avatar-xl{width:56px;height:56px;font-size:20px} + +/* Avatar Group */ +.mx-avatar-group{ + display:flex; +} +.mx-avatar-group .mx-avatar, +.mx-avatar-group .mx-avatar-img{ + margin-left:-8px; + border:2px solid var(--mx-s2); + transition:transform var(--mx-d2) var(--mx-spring); +} +.mx-avatar-group .mx-avatar:first-child, +.mx-avatar-group .mx-avatar-img:first-child{margin-left:0} +.mx-avatar-group .mx-avatar:hover, +.mx-avatar-group .mx-avatar-img:hover{ + transform:translateY(-2px); + z-index:1; +} + +/* Overflow counter */ +.mx-avatar-overflow{ + width:32px;height:32px; + border-radius:50%; + background:var(--mx-s3); + border:2px solid var(--mx-s2); + display:flex;align-items:center;justify-content:center; + font-size:11px;font-weight:700; + color:var(--mx-t2); + margin-left:-8px; +} + +/* Status dot */ +.mx-avatar-status{ + position:relative; +} +.mx-avatar-status::after{ + content:'';position:absolute; + bottom:0;right:0; + width:10px;height:10px; + border-radius:50%; + border:2px solid var(--mx-s2); +} +.mx-avatar-status.online::after{background:var(--mx-green)} +.mx-avatar-status.busy::after{background:var(--mx-red)} +.mx-avatar-status.away::after{background:var(--mx-amber)} +.mx-avatar-status.offline::after{background:var(--mx-t3)} + +/* Mobile */ +@media(max-width:767px){ + .mx-avatar-group .mx-avatar, + .mx-avatar-group .mx-avatar-img{margin-left:-10px} + .mx-avatar-overflow{width:28px;height:28px;font-size:10px} +} diff --git a/vendor/miuix-console/components/badge.css b/vendor/miuix-console/components/badge.css new file mode 100644 index 0000000..3b0b1d8 --- /dev/null +++ b/vendor/miuix-console/components/badge.css @@ -0,0 +1,61 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Badge & Tag Component + Miuix capsule shape badges + ═══════════════════════════════════════════ */ + +.mx-badge{ + display:inline-flex;align-items:center;gap:5px; + padding:3px 10px; + border-radius:var(--mx-r-pill); + font-size:12px;font-weight:600; + transition:filter var(--mx-d1); +} +.mx-badge:hover{filter:brightness(1.15)} + +.mx-badge::before{ + content:'';width:6px;height:6px; + border-radius:50%;flex-shrink:0; +} + +/* Variants */ +.mx-badge-success{background:rgba(52,211,153,0.1);color:var(--mx-green)} +.mx-badge-success::before{background:var(--mx-green);animation:mx-pulse 2s infinite} + +.mx-badge-warning{background:rgba(251,191,36,0.1);color:var(--mx-amber)} +.mx-badge-warning::before{background:var(--mx-amber)} + +.mx-badge-error{background:rgba(248,113,113,0.1);color:var(--mx-red)} +.mx-badge-error::before{background:var(--mx-red);animation:mx-pulse 1.5s infinite} + +.mx-badge-info{background:rgba(96,165,250,0.1);color:var(--mx-blue)} +.mx-badge-info::before{background:var(--mx-blue)} + +.mx-badge-neutral{background:rgba(139,149,165,0.1);color:var(--mx-t2)} +.mx-badge-neutral::before{background:var(--mx-t2)} + +/* Dot-only badge */ +.mx-dot{ + width:8px;height:8px; + border-radius:50%; + flex-shrink:0; +} + +/* Count badge */ +.mx-count{ + display:inline-flex;align-items:center;justify-content:center; + min-width:18px;height:18px;padding:0 5px; + border-radius:var(--mx-r-pill); + font-size:10px;font-weight:700; + background:var(--mx-primary);color:#fff; +} + +@keyframes mx-pulse{ + 0%,100%{opacity:1;transform:scale(1)} + 50%{opacity:0.5;transform:scale(1.3)} +} + +/* Mobile */ +@media(max-width:767px){ + .mx-badge{padding:4px 12px;font-size:13px} + .mx-count{min-width:20px;height:20px;font-size:11px} +} diff --git a/vendor/miuix-console/components/button.css b/vendor/miuix-console/components/button.css new file mode 100644 index 0000000..dfedb51 --- /dev/null +++ b/vendor/miuix-console/components/button.css @@ -0,0 +1,89 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Button Component + ═══════════════════════════════════════════ */ + +.mx-btn{ + display:inline-flex;align-items:center;justify-content:center;gap:6px; + height:36px;padding:0 18px; + border-radius:var(--mx-r-sm); + font-size:13px;font-weight:600; + cursor:pointer; + transition:all var(--mx-d1) var(--mx-ease); + -webkit-tap-highlight-color:transparent; + position:relative;overflow:hidden; + white-space:nowrap; +} +.mx-btn:active{transform:scale(0.95);box-shadow:inset 0 2px 4px rgba(0,0,0,0.2)} + +/* Primary */ +.mx-btn-primary{ + background:var(--mx-primary);color:#fff; + box-shadow:0 2px 8px rgba(76,139,245,0.2); +} +.mx-btn-primary:hover{ + background:var(--mx-primary-hover); + box-shadow:0 4px 14px rgba(76,139,245,0.3); + transform:translateY(-1px); +} +.mx-btn-primary:active{transform:scale(0.95) translateY(0)} + +/* Secondary */ +.mx-btn-secondary{ + background:var(--mx-s3);color:var(--mx-text); + border:1px solid var(--mx-border); +} +.mx-btn-secondary:hover{ + background:var(--mx-s4); + border-color:var(--mx-t3); +} + +/* Ghost */ +.mx-btn-ghost{ + background:transparent;color:var(--mx-t2); +} +.mx-btn-ghost:hover{ + background:var(--mx-s-hover);color:var(--mx-text); +} + +/* Danger */ +.mx-btn-danger{ + background:var(--mx-red);color:#fff; +} +.mx-btn-danger:hover{ + filter:brightness(1.1); + box-shadow:0 4px 14px rgba(248,113,113,0.25); +} + +/* Pill — Miuix style capsule button */ +.mx-btn-pill{ + border-radius:var(--mx-r-pill); + height:32px;padding:0 14px; + font-size:12px; +} + +/* Icon button */ +.mx-btn-icon{ + width:36px;height:36px;padding:0; + border-radius:var(--mx-r-sm); +} + +/* Sizes */ +.mx-btn-sm{height:30px;padding:0 12px;font-size:12px;border-radius:var(--mx-r-xs)} +.mx-btn-lg{height:42px;padding:0 22px;font-size:14px} + +/* Disabled */ +.mx-btn[disabled]{ + opacity:0.4;cursor:not-allowed;pointer-events:none; +} + +/* Mobile: 加大触控区 */ +/* ═══ DARK MODE — button pressed inner shadow ═══ */ +[data-theme="dark"] .mx-btn:active{box-shadow:inset 0 2px 4px rgba(0,0,0,0.3)} + +@media(max-width:767px){ + .mx-btn{height:40px;padding:0 20px;font-size:14px} + .mx-btn-sm{height:36px;padding:0 14px;font-size:13px} + .mx-btn-lg{height:46px;padding:0 24px;font-size:15px} + .mx-btn-icon{width:40px;height:40px} + .mx-btn-pill{height:34px;padding:0 16px;font-size:13px} +} diff --git a/vendor/miuix-console/components/card.css b/vendor/miuix-console/components/card.css new file mode 100644 index 0000000..f283386 --- /dev/null +++ b/vendor/miuix-console/components/card.css @@ -0,0 +1,67 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Card Component + 对应 Miuix Card.kt + CornerRadius = 16.dp, surfaceContainer 背景 + ═══════════════════════════════════════════ */ + +.mx-card{ + background:var(--mx-s2); + border-radius:var(--mx-r); + overflow:hidden; + box-shadow:var(--mx-sh1); + transition:box-shadow var(--mx-d3) var(--mx-ease); +} +.mx-card:hover{ + box-shadow:var(--mx-sh2); + transition-duration:var(--mx-d2); +} + +.mx-card-head{ + display:flex;align-items:center;justify-content:space-between; + padding:12px 16px; + border-bottom:1px solid var(--mx-divider); +} +.mx-card-title{font-size:15px;font-weight:700} + +.mx-card-body{padding:16px} +.mx-card-body > * + * { margin-top: 12px; } +.mx-card-body > .mx-table-wrap { margin-top: 0; } +.mx-card-body > .mx-chart { margin-top: 0; } + +/* 可点击卡片 — Miuix BasicCard with press feedback */ +.mx-card-clickable{ + cursor:pointer; + transition:box-shadow var(--mx-d3) var(--mx-ease), + transform var(--mx-d3) var(--mx-ease); +} +.mx-card-clickable:hover{ + transition-duration:var(--mx-d2); +} +.mx-card-clickable:active{transform:scale(0.98)} + +/* Mobile: 卡片内边距调整 */ +@media(max-width:767px){ + .mx-card-body{padding:14px} + .mx-card-head{padding:12px 14px} +} + +/* Miuix: 顶部 accent 线 */ +.mx-card-accent{ + position:relative;overflow:hidden; +} +.mx-card-accent::before{ + content:'';position:absolute;top:0;left:0;right:0;height:2px; + background:var(--mx-card-accent,var(--mx-primary)); + opacity:0;transition:opacity var(--mx-d2); +} +.mx-card-accent:hover::before{opacity:1} + +/* Miuix: 右上角柔光晕 */ +.mx-card-accent::after{ + content:'';position:absolute;top:-40%;right:-20%; + width:100px;height:100px; + background:var(--mx-card-accent,var(--mx-primary)); + border-radius:50%;opacity:0.04; + transition:opacity var(--mx-d2); +} +.mx-card-accent:hover::after{opacity:0.08} diff --git a/vendor/miuix-console/components/checkbox.css b/vendor/miuix-console/components/checkbox.css new file mode 100644 index 0000000..8d6c08b --- /dev/null +++ b/vendor/miuix-console/components/checkbox.css @@ -0,0 +1,139 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Checkbox & Radio Component + ═══════════════════════════════════════════ */ + +/* ── Checkbox ── */ +.mx-checkbox{ + display:inline-flex;align-items:center;gap:8px; + cursor:pointer; + -webkit-tap-highlight-color:transparent; +} +.mx-checkbox input{display:none} + +.mx-checkbox-box{ + width:18px;height:18px; + border:2px solid var(--mx-border); + border-radius:5px; + display:flex;align-items:center;justify-content:center; + flex-shrink:0; + transition:all var(--mx-d2) var(--mx-spring); + background:transparent; +} +.mx-checkbox-box svg{ + width:12px;height:12px; + color:#fff;opacity:0; + transform:scale(0); + transition:all var(--mx-d2) var(--mx-spring); +} + +/* Checked */ +.mx-checkbox input:checked + .mx-checkbox-box{ + background:var(--mx-primary); + border-color:var(--mx-primary); +} +.mx-checkbox input:checked + .mx-checkbox-box svg{ + opacity:1;transform:scale(1); +} + +/* Indeterminate */ +.mx-checkbox input:indeterminate + .mx-checkbox-box{ + background:var(--mx-primary); + border-color:var(--mx-primary); +} +.mx-checkbox input:indeterminate + .mx-checkbox-box svg{ + opacity:1;transform:scale(1); +} + +/* Hover */ +.mx-checkbox:hover .mx-checkbox-box{border-color:var(--mx-primary)} + +/* Disabled */ +.mx-checkbox input:disabled + .mx-checkbox-box{ + opacity:0.4;cursor:not-allowed; +} +.mx-checkbox input:disabled ~ .mx-checkbox-label{ + opacity:0.4;cursor:not-allowed; +} + +/* Focus */ +.mx-checkbox input:focus-visible + .mx-checkbox-box{ + box-shadow:0 0 0 3px var(--mx-primary-glow); +} + +/* Label */ +.mx-checkbox-label{ + font-size:14px;color:var(--mx-text); + transition:color var(--mx-d1); + user-select:none; +} + +/* ── Radio ── */ +.mx-radio{ + display:inline-flex;align-items:center;gap:8px; + cursor:pointer; + -webkit-tap-highlight-color:transparent; +} +.mx-radio input{display:none} + +.mx-radio-dot{ + width:18px;height:18px; + border:2px solid var(--mx-border); + border-radius:50%; + display:flex;align-items:center;justify-content:center; + flex-shrink:0; + transition:all var(--mx-d2) var(--mx-spring); +} +.mx-radio-dot::after{ + content:'';width:8px;height:8px; + border-radius:50%;background:#fff; + transform:scale(0); + transition:transform var(--mx-d2) var(--mx-spring); +} + +/* Checked */ +.mx-radio input:checked + .mx-radio-dot{ + background:var(--mx-primary); + border-color:var(--mx-primary); +} +.mx-radio input:checked + .mx-radio-dot::after{ + transform:scale(1); +} + +/* Hover */ +.mx-radio:hover .mx-radio-dot{border-color:var(--mx-primary)} + +/* Disabled */ +.mx-radio input:disabled + .mx-radio-dot{ + opacity:0.4;cursor:not-allowed; +} +.mx-radio input:disabled ~ .mx-radio-label{ + opacity:0.4;cursor:not-allowed; +} + +/* Focus */ +.mx-radio input:focus-visible + .mx-radio-dot{ + box-shadow:0 0 0 3px var(--mx-primary-glow); +} + +/* Label */ +.mx-radio-label{ + font-size:14px;color:var(--mx-text); + transition:color var(--mx-d1); + user-select:none; +} + +/* ── Checkbox Group / Radio Group layout ── */ +.mx-checkbox-group,.mx-radio-group{ + display:flex;flex-direction:column;gap:10px; +} +.mx-checkbox-group.horizontal,.mx-radio-group.horizontal{ + flex-direction:row;flex-wrap:wrap;gap:16px; +} + +/* Mobile: 加大触控区 */ +@media(max-width:767px){ + .mx-checkbox,.mx-radio{min-height:44px;padding:4px 0} + .mx-checkbox-box,.mx-radio-dot{width:22px;height:22px} + .mx-checkbox-box svg{width:14px;height:14px} + .mx-radio-dot::after{width:10px;height:10px} +} diff --git a/vendor/miuix-console/components/drawer.css b/vendor/miuix-console/components/drawer.css new file mode 100644 index 0000000..9c34e1d --- /dev/null +++ b/vendor/miuix-console/components/drawer.css @@ -0,0 +1,93 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Drawer / Side Panel Component + ═══════════════════════════════════════════ */ + +/* Overlay */ +.mx-drawer-overlay{ + position:fixed;inset:0;z-index:900; + background:var(--mx-window-dim); + opacity:0;visibility:hidden; + transition:opacity var(--mx-d2) var(--mx-ease), + visibility var(--mx-d2); +} +.mx-drawer-overlay.open{opacity:1;visibility:visible} + +/* Drawer */ +.mx-drawer{ + position:fixed;top:0;z-index:950; + width:320px;height:100dvh; + background:var(--mx-s1); + box-shadow:var(--mx-sh3); + display:flex;flex-direction:column; + transition:transform var(--mx-d3) var(--mx-spring); +} + +/* Position: right (default) */ +.mx-drawer-right{ + right:0; + transform:translateX(100%); +} +.mx-drawer-right.open{transform:translateX(0)} + +/* Position: left */ +.mx-drawer-left{ + left:0; + transform:translateX(-100%); +} +.mx-drawer-left.open{transform:translateX(0)} + +/* Size variants */ +.mx-drawer-sm{width:280px} +.mx-drawer-lg{width:420px} +@media(min-width:1024px){ + .mx-drawer-xl{width:560px} +} + +/* Header */ +.mx-drawer-head{ + display:flex;align-items:center;justify-content:space-between; + padding:16px 20px; + border-bottom:1px solid var(--mx-divider); + flex-shrink:0; +} +.mx-drawer-title{font-size:15px;font-weight:700} +.mx-drawer-close{ + width:28px;height:28px; + display:flex;align-items:center;justify-content:center; + border-radius:var(--mx-r-xs); + cursor:pointer;color:var(--mx-t3); + transition:all var(--mx-d1); +} +.mx-drawer-close:hover{background:var(--mx-s-hover);color:var(--mx-text)} + +/* Body */ +.mx-drawer-body{ + flex:1;overflow-y:auto;padding:20px; + font-size:14px;line-height:1.6; +} + +/* Footer */ +.mx-drawer-foot{ + display:flex;align-items:center;justify-content:flex-end;gap:8px; + padding:12px 20px; + border-top:1px solid var(--mx-divider); + flex-shrink:0; +} + +/* Full width on mobile */ +@media(max-width:767px){ + .mx-drawer{width:100%} + .mx-drawer-sm,.mx-drawer-lg,.mx-drawer-xl{width:100%} +} + +/* Tablet */ +@media(min-width:768px) and (max-width:1023px){ + .mx-drawer{width:360px} + .mx-drawer-sm{width:300px} + .mx-drawer-lg{width:420px} +} + +/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */ +[data-theme="dark"] .mx-drawer{ + box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04); +} diff --git a/vendor/miuix-console/components/empty.css b/vendor/miuix-console/components/empty.css new file mode 100644 index 0000000..4e42338 --- /dev/null +++ b/vendor/miuix-console/components/empty.css @@ -0,0 +1,46 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Empty State Component + ═══════════════════════════════════════════ */ + +.mx-empty{ + display:flex;flex-direction:column; + align-items:center;justify-content:center; + padding:48px 24px;text-align:center; +} + +.mx-empty-icon{ + width:64px;height:64px; + border-radius:var(--mx-r); + background:var(--mx-s3); + display:flex;align-items:center;justify-content:center; + margin-bottom:16px; + color:var(--mx-t3); +} +.mx-empty-icon svg{width:28px;height:28px} + +.mx-empty-title{ + font-size:15px;font-weight:700; + color:var(--mx-text);margin-bottom:6px; +} +.mx-empty-desc{ + font-size:13px;color:var(--mx-t3); + line-height:1.5;max-width:320px;margin-bottom:20px; +} + +/* Inline variant — smaller */ +.mx-empty-inline{ + padding:24px 16px; +} +.mx-empty-inline .mx-empty-icon{ + width:48px;height:48px; +} +.mx-empty-inline .mx-empty-icon svg{width:22px;height:22px} + +/* Mobile */ +@media(max-width:767px){ + .mx-empty{padding:32px 16px} + .mx-empty-icon{width:52px;height:52px} + .mx-empty-icon svg{width:24px;height:24px} + .mx-empty-title{font-size:14px} + .mx-empty-desc{font-size:12px} +} diff --git a/vendor/miuix-console/components/input.css b/vendor/miuix-console/components/input.css new file mode 100644 index 0000000..74368c6 --- /dev/null +++ b/vendor/miuix-console/components/input.css @@ -0,0 +1,77 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Input Component + ═══════════════════════════════════════════ */ + +.mx-input-wrap{ + display:flex;align-items:center;gap:10px; + background:var(--mx-s3); + border:1px solid var(--mx-border); + border-radius:var(--mx-r-sm); + padding:0 16px;height:40px; + transition:all var(--mx-d2) var(--mx-ease); +} +.mx-input-wrap:focus-within{ + border-color:var(--mx-primary); + box-shadow:0 0 0 3px var(--mx-primary-glow); + background:var(--mx-s2); +} +.mx-input-wrap .mx-input-icon{ + color:var(--mx-t3);flex-shrink:0; + display:flex;align-items:center; +} +.mx-input{ + width:100%;height:100%; + background:none; + color:var(--mx-text);font-size:14px; + font-family:var(--mx-font); +} +.mx-input::placeholder{color:var(--mx-t3)} + +/* Textarea */ +.mx-textarea-wrap{ + padding:12px 14px;height:auto;min-height:80px; + align-items:flex-start; +} +.mx-textarea{ + width:100%;resize:vertical;min-height:60px; + background:none;border:none;outline:none; + color:var(--mx-text);font-size:14px; + font-family:var(--mx-font);line-height:1.6; +} +.mx-textarea::placeholder{color:var(--mx-t3)} + +/* Sizes */ +.mx-input-sm{height:34px;padding:0 12px;font-size:13px;border-radius:var(--mx-r-xs)} +.mx-input-lg{height:46px;padding:0 16px;font-size:15px} + +/* Error */ +.mx-input-error{border-color:var(--mx-red)} +.mx-input-error:focus-within{box-shadow:0 0 0 3px rgba(248,113,113,0.15)} + +/* Disabled */ +.mx-input-wrap.mx-disabled{ + opacity:0.4;cursor:not-allowed; +} +.mx-input-wrap.mx-disabled input{pointer-events:none} + +/* Label */ +.mx-label{ + display:block;font-size:12px;font-weight:600; + color:var(--mx-t2);margin-bottom:6px; + text-transform:uppercase;letter-spacing:0.04em; +} + +/* Helper text */ +.mx-helper{font-size:12px;color:var(--mx-t3);margin-top:4px} +.mx-helper-error{color:var(--mx-red)} + +/* Mobile: 防止 iOS 自动缩放 + 加大触控区 */ +@media(max-width:767px){ + .mx-input-wrap{height:44px;padding:0 16px} + .mx-input{font-size:16px} + .mx-input-sm{height:38px;font-size:15px} + .mx-input-lg{height:50px;font-size:16px} + .mx-textarea-wrap{min-height:88px} + .mx-textarea{font-size:16px;min-height:72px} + .mx-label{font-size:13px;margin-bottom:8px} +} diff --git a/vendor/miuix-console/components/layout.css b/vendor/miuix-console/components/layout.css new file mode 100644 index 0000000..005d7e0 --- /dev/null +++ b/vendor/miuix-console/components/layout.css @@ -0,0 +1,327 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Layout System + 全设备适配:手机 / 平板 / 桌面 / 大屏 + ═══════════════════════════════════════════ */ + +/* ── App Shell ── */ +.mx-app{display:flex;flex-direction:column;height:100dvh} + +/* ── Scrollable Body ── */ +.mx-body{ + flex:1;overflow-y:auto;overflow-x:hidden; + -webkit-overflow-scrolling:touch; +} + +/* ── 自动为手机端主内容区添加底部内边距,防止 fixed 底栏遮挡 ── */ +@media(max-width:1023px){ + .mx-app > .mx-body, + .mx-app > .mx-desktop-main.mx-body{ + padding-bottom:calc(72px + var(--mx-safe-b)); + } +} +@media(min-width:1024px){ + .mx-app > .mx-body, + .mx-app > .mx-desktop-main.mx-body{ + padding-bottom:0; + } +} + +/* ── Padding utilities ── */ +.mx-pad{padding:12px} +.mx-pad-b{padding-bottom:calc(72px + var(--mx-safe-b))} +@media(min-width:768px){.mx-pad{padding:20px}.mx-pad-b{padding-bottom:20px}} +@media(min-width:1024px){.mx-pad{padding:28px}} + +/* ── Page Header ── */ +.mx-page-head{margin-bottom:16px} +@media(min-width:768px){.mx-page-head{margin-bottom:20px}} +.mx-page-title{ + font-size:20px;font-weight:800;letter-spacing:-0.02em; + background:linear-gradient(135deg,var(--mx-text),var(--mx-t2)); + -webkit-background-clip:text;-webkit-text-fill-color:transparent; +} +@media(min-width:768px){.mx-page-title{font-size:24px}} +.mx-page-sub{ + font-size:12px;color:var(--mx-t3);margin-top:4px; + display:flex;align-items:center;gap:6px; +} + +/* ── Section ── */ +.mx-section{margin-bottom:12px} +@media(min-width:768px){.mx-section{margin-bottom:16px}} + +/* ══════════════════════════════════════════ + MOBILE (< 768px) — 触控优先 + ══════════════════════════════════════════ */ +@media(max-width:767px){ + /* 触控友好的最小高度 */ + .mx-btn{height:40px;padding:0 20px;font-size:14px} + .mx-btn-sm{height:36px;padding:0 14px;font-size:13px} + .mx-btn-lg{height:46px;padding:0 24px;font-size:15px} + .mx-btn-icon{width:40px;height:40px} + + /* 输入框加大 */ + .mx-input-wrap{height:44px;padding:0 16px} + .mx-input{font-size:16px} /* 防止 iOS 自动缩放 */ + .mx-input-sm{height:38px;font-size:15px} + .mx-input-lg{height:50px;font-size:16px} + + /* Select 加大 */ + .mx-select-trigger{height:44px;font-size:16px} + + /* Switch 加大 */ + .mx-switch-track{width:48px;height:28px} + .mx-switch-thumb{width:22px;height:22px;top:3px;left:3px} + .mx-switch input:checked + .mx-switch-track .mx-switch-thumb{transform:translateX(20px)} + + /* Checkbox/Radio 加大触控区 */ + .mx-checkbox-box,.mx-radio-dot{width:20px;height:20px} + .mx-checkbox-box svg{width:14px;height:14px} + .mx-radio-dot::after{width:9px;height:9px} + + /* Slider 触控加大 */ + .mx-slider input[type="range"]::-webkit-slider-thumb{width:22px;height:22px} + .mx-slider input[type="range"]::-moz-range-thumb{width:22px;height:22px} + + /* Navigation 触控加大 */ + .mx-nav-item{padding:11px 10px} + .mx-btm-item{padding:8px 16px} + .mx-btm-item span{font-size:11px} + + /* TopBar */ + .mx-topbar{height:48px;padding:0 12px} + + /* BottomNav */ + .mx-bottom-nav{height:auto} + + /* Modal 全屏化 */ + .mx-modal-overlay{padding:0;align-items:flex-end} + .mx-modal{ + max-width:100%;max-height:90dvh; + border-radius:var(--mx-r) var(--mx-r) 0 0; + transform:translateY(100%); + opacity:1; + } + .mx-modal-overlay.open .mx-modal{transform:translateY(0)} + .mx-modal-sm,.mx-modal-lg,.mx-modal-xl{max-width:100%} + + /* Drawer 全宽 */ + .mx-drawer{width:100%} + .mx-drawer-sm,.mx-drawer-lg,.mx-drawer-xl{width:100%} + + /* Grid 单列降级 */ + .mx-grid-2,.mx-grid-3{grid-template-columns:1fr} + .mx-grid-sidebar{grid-template-columns:1fr} + + /* Stat 单列(窄屏手机) */ + .mx-stat-grid{grid-template-columns:1fr 1fr} + + /* Table 卡片式响应 */ + .mx-table{border:0} + .mx-table thead{display:none} + .mx-table tbody tr{ + display:block;margin-bottom:12px; + border:1px solid var(--mx-divider); + border-radius:var(--mx-r-sm); + padding:12px; + background:var(--mx-s2); + } + .mx-table tbody td{ + display:flex;justify-content:space-between;align-items:center; + padding:6px 0;border-bottom:1px solid var(--mx-divider); + white-space:normal; + } + .mx-table tbody td:last-child{border-bottom:none} + .mx-table tbody td::before{ + content:attr(data-label); + font-size:11px;font-weight:700;color:var(--mx-t3); + text-transform:uppercase;letter-spacing:0.04em; + flex-shrink:0;margin-right:12px; + } + .mx-table tbody tr:hover td{padding-left:0} + + /* Tabs 横向滚动 */ + .mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch} + .mx-tab{padding:10px 14px} + + /* Pagination 精简 */ + .mx-page-prev span,.mx-page-next span{display:none} + .mx-page-info{display:none} + .mx-pagination{gap:2px} + .mx-page-btn{width:32px;height:32px;font-size:12px} + + /* Popover 全宽 */ + .mx-popover{ + position:fixed;left:8px!important;right:8px!important; + bottom:8px!important;top:auto!important; + width:auto!important;max-width:none; + transform:translateY(100%)!important; + transform-origin:bottom center; + border-radius:var(--mx-r); + } + .mx-popover.open{transform:translateY(0)!important} + + /* Select dropdown 全宽 */ + .mx-select-dropdown{ + position:fixed;left:8px!important;right:8px!important; + bottom:8px!important;top:auto!important; + max-height:50dvh; + transform:translateY(100%)!important; + transform-origin:bottom center; + border-radius:var(--mx-r); + } + .mx-select-dropdown.open{transform:translateY(0)!important} + + /* Datepicker dropdown 全宽 */ + .mx-datepicker-dropdown{ + position:fixed;left:8px!important;right:8px!important; + bottom:8px!important;top:auto!important; + transform:translateY(100%)!important; + transform-origin:bottom center; + border-radius:var(--mx-r); + } + .mx-datepicker-dropdown.open{transform:translateY(0)!important} + + /* Accordion */ + .mx-accordion-header{padding:12px 14px} + + /* Upload */ + .mx-upload{padding:24px 16px} + + /* Avatar group */ + .mx-avatar-group .mx-avatar, + .mx-avatar-group .mx-avatar-img{margin-left:-10px} +} + +/* ══════════════════════════════════════════ + TABLET (768px — 1023px) + ══════════════════════════════════════════ */ +@media(min-width:768px) and (max-width:1023px){ + .mx-grid-3{grid-template-columns:1fr 1fr} + .mx-grid-sidebar{grid-template-columns:1fr} + .mx-stat-grid{grid-template-columns:repeat(4,1fr)} + + /* Modal 稍宽 */ + .mx-modal{max-width:480px} + + /* Drawer */ + .mx-drawer{width:360px} + .mx-drawer-sm{width:300px} + .mx-drawer-lg{width:420px} + + /* Tabs 横向滚动 */ + .mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch} +} + +/* ══════════════════════════════════════════ + DESKTOP (≥ 1024px) + ══════════════════════════════════════════ */ +@media(min-width:1024px){ + .mx-app{flex-direction:row} + .mx-app>.mx-topbar{display:none} + .mx-app>.mx-bottom-nav{display:none!important} + + .mx-desktop-shell{ + display:flex;flex-direction:column;width:100%; + } + .mx-desktop-topbar{ + display:flex;align-items:center;justify-content:space-between; + height:52px;padding:0 24px; + border-bottom:1px solid var(--mx-divider); + backdrop-filter:blur(24px) saturate(1.8); + -webkit-backdrop-filter:blur(24px) saturate(1.8); + flex-shrink:0; + } + [data-theme="dark"] .mx-desktop-topbar{background:rgba(0,0,0,0.6)} + [data-theme="light"] .mx-desktop-topbar{background:rgba(247,247,247,0.65)} + + .mx-desktop-main{ + flex:1;padding:28px;overflow-y:auto;background:var(--mx-bg); + } + + .mx-sidebar{display:block} + .mx-bottom-nav{display:none!important} + + /* Grid 正常 */ + .mx-grid-2{grid-template-columns:1fr 1fr} + .mx-grid-3{grid-template-columns:1fr 1fr 1fr} + .mx-grid-sidebar{display:grid;grid-template-columns:1fr 320px;gap:16px} + .mx-stat-grid{grid-template-columns:repeat(4,1fr)} +} + +/* Hide desktop elements on mobile */ +@media(max-width:1023px){ + .mx-sidebar{display:none} + .mx-desktop-shell{display:none} +} + +/* ── Wide (≥1440px) ── */ +@media(min-width:1440px){ + .mx-sidebar{width:260px} + .mx-desktop-main{padding:28px 32px} +} + +/* ══════════════════════════════════════════ + TOUCH DETECTION — 精确触控增强 + ══════════════════════════════════════════ */ +@media(hover:none) and (pointer:coarse){ + /* 触控设备额外加大可交互区域 */ + .mx-tab{min-height:44px} + .mx-nav-item{min-height:44px} + .mx-accordion-header{min-height:48px} + .mx-popover-item{min-height:44px} + .mx-select-option{min-height:44px} + .mx-page-btn{min-height:36px} + .mx-tag{min-height:32px} + + /* 禁用 hover 效果(触控无 hover) */ + .mx-card:hover{box-shadow:var(--mx-sh1)} + .mx-stat:hover::before{opacity:0} + .mx-stat:hover::after{opacity:0.04} + .mx-stat:hover .mx-stat-icon{transform:none} + .mx-table tbody tr:hover{background:transparent} + .mx-table tbody tr:hover td{padding-left:14px} + .mx-table th:hover::after{transform:scaleX(0)} + + /* 用 :active 替代 */ + .mx-card:active{box-shadow:var(--mx-sh2)} + .mx-stat:active{box-shadow:var(--mx-sh2)} + .mx-table tbody tr:active{background:var(--mx-s-hover)} + + /* Tooltip 改为长按触发 */ + .mx-tooltip::after{ + transition:none; + opacity:0;visibility:hidden; + } + .mx-tooltip:active::after{ + opacity:1;visibility:visible; + } +} + +/* ══════════════════════════════════════════ + LANDSCAPE PHONE — 横屏手机 + ══════════════════════════════════════════ */ +@media(max-height:500px) and (orientation:landscape){ + .mx-topbar{height:40px} + .mx-bottom-nav{display:none!important} + .mx-modal{max-height:95dvh;border-radius:var(--mx-r)} + .mx-modal-overlay{align-items:center;padding:8px} + .mx-drawer{height:100dvh} +} + +/* ══════════════════════════════════════════ + FLEX utilities + ══════════════════════════════════════════ */ +.mx-flex{display:flex} +.mx-flex-center{display:flex;align-items:center;justify-content:center} +.mx-flex-between{display:flex;align-items:center;justify-content:space-between} +.mx-gap-4{gap:4px}.mx-gap-8{gap:8px}.mx-gap-12{gap:12px}.mx-gap-16{gap:16px} + +/* ══════════════════════════════════════════ + SAFE AREA — 全面屏设备 + ══════════════════════════════════════════ */ +@supports(padding:max(0px)){ + .mx-pad{padding:max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right))} + @media(min-width:768px){.mx-pad{padding:max(20px, env(safe-area-inset-left)) max(20px, env(safe-area-inset-right))}} + .mx-bottom-nav{padding-bottom:max(4px, env(safe-area-inset-bottom))} +} diff --git a/vendor/miuix-console/components/modal.css b/vendor/miuix-console/components/modal.css new file mode 100644 index 0000000..796f961 --- /dev/null +++ b/vendor/miuix-console/components/modal.css @@ -0,0 +1,117 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Modal / Dialog Component + 对应 Miuix Dialog — 多层 Surface + 弹簧入场 + ═══════════════════════════════════════════ */ + +/* Overlay */ +.mx-modal-overlay{ + position:fixed;inset:0;z-index:1000; + display:flex;align-items:center;justify-content:center; + background:var(--mx-window-dim); + opacity:0;visibility:hidden; + transition:opacity var(--mx-d2) var(--mx-ease), + visibility var(--mx-d2); + padding:16px; +} +.mx-modal-overlay.open{ + opacity:1;visibility:visible; +} + +/* Dialog */ +.mx-modal{ + background:var(--mx-s2); + border-radius:var(--mx-r); + box-shadow:var(--mx-sh3); + width:100%;max-width:420px; + max-height:calc(100dvh - 48px); + overflow:hidden; + display:flex;flex-direction:column; + transform:scale(0.92) translateY(16px); + opacity:0; + transition:transform var(--mx-d3) var(--mx-spring), + opacity var(--mx-d2) var(--mx-ease); +} +.mx-modal-overlay.open .mx-modal{ + transform:scale(1) translateY(0); + opacity:1; +} + +/* Size variants */ +.mx-modal-sm{max-width:340px} +.mx-modal-lg{max-width:560px} +.mx-modal-xl{max-width:720px} +.mx-modal-full{max-width:calc(100dvw - 32px);max-height:calc(100dvh - 32px)} + +/* Header */ +.mx-modal-head{ + display:flex;align-items:center;justify-content:space-between; + padding:16px 20px; + border-bottom:1px solid var(--mx-divider); + flex-shrink:0; +} +.mx-modal-title{ + font-size:15px;font-weight:700; +} +.mx-modal-close{ + width:28px;height:28px; + display:flex;align-items:center;justify-content:center; + border-radius:var(--mx-r-xs); + cursor:pointer;color:var(--mx-t3); + transition:all var(--mx-d1) var(--mx-ease); +} +.mx-modal-close:hover{background:var(--mx-s-hover);color:var(--mx-text)} +.mx-modal-close:active{transform:scale(0.9)} + +/* Body */ +.mx-modal-body{ + padding:20px; + overflow-y:auto;flex:1; + font-size:14px;line-height:1.6;color:var(--mx-text-on-surface); +} + +/* Footer */ +.mx-modal-foot{ + display:flex;align-items:center;justify-content:flex-end;gap:8px; + padding:12px 20px; + border-top:1px solid var(--mx-divider); + flex-shrink:0; +} + +/* Confirmation variant — icon + centered text */ +.mx-modal-confirm-icon{ + width:48px;height:48px; + border-radius:50%; + display:flex;align-items:center;justify-content:center; + margin:0 auto 16px; +} +.mx-modal-confirm-icon.info{background:rgba(96,165,250,0.12);color:var(--mx-blue)} +.mx-modal-confirm-icon.success{background:rgba(52,211,153,0.12);color:var(--mx-green)} +.mx-modal-confirm-icon.warning{background:rgba(251,191,36,0.12);color:var(--mx-amber)} +.mx-modal-confirm-icon.danger{background:rgba(248,113,113,0.12);color:var(--mx-red)} +.mx-modal-confirm-text{ + text-align:center;font-size:14px;color:var(--mx-text-on-surface); + line-height:1.6;margin-bottom:4px; +} +.mx-modal-confirm-title{ + text-align:center;font-size:16px;font-weight:700; + margin-bottom:8px; +} +.mx-modal-confirm-foot{justify-content:center} + +/* Mobile: 按钮全宽堆叠 */ +@media(max-width:767px){ + .mx-modal-foot{flex-direction:column-reverse} + .mx-modal-foot .mx-btn{width:100%} +} + +/* Tablet: 居中弹窗 */ +@media(min-width:768px) and (max-width:1023px){ + .mx-modal-overlay{align-items:center;padding:16px} + .mx-modal{max-width:480px;transform:scale(0.92) translateY(16px)} + .mx-modal-overlay.open .mx-modal{transform:scale(1) translateY(0)} +} + +/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */ +[data-theme="dark"] .mx-modal{ + box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04); +} diff --git a/vendor/miuix-console/components/navigation.css b/vendor/miuix-console/components/navigation.css new file mode 100644 index 0000000..9634888 --- /dev/null +++ b/vendor/miuix-console/components/navigation.css @@ -0,0 +1,212 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Navigation Components + TopBar + Sidebar + BottomNav + ═══════════════════════════════════════════ */ + +/* ── TopBar — Miuix frosted glass ── */ +.mx-topbar{ + position:sticky;top:0;z-index:100; + display:flex;align-items:center;justify-content:space-between; + height:52px;padding:0 16px; + padding-top:var(--mx-safe-t); + border-bottom:1px solid var(--mx-divider); + backdrop-filter:blur(24px) saturate(1.8); + -webkit-backdrop-filter:blur(24px) saturate(1.8); + flex-shrink:0; +} +[data-theme="dark"] .mx-topbar{background:rgba(0,0,0,0.6)} +[data-theme="light"] .mx-topbar{background:rgba(247,247,247,0.65)} + +.mx-topbar-left{display:flex;align-items:center;gap:10px} +.mx-topbar-right{display:flex;align-items:center;gap:4px} + +/* Logo */ +.mx-logo{ + display:flex;align-items:center;gap:10px; +} +.mx-logo-icon{ + width:32px;height:32px; + background:linear-gradient(135deg,var(--mx-primary),var(--mx-accent)); + border-radius:10px; + display:flex;align-items:center;justify-content:center; + transition:transform var(--mx-d2) var(--mx-ease); + box-shadow:0 2px 8px rgba(76,139,245,0.2); + flex-shrink:0; +} +.mx-logo-icon:active{transform:scale(0.92)} +.mx-logo-icon svg{color:#fff} +.mx-logo-text{font-size:16px;font-weight:700} + +/* ── Sidebar ── */ +.mx-sidebar{ + width:240px;flex-shrink:0; + background:var(--mx-s1); + border-right:1px solid var(--mx-divider); + padding:12px; + overflow-y:auto;scrollbar-width:none; +} +.mx-sidebar::-webkit-scrollbar{display:none} + +.mx-sidebar-section{ + font-size:11px;font-weight:700;color:var(--mx-t3); + text-transform:uppercase;letter-spacing:0.06em; + padding:0 10px;margin:16px 0 4px; +} +.mx-sidebar-section:first-child{margin-top:4px} + +.mx-nav-item{ + display:flex;align-items:center;gap:10px; + padding:8px 10px;border-radius:var(--mx-r-sm); + cursor:pointer;color:var(--mx-t2); + font-size:13px;font-weight:500; + transition:all var(--mx-d1) var(--mx-ease); + position:relative; + -webkit-tap-highlight-color:transparent; +} +.mx-nav-item::before{ + content:'';position:absolute;left:0;top:50%; + width:3px;height:0; + background:var(--mx-primary); + border-radius:0 3px 3px 0; + transform:translateY(-50%); + transition:height var(--mx-d2) var(--mx-spring); +} +.mx-nav-item:hover{background:var(--mx-s-hover);color:var(--mx-text)} +.mx-nav-item.active{background:var(--mx-primary-glow);color:var(--mx-primary)} +.mx-nav-item.active::before{height:18px} +.mx-nav-item:active{transform:scale(0.97)} +.mx-nav-item svg{flex-shrink:0} +.mx-nav-label{flex:1} + +.mx-sidebar-bottom{ + margin-top:auto;padding-top:12px; + border-top:1px solid var(--mx-divider); +} + +/* ── BottomNav — mobile only ── */ +.mx-bottom-nav{ + position:fixed;bottom:0;left:0;right:0;z-index:200; + border-top:1px solid var(--mx-divider); + backdrop-filter:blur(24px) saturate(1.8); + -webkit-backdrop-filter:blur(24px) saturate(1.8); + padding:4px 0; + padding-bottom:calc(4px + var(--mx-safe-b)); +} +[data-theme="dark"] .mx-bottom-nav{background:rgba(0,0,0,0.6)} +[data-theme="light"] .mx-bottom-nav{background:rgba(247,247,247,0.65)} + +.mx-bottom-nav-items{ + display:flex;justify-content:space-around; +} + +.mx-btm-item{ + display:flex;flex-direction:column;align-items:center;gap:2px; + padding:6px 16px;border-radius:var(--mx-r-sm); + cursor:pointer;color:var(--mx-t3); + transition:all var(--mx-d2) var(--mx-spring); + -webkit-tap-highlight-color:transparent; + position:relative; +} +.mx-btm-item.active{color:var(--mx-primary)} +.mx-btm-item.active::before{ + content:'';position:absolute;top:2px;left:50%; + width:5px;height:5px; + background:var(--mx-primary); + border-radius:50%;transform:translateX(-50%); + animation:mx-dot-in var(--mx-d2) var(--mx-spring); +} +.mx-btm-item span{font-size:10px;font-weight:600} +.mx-btm-item:active{transform:scale(0.88)} + +@keyframes mx-dot-in{ + from{transform:translateX(-50%) scale(0);opacity:0} + to{transform:translateX(-50%) scale(1);opacity:1} +} + +/* ── Breadcrumb ── */ +.mx-breadcrumb{ + display:flex;align-items:center;gap:8px; + font-size:13px;color:var(--mx-t3); +} +.mx-breadcrumb span{transition:color var(--mx-d1)} +.mx-breadcrumb span:last-child{color:var(--mx-t2);font-weight:500} +.mx-breadcrumb .mx-breadcrumb-sep{color:var(--mx-t3);font-size:11px} + +/* ── Pill Group — density/theme toggle ── */ +.mx-pill-group{ + display:inline-flex; + background:var(--mx-s3); + border-radius:var(--mx-r-pill); + padding:3px;gap:2px; +} +.mx-pill-group button{ + padding:5px 14px;border-radius:var(--mx-r-pill); + font-size:12px;font-weight:600;cursor:pointer; + color:var(--mx-t3); + transition:all var(--mx-d2) var(--mx-ease); +} +.mx-pill-group button.active{ + background:var(--mx-primary);color:#fff; + box-shadow:0 2px 8px rgba(76,139,245,0.25); +} +.mx-pill-group button:hover:not(.active){color:var(--mx-text)} + +/* ── Avatar ── */ +.mx-avatar{ + width:32px;height:32px;border-radius:50%; + background:linear-gradient(135deg,var(--mx-primary),var(--mx-accent)); + display:flex;align-items:center;justify-content:center; + font-size:13px;font-weight:700;color:#fff; + cursor:pointer;flex-shrink:0; + transition:all var(--mx-d2) var(--mx-ease); + box-shadow:0 0 0 0 var(--mx-primary-glow); +} +.mx-avatar:hover{box-shadow:0 0 0 4px var(--mx-primary-glow)} +.mx-avatar:active{transform:scale(0.9)} + +/* ── Notification Dot ── */ +.mx-notify{ + position:relative; +} +.mx-notify .mx-notify-dot{ + position:absolute;top:7px;right:7px; + width:7px;height:7px; + background:var(--mx-red); + border-radius:50%; + border:2px solid var(--mx-s1); + animation:mx-pulse 2s infinite; +} + +/* ═══ MOBILE ADAPTATIONS ═══ */ +@media(max-width:767px){ + /* TopBar 更紧凑 */ + .mx-topbar{height:48px;padding:0 12px} + .mx-logo-icon{width:28px;height:28px;border-radius:8px} + .mx-logo-text{font-size:14px} + .mx-avatar{width:28px;height:28px;font-size:12px} + + /* BottomNav 加大触控区 */ + .mx-btm-item{padding:8px 16px;min-width:56px} + .mx-btm-item span{font-size:11px} + .mx-btm-item svg{width:24px;height:24px} + + /* Sidebar section */ + .mx-sidebar-section{font-size:10px} + .mx-nav-item{padding:11px 10px;min-height:44px} + + /* Breadcrumb 滚动 */ + .mx-breadcrumb{overflow-x:auto;white-space:nowrap;-webkit-overflow-scrolling:touch} + + /* Pill group */ + .mx-pill-group button{padding:6px 12px;font-size:11px} + + /* Notification dot */ + .mx-notify .mx-notify-dot{top:5px;right:5px;width:6px;height:6px} +} + +/* ═══ TABLET ADAPTATIONS ═══ */ +@media(min-width:768px) and (max-width:1023px){ + .mx-topbar{height:50px} + .mx-bottom-nav{padding:6px 0} + .mx-btm-item span{font-size:10px} +} diff --git a/vendor/miuix-console/components/pagination.css b/vendor/miuix-console/components/pagination.css new file mode 100644 index 0000000..a3381c9 --- /dev/null +++ b/vendor/miuix-console/components/pagination.css @@ -0,0 +1,83 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Pagination Component + ═══════════════════════════════════════════ */ + +.mx-pagination{ + display:flex;align-items:center;gap:4px; + font-size:13px; +} + +/* Page button */ +.mx-page-btn{ + width:34px;height:34px; + display:flex;align-items:center;justify-content:center; + border-radius:var(--mx-r-xs); + cursor:pointer;color:var(--mx-t2); + font-weight:500; + transition:all var(--mx-d1) var(--mx-ease); + -webkit-tap-highlight-color:transparent; +} +.mx-page-btn:hover{background:var(--mx-s-hover);color:var(--mx-text)} +.mx-page-btn:active{transform:scale(0.92)} + +/* Active */ +.mx-page-btn.active{ + background:var(--mx-primary);color:#fff; + box-shadow:0 2px 8px rgba(76,139,245,0.25); +} + +/* Disabled */ +.mx-page-btn.disabled{ + opacity:0.3;pointer-events:none; +} + +/* Ellipsis */ +.mx-page-ellipsis{ + width:34px;height:34px; + display:flex;align-items:center;justify-content:center; + color:var(--mx-t3);font-size:12px; + letter-spacing:2px; +} + +/* Prev / Next with text */ +.mx-page-prev,.mx-page-next{ + display:flex;align-items:center;gap:4px; + padding:0 10px;height:34px; + border-radius:var(--mx-r-xs); + cursor:pointer;color:var(--mx-t2); + font-size:13px;font-weight:500; + transition:all var(--mx-d1) var(--mx-ease); +} +.mx-page-prev:hover,.mx-page-next:hover{ + background:var(--mx-s-hover);color:var(--mx-text); +} +.mx-page-prev:active,.mx-page-next:active{transform:scale(0.95)} +.mx-page-prev.disabled,.mx-page-next.disabled{ + opacity:0.3;pointer-events:none; +} + +/* Size info */ +.mx-page-info{ + font-size:12px;color:var(--mx-t3); + margin:0 8px; +} + +/* Simple variant — just prev/next + info */ +.mx-pagination-simple .mx-page-btn{display:none} +.mx-pagination-simple .mx-page-ellipsis{display:none} + +/* Mobile: 精简显示 */ +@media(max-width:767px){ + .mx-page-prev span,.mx-page-next span{display:none} + .mx-page-info{display:none} + .mx-pagination{gap:2px} + .mx-page-btn{width:32px;height:32px;font-size:12px} + .mx-page-prev,.mx-page-next{padding:0 6px;height:32px} + /* 只显示前 5 页 */ + .mx-page-btn:nth-child(n+8){display:none} +} + +/* Tablet */ +@media(min-width:768px) and (max-width:1023px){ + .mx-page-btn{width:36px;height:36px} +} diff --git a/vendor/miuix-console/components/popover.css b/vendor/miuix-console/components/popover.css new file mode 100644 index 0000000..2330837 --- /dev/null +++ b/vendor/miuix-console/components/popover.css @@ -0,0 +1,87 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Popover Component + ═══════════════════════════════════════════ */ + +.mx-popover-wrap{position:relative;display:inline-flex} + +.mx-popover{ + position:absolute;z-index:600; + min-width:180px; + background:var(--mx-s4); + border:1px solid var(--mx-divider); + border-radius:var(--mx-r-sm); + box-shadow:var(--mx-sh3); + padding:6px; + opacity:0;visibility:hidden; + transform:scale(0.95) translateY(-4px); + transform-origin:top center; + transition:all var(--mx-d2) var(--mx-spring); + pointer-events:none; +} +.mx-popover.open{ + opacity:1;visibility:visible; + transform:scale(1) translateY(0); + pointer-events:auto; +} + +/* Position: bottom */ +.mx-popover-bottom{ + top:calc(100% + 6px);left:50%; + transform:translateX(-50%) scale(0.95) translateY(-4px); + transform-origin:top center; +} +.mx-popover-bottom.open{ + transform:translateX(-50%) scale(1) translateY(0); +} + +/* Position: left */ +.mx-popover-left{ + right:calc(100% + 6px);top:50%; + transform:translateY(-50%) scale(0.95) translateX(4px); + transform-origin:center right; +} +.mx-popover-left.open{ + transform:translateY(-50%) scale(1) translateX(0); +} + +/* Position: right */ +.mx-popover-right{ + left:calc(100% + 6px);top:50%; + transform:translateY(-50%) scale(0.95) translateX(-4px); + transform-origin:center left; +} +.mx-popover-right.open{ + transform:translateY(-50%) scale(1) translateX(0); +} + +/* Item */ +.mx-popover-item{ + display:flex;align-items:center;gap:8px; + padding:8px 10px;border-radius:var(--mx-r-xs); + font-size:13px;font-weight:500; + cursor:pointer;color:var(--mx-text); + transition:all var(--mx-d1) var(--mx-ease); +} +.mx-popover-item:hover{background:var(--mx-s-hover)} +.mx-popover-item:active{background:var(--mx-s-active);transform:scale(0.98)} +.mx-popover-item svg{flex-shrink:0;color:var(--mx-t2)} + +/* Divider */ +.mx-popover-divider{ + height:1px;background:var(--mx-divider); + margin:4px 0; +} + +/* Danger item */ +.mx-popover-item.danger{color:var(--mx-red)} +.mx-popover-item.danger svg{color:var(--mx-red)} + +/* Mobile: 底部弹出 + 加大触控 */ +@media(max-width:767px){ + .mx-popover-item{padding:12px 14px;min-height:44px;font-size:14px} +} + +/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */ +[data-theme="dark"] .mx-popover{ + box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04); +} diff --git a/vendor/miuix-console/components/progress.css b/vendor/miuix-console/components/progress.css new file mode 100644 index 0000000..7e2d3cd --- /dev/null +++ b/vendor/miuix-console/components/progress.css @@ -0,0 +1,135 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Progress Component + 进度条 + 加载指示器 + ═══════════════════════════════════════════ */ + +/* ── Linear Progress ── */ +.mx-progress{ + height:4px;background:var(--mx-s3); + border-radius:var(--mx-r-pill); + overflow:hidden; + position:relative; +} +.mx-progress-bar{ + height:100%; + background:var(--mx-primary); + border-radius:var(--mx-r-pill); + transition:width var(--mx-d3) var(--mx-ease); + position:relative; +} + +/* Striped */ +.mx-progress-striped .mx-progress-bar{ + background-image:linear-gradient( + 45deg, + rgba(255,255,255,0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255,255,255,0.15) 50%, + rgba(255,255,255,0.15) 75%, + transparent 75% + ); + background-size:16px 16px; +} + +/* Animated striped */ +.mx-progress-animated .mx-progress-bar{ + animation:mx-progress-stripes 0.8s linear infinite; +} +@keyframes mx-progress-stripes{ + from{background-position:16px 0} + to{background-position:0 0} +} + +/* Indeterminate */ +.mx-progress-indeterminate{ + overflow:hidden; +} +.mx-progress-indeterminate .mx-progress-bar{ + width:30%; + animation:mx-progress-slide 1.5s var(--mx-ease) infinite; +} +@keyframes mx-progress-slide{ + 0%{transform:translateX(-100%)} + 100%{transform:translateX(400%)} +} + +/* Color variants */ +.mx-progress-success .mx-progress-bar{background:var(--mx-green)} +.mx-progress-warning .mx-progress-bar{background:var(--mx-amber)} +.mx-progress-error .mx-progress-bar{background:var(--mx-red)} + +/* Size */ +.mx-progress-sm{height:2px} +.mx-progress-lg{height:8px} + +/* With label */ +.mx-progress-label{ + display:flex;align-items:center;justify-content:space-between; + margin-bottom:6px; + font-size:12px;font-weight:600;color:var(--mx-t2); +} +.mx-progress-label .mx-progress-value{ + color:var(--mx-primary);font-variant-numeric:tabular-nums; +} + +/* ── Circular Progress (Spinner) ── */ +.mx-spinner{ + width:24px;height:24px; + border:2.5px solid var(--mx-s3); + border-top-color:var(--mx-primary); + border-radius:50%; + animation:mx-spin 0.7s linear infinite; +} +@keyframes mx-spin{to{transform:rotate(360deg)}} + +.mx-spinner-sm{width:18px;height:18px;border-width:2px} +.mx-spinner-lg{width:36px;height:36px;border-width:3px} + +/* Dots spinner */ +.mx-spinner-dots{ + display:flex;gap:4px; +} +.mx-spinner-dots span{ + width:6px;height:6px; + border-radius:50%; + background:var(--mx-primary); + animation:mx-dot-bounce 1.2s ease-in-out infinite; +} +.mx-spinner-dots span:nth-child(2){animation-delay:0.15s} +.mx-spinner-dots span:nth-child(3){animation-delay:0.3s} +@keyframes mx-dot-bounce{ + 0%,80%,100%{transform:scale(0.6);opacity:0.4} + 40%{transform:scale(1);opacity:1} +} + +/* ── Progress Ring (circular) ── */ +.mx-progress-ring{ + display:inline-flex;align-items:center;justify-content:center; + position:relative; +} +.mx-progress-ring svg{ + transform:rotate(-90deg); +} +.mx-progress-ring-track{ + fill:none;stroke:var(--mx-s3);stroke-width:4; +} +.mx-progress-ring-bar{ + fill:none;stroke:var(--mx-primary);stroke-width:4; + stroke-linecap:round; + transition:stroke-dashoffset var(--mx-d3) var(--mx-ease); +} +.mx-progress-ring-text{ + position:absolute; + font-size:12px;font-weight:700; + color:var(--mx-text); + font-variant-numeric:tabular-nums; +} + +/* Mobile */ +@media(max-width:767px){ + .mx-progress{height:6px} + .mx-progress-lg{height:10px} + .mx-spinner{width:28px;height:28px} + .mx-spinner-lg{width:40px;height:40px} +} diff --git a/vendor/miuix-console/components/select.css b/vendor/miuix-console/components/select.css new file mode 100644 index 0000000..4c7bf39 --- /dev/null +++ b/vendor/miuix-console/components/select.css @@ -0,0 +1,130 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Select / Dropdown Component + ═══════════════════════════════════════════ */ + +/* Trigger */ +.mx-select{ + position:relative;display:inline-flex; +} +.mx-select-trigger{ + display:flex;align-items:center;gap:8px; + height:40px;padding:0 16px; + background:var(--mx-s3); + border:1px solid var(--mx-border); + border-radius:var(--mx-r-sm); + font-size:14px;color:var(--mx-text); + cursor:pointer; + transition:all var(--mx-d2) var(--mx-ease); + min-width:160px; +} +.mx-select-trigger:hover{border-color:var(--mx-t3)} +.mx-select-trigger.open{ + border-color:var(--mx-primary); + box-shadow:0 0 0 3px var(--mx-primary-glow); +} +.mx-select-trigger .mx-select-placeholder{color:var(--mx-t3);flex:1} +.mx-select-trigger .mx-select-value{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.mx-select-trigger .mx-select-arrow{ + color:var(--mx-t3);flex-shrink:0; + transition:transform var(--mx-d2) var(--mx-ease); +} +.mx-select-trigger.open .mx-select-arrow{transform:rotate(180deg)} + +/* Sizes */ +.mx-select-sm .mx-select-trigger{height:34px;padding:0 12px;font-size:13px;border-radius:var(--mx-r-xs)} +.mx-select-lg .mx-select-trigger{height:46px;padding:0 16px;font-size:15px} + +/* Dropdown */ +.mx-select-dropdown{ + position:absolute;top:calc(100% + 4px);left:0;z-index:700; + min-width:100%;max-height:280px; + background:var(--mx-s4); + border:1px solid var(--mx-divider); + border-radius:var(--mx-r-sm); + box-shadow:var(--mx-sh3); + padding:4px; + overflow-y:auto; + opacity:0;visibility:hidden; + transform:scale(0.96) translateY(-4px); + transform-origin:top center; + transition:all var(--mx-d2) var(--mx-spring); +} +.mx-select-dropdown.open{ + opacity:1;visibility:visible; + transform:scale(1) translateY(0); +} + +/* Search input inside dropdown */ +.mx-select-search{ + display:flex;align-items:center;gap:8px; + padding:8px 10px;margin-bottom:2px; + border-bottom:1px solid var(--mx-divider); +} +.mx-select-search input{ + flex:1;background:none;border:none;outline:none; + font-size:13px;color:var(--mx-text); +} +.mx-select-search input::placeholder{color:var(--mx-t3)} +.mx-select-search svg{color:var(--mx-t3);flex-shrink:0} + +/* Option */ +.mx-select-option{ + display:flex;align-items:center;gap:8px; + padding:8px 10px;border-radius:var(--mx-r-xs); + font-size:13px;cursor:pointer; + color:var(--mx-text); + transition:all var(--mx-d1) var(--mx-ease); +} +.mx-select-option:hover{background:var(--mx-s-hover)} +.mx-select-option:active{background:var(--mx-s-active)} +.mx-select-option.selected{ + background:var(--mx-primary-glow);color:var(--mx-primary); + font-weight:600; +} +.mx-select-option .mx-select-check{ + margin-left:auto;color:var(--mx-primary); + opacity:0;transition:opacity var(--mx-d1); +} +.mx-select-option.selected .mx-select-check{opacity:1} + +/* Group label */ +.mx-select-group-label{ + font-size:11px;font-weight:700;color:var(--mx-t3); + text-transform:uppercase;letter-spacing:0.06em; + padding:8px 10px 4px; +} + +/* Disabled */ +.mx-select.disabled{opacity:0.4;pointer-events:none} + +/* Mobile: trigger 加大 */ +@media(max-width:767px){ + .mx-select-trigger{height:44px;font-size:16px} + .mx-select-sm .mx-select-trigger{height:40px;font-size:15px} + .mx-select-lg .mx-select-trigger{height:50px;font-size:16px} + .mx-select-option{min-height:44px;padding:10px} +} + +/* Multi-select tags */ +.mx-select-tags{ + display:flex;flex-wrap:wrap;gap:4px;flex:1; +} +.mx-select-tag{ + display:inline-flex;align-items:center;gap:4px; + padding:2px 8px;border-radius:var(--mx-r-pill); + background:var(--mx-primary-glow); + color:var(--mx-primary); + font-size:12px;font-weight:600; +} +.mx-select-tag-remove{ + width:14px;height:14px; + display:flex;align-items:center;justify-content:center; + border-radius:50%;cursor:pointer; + transition:background var(--mx-d1); +} +.mx-select-tag-remove:hover{background:rgba(52,130,255,0.2)} + +/* ═══ DARK MODE INNER GLOW — unified with Card/Stat ═══ */ +[data-theme="dark"] .mx-select-dropdown{ + box-shadow:var(--mx-sh3),inset 0 1px 0 rgba(255,255,255,0.04); +} diff --git a/vendor/miuix-console/components/stat.css b/vendor/miuix-console/components/stat.css new file mode 100644 index 0000000..45f9164 --- /dev/null +++ b/vendor/miuix-console/components/stat.css @@ -0,0 +1,79 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Stat Card Component + ═══════════════════════════════════════════ */ + +.mx-stat{ + background:var(--mx-s2); + border-radius:var(--mx-r); + padding:16px; + position:relative;overflow:hidden; + cursor:pointer; + box-shadow:var(--mx-sh1); + transition:all var(--mx-d3) var(--mx-ease); +} +.mx-stat:hover{transition-duration:var(--mx-d2)} +.mx-stat:active{transform:scale(0.97)} + +/* 顶部 accent 线 */ +.mx-stat::before{ + content:'';position:absolute;top:0;left:0;right:0;height:2px; + background:var(--mx-stat-accent,var(--mx-primary)); + opacity:0;transition:opacity var(--mx-d2); +} +.mx-stat:hover::before{opacity:1} + +/* 右上角柔光晕 */ +.mx-stat::after{ + content:'';position:absolute;top:-40%;right:-20%; + width:100px;height:100px; + background:var(--mx-stat-accent,var(--mx-primary)); + border-radius:50%;opacity:0.04; + transition:opacity var(--mx-d2); +} +.mx-stat:hover::after{opacity:0.08} + +.mx-stat-label{ + font-size:11px;font-weight:600;color:var(--mx-t3); + text-transform:uppercase;letter-spacing:0.06em; + margin-bottom:10px;display:flex;align-items:center;gap:8px; +} + +.mx-stat-icon{ + width:26px;height:26px;border-radius:var(--mx-r-xs); + display:flex;align-items:center;justify-content:center; + transition:transform var(--mx-d2) var(--mx-ease); +} +.mx-stat:hover .mx-stat-icon{transform:scale(1.12) rotate(-3deg)} + +.mx-stat-value{ + font-size:28px;font-weight:800; + letter-spacing:-0.03em; + font-variant-numeric:tabular-nums; + line-height:1;margin-bottom:8px; +} + +.mx-stat-delta{ + display:inline-flex;align-items:center;gap:4px; + font-size:11px;font-weight:600; + padding:2px 8px;border-radius:var(--mx-r-pill); +} +.mx-stat-delta.up{background:rgba(52,211,153,0.1);color:var(--mx-green)} +.mx-stat-delta.down{background:rgba(248,113,113,0.1);color:var(--mx-red)} + +/* Grid layout */ +.mx-stat-grid{ + display:grid;grid-template-columns:repeat(2,1fr);gap:12px; +} +@media(min-width:768px){ + .mx-stat-grid{grid-template-columns:repeat(4,1fr)} +} + +/* Mobile */ +@media(max-width:767px){ + .mx-stat{padding:14px} + .mx-stat-value{font-size:22px} + .mx-stat-icon{width:24px;height:24px} + .mx-stat-icon svg{width:12px;height:12px} + .mx-stat-label{font-size:10px} + .mx-stat-delta{font-size:10px;padding:2px 6px} +} diff --git a/vendor/miuix-console/components/switch.css b/vendor/miuix-console/components/switch.css new file mode 100644 index 0000000..bdbdcf6 --- /dev/null +++ b/vendor/miuix-console/components/switch.css @@ -0,0 +1,67 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Switch Component + 对应 Miuix Switch.kt — miuixCapsuleShape + ═══════════════════════════════════════════ */ + +.mx-switch{ + display:inline-flex;align-items:center;gap:10px; + cursor:pointer; + -webkit-tap-highlight-color:transparent; +} +.mx-switch input{display:none} + +.mx-switch-track{ + width:44px;height:26px; + background:var(--mx-s4); + border-radius:var(--mx-r-pill); + position:relative; + transition:background var(--mx-d2) var(--mx-ease); + flex-shrink:0; +} +.mx-switch-thumb{ + position:absolute;top:3px;left:3px; + width:20px;height:20px; + background:#fff; + border-radius:50%; + box-shadow:0 1px 4px rgba(0,0,0,0.25); + transition:transform var(--mx-d2) var(--mx-spring); +} + +@keyframes mx-switch-bounce{ + 0%{transform:translateX(0)} + 50%{transform:translateX(22px)} + 100%{transform:translateX(20px)} +} + +/* Checked */ +.mx-switch input:checked + .mx-switch-track{ + background:var(--mx-primary); +} +.mx-switch input:checked + .mx-switch-track .mx-switch-thumb{ + animation:mx-switch-bounce 0.3s cubic-bezier(0.34,1.56,0.64,1) forwards; +} + +/* Disabled */ +.mx-switch input:disabled + .mx-switch-track{ + opacity:0.4;cursor:not-allowed; +} + +/* Label */ +.mx-switch-label{ + font-size:14px;color:var(--mx-text); + transition:color var(--mx-d1); +} + +/* Mobile: 加大触控区 */ +@media(max-width:767px){ + .mx-switch{min-height:44px;padding:4px 0} + .mx-switch-track{width:48px;height:28px} + .mx-switch-thumb{width:22px;height:22px;top:3px;left:3px} +@keyframes mx-switch-bounce-mobile{ + 0%{transform:translateX(0)} + 50%{transform:translateX(22px)} + 100%{transform:translateX(20px)} +} + .mx-switch input:checked + .mx-switch-track .mx-switch-thumb{animation:mx-switch-bounce-mobile 0.3s cubic-bezier(0.34,1.56,0.64,1) forwards} + .mx-switch-label{font-size:15px} +} diff --git a/vendor/miuix-console/components/table.css b/vendor/miuix-console/components/table.css new file mode 100644 index 0000000..7e50716 --- /dev/null +++ b/vendor/miuix-console/components/table.css @@ -0,0 +1,85 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Table Component + ═══════════════════════════════════════════ */ + +.mx-table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch} +.mx-table{width:100%;border-collapse:collapse} + +.mx-table th{ + padding:12px 16px; + font-size:11px;font-weight:700; + color:var(--mx-t3);text-align:left; + text-transform:uppercase;letter-spacing:0.06em; + border-bottom:1px solid var(--mx-divider); + white-space:nowrap;cursor:pointer; + position:relative;transition:color var(--mx-d1); + -webkit-user-select:none;user-select:none; +} +.mx-table th:hover{color:var(--mx-t2)} +/* Miuix: 表头 hover 底部亮线 */ +.mx-table th::after{ + content:'';position:absolute;bottom:0;left:14px;right:14px; + height:2px;background:var(--mx-primary);border-radius:1px; + transform:scaleX(0);transition:transform var(--mx-d2) var(--mx-ease); +} +.mx-table th:hover::after{transform:scaleX(1)} + +.mx-table td{ + padding:12px 16px; + font-size:13px; + border-bottom:1px solid var(--mx-divider); + white-space:nowrap; + transition:padding var(--mx-d1); +} + +.mx-table tbody tr{ + transition:background var(--mx-d3); +} +.mx-table tbody tr:hover{ + background:var(--mx-s-hover); + transition-duration:var(--mx-d2); +} +.mx-table tbody tr:active{background:var(--mx-s-active)} +/* Miuix: hover 时左 padding 微推 */ +.mx-table tbody tr:hover td{padding-left:18px} + +/* Mono cell */ +.mx-table .mono{ + font-family:var(--mx-mono);font-size:12px;color:var(--mx-t2); +} + +/* Compact mode */ +.mx-table-compact th{padding:8px 12px} +.mx-table-compact td{padding:10px 12px} +.mx-table-compact tbody tr:hover td{padding-left:14px} + +/* Mobile: 表格卡片化(需 data-label 属性配合) */ +@media(max-width:767px){ + .mx-table-wrap{overflow-x:visible} + .mx-table{border:0} + .mx-table thead{display:none} + .mx-table tbody tr{ + display:block;margin-bottom:12px; + border:1px solid var(--mx-divider); + border-radius:var(--mx-r-sm); + padding:12px; + background:var(--mx-s2); + } + .mx-table tbody td{ + display:flex;justify-content:space-between;align-items:center; + padding:6px 0;border-bottom:1px solid var(--mx-divider); + white-space:normal; + } + .mx-table tbody td:last-child{border-bottom:none} + .mx-table tbody td::before{ + content:attr(data-label); + font-size:11px;font-weight:700;color:var(--mx-t3); + text-transform:uppercase;letter-spacing:0.04em; + flex-shrink:0;margin-right:12px; + } + .mx-table tbody tr:hover td{padding-left:0} + .mx-table tbody tr:active{background:var(--mx-s-hover)} + + .mx-table-compact tbody tr{padding:10px} + .mx-table-compact td{padding:5px 0} +} diff --git a/vendor/miuix-console/components/tabs.css b/vendor/miuix-console/components/tabs.css new file mode 100644 index 0000000..4e928ca --- /dev/null +++ b/vendor/miuix-console/components/tabs.css @@ -0,0 +1,94 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Tabs Component + ═══════════════════════════════════════════ */ + +/* Tab bar */ +.mx-tabs{ + display:flex;align-items:center; + border-bottom:1px solid var(--mx-divider); + overflow-x:auto;scrollbar-width:none; + -webkit-overflow-scrolling:touch; +} +.mx-tabs::-webkit-scrollbar{display:none} + +/* Tab item */ +.mx-tab{ + display:flex;align-items:center;gap:6px; + padding:10px 16px; + font-size:13px;font-weight:500; + color:var(--mx-t3); + cursor:pointer;white-space:nowrap; + position:relative; + transition:all var(--mx-d1) var(--mx-ease); + -webkit-tap-highlight-color:transparent; +} +.mx-tab:hover{color:var(--mx-text)} + +/* Active indicator — bottom line */ +.mx-tab::after{ + content:'';position:absolute;bottom:0;left:8px;right:8px; + height:2px;border-radius:1px; + background:var(--mx-primary); + transform:scaleX(0); + transition:transform var(--mx-d2) var(--mx-spring); +} +.mx-tab.active{color:var(--mx-primary);font-weight:600} +.mx-tab.active::after{transform:scaleX(1)} + +/* Badge in tab */ +.mx-tab .mx-count{margin-left:4px} + +/* Disabled */ +.mx-tab.disabled{opacity:0.4;pointer-events:none} + +/* Pill variant */ +.mx-tabs-pill{ + border-bottom:none; + background:var(--mx-s3); + border-radius:var(--mx-r-pill); + padding:3px;gap:2px; +} +.mx-tabs-pill .mx-tab{ + border-radius:var(--mx-r-pill); + padding:6px 14px; +} +.mx-tabs-pill .mx-tab::after{display:none} +.mx-tabs-pill .mx-tab.active{ + background:var(--mx-primary);color:#fff; + box-shadow:0 2px 8px rgba(76,139,245,0.25); +} + +/* Card variant */ +.mx-tabs-card{ + border-bottom:none;gap:4px; +} +.mx-tabs-card .mx-tab{ + border-radius:var(--mx-r-sm); + padding:8px 14px; +} +.mx-tabs-card .mx-tab::after{display:none} +.mx-tabs-card .mx-tab.active{ + background:var(--mx-primary-glow);color:var(--mx-primary); +} + +/* Tab content */ +.mx-tab-panel{display:none;padding:16px 0} +.mx-tab-panel.active{display:block} + +/* Alignment */ +.mx-tabs-center{justify-content:center} +.mx-tabs-end{justify-content:flex-end} + +/* Mobile: 触控友好 + 可滚动 */ +@media(max-width:767px){ + .mx-tab{padding:12px 14px;min-height:44px;font-size:14px} + .mx-tabs-pill .mx-tab{padding:8px 16px} + .mx-tabs-card .mx-tab{padding:10px 14px} + /* 隐藏超出的 badge */ + .mx-tab .mx-count{display:none} +} + +/* Tablet: 可滚动 */ +@media(min-width:768px) and (max-width:1023px){ + .mx-tabs{overflow-x:auto;-webkit-overflow-scrolling:touch} +} diff --git a/vendor/miuix-console/components/tag.css b/vendor/miuix-console/components/tag.css new file mode 100644 index 0000000..6bd90f7 --- /dev/null +++ b/vendor/miuix-console/components/tag.css @@ -0,0 +1,94 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Tag / Chip Component + ═══════════════════════════════════════════ */ + +.mx-tag{ + display:inline-flex;align-items:center;gap:4px; + padding:4px 10px; + border-radius:var(--mx-r-pill); + font-size:12px;font-weight:600; + background:var(--mx-s3);color:var(--mx-text); + transition:all var(--mx-d1) var(--mx-ease); + cursor:default; + -webkit-tap-highlight-color:transparent; +} + +/* Clickable */ +.mx-tag-clickable{cursor:pointer} +.mx-tag-clickable:hover{background:var(--mx-s4)} +.mx-tag-clickable:active{transform:scale(0.95)} + +/* Color variants */ +.mx-tag-primary{background:var(--mx-primary-glow);color:var(--mx-primary)} +.mx-tag-success{background:rgba(52,211,153,0.1);color:var(--mx-green)} +.mx-tag-warning{background:rgba(251,191,36,0.1);color:var(--mx-amber)} +.mx-tag-error{background:rgba(248,113,113,0.1);color:var(--mx-red)} +.mx-tag-info{background:rgba(96,165,250,0.1);color:var(--mx-blue)} + +/* Outline variant */ +.mx-tag-outline{ + background:transparent; + border:1px solid var(--mx-border); +} +.mx-tag-outline.mx-tag-primary{border-color:var(--mx-primary);color:var(--mx-primary)} +.mx-tag-outline.mx-tag-success{border-color:var(--mx-green);color:var(--mx-green)} +.mx-tag-outline.mx-tag-warning{border-color:var(--mx-amber);color:var(--mx-amber)} +.mx-tag-outline.mx-tag-error{border-color:var(--mx-red);color:var(--mx-red)} + +/* With dot */ +.mx-tag-dot::before{ + content:'';width:6px;height:6px; + border-radius:50%;flex-shrink:0; +} +.mx-tag-primary.mx-tag-dot::before{background:var(--mx-primary)} +.mx-tag-success.mx-tag-dot::before{background:var(--mx-green)} +.mx-tag-warning.mx-tag-dot::before{background:var(--mx-amber)} +.mx-tag-error.mx-tag-dot::before{background:var(--mx-red)} +.mx-tag-info.mx-tag-dot::before{background:var(--mx-blue)} + +/* Close button */ +.mx-tag-close{ + width:14px;height:14px; + display:flex;align-items:center;justify-content:center; + border-radius:50%;cursor:pointer; + transition:background var(--mx-d1); +} +.mx-tag-close:hover{background:rgba(0,0,0,0.1)} +[data-theme="dark"] .mx-tag-close:hover{background:rgba(255,255,255,0.1)} + +/* Sizes */ +.mx-tag-sm{padding:2px 8px;font-size:11px} +.mx-tag-lg{padding:6px 14px;font-size:13px} + +/* Tag group */ +.mx-tag-group{ + display:flex;flex-wrap:wrap;gap:6px; +} + +/* Input tag — for editable tag list */ +.mx-tag-input{ + display:flex;flex-wrap:wrap;gap:6px; + padding:6px 10px; + background:var(--mx-s3); + border:1px solid var(--mx-border); + border-radius:var(--mx-r-sm); + min-height:40px;align-items:center; + transition:all var(--mx-d2) var(--mx-ease); + cursor:text; +} + +/* Mobile */ +@media(max-width:767px){ + .mx-tag{min-height:32px;padding:4px 12px} + .mx-tag-lg{min-height:38px} + .mx-tag-close{width:18px;height:18px} +} +.mx-tag-input:focus-within{ + border-color:var(--mx-primary); + box-shadow:0 0 0 3px var(--mx-primary-glow); +} +.mx-tag-input input{ + flex:1;min-width:60px;border:none;outline:none; + background:none;font-size:13px;color:var(--mx-text); +} +.mx-tag-input input::placeholder{color:var(--mx-t3)} diff --git a/vendor/miuix-console/components/toast.css b/vendor/miuix-console/components/toast.css new file mode 100644 index 0000000..e47f9c7 --- /dev/null +++ b/vendor/miuix-console/components/toast.css @@ -0,0 +1,98 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Toast / Snackbar Component + Miuix 轻量反馈 — 底部弹出 + 自动消失 + ═══════════════════════════════════════════ */ + +/* Container */ +.mx-toast-container{ + position:fixed;bottom:16px;left:50%; + transform:translateX(-50%); + z-index:2000; + display:flex;flex-direction:column-reverse;align-items:center;gap:8px; + pointer-events:none; + padding-bottom:var(--mx-safe-b); +} + +/* Toast */ +.mx-toast{ + display:flex;align-items:center;gap:10px; + padding:10px 16px; + background:var(--mx-s4); + border-radius:var(--mx-r-sm); + box-shadow:var(--mx-sh3); + font-size:13px;font-weight:500; + color:var(--mx-text); + pointer-events:auto; + max-width:calc(100vw - 32px); + transform:translateY(12px) scale(0.95); + opacity:0; + transition:transform var(--mx-d3) var(--mx-spring), + opacity var(--mx-d2) var(--mx-ease); +} +.mx-toast.show{ + transform:translateY(0) scale(1); + opacity:1; +} +.mx-toast.hide{ + transform:translateY(-8px) scale(0.95); + opacity:0; +} + +/* Icon */ +.mx-toast-icon{ + width:20px;height:20px;flex-shrink:0; + display:flex;align-items:center;justify-content:center; +} +.mx-toast-icon svg{width:18px;height:18px} + +/* Variants */ +.mx-toast-success .mx-toast-icon{color:var(--mx-green)} +.mx-toast-warning .mx-toast-icon{color:var(--mx-amber)} +.mx-toast-error .mx-toast-icon{color:var(--mx-red)} +.mx-toast-info .mx-toast-icon{color:var(--mx-blue)} + +/* Action button */ +.mx-toast-action{ + padding:4px 10px;border-radius:var(--mx-r-xs); + font-size:12px;font-weight:600; + cursor:pointer; + transition:all var(--mx-d1) var(--mx-ease); + flex-shrink:0; +} +.mx-toast-action{color:var(--mx-primary)} +.mx-toast-action:hover{background:var(--mx-primary-glow)} + +/* Close button */ +.mx-toast-close{ + width:20px;height:20px; + display:flex;align-items:center;justify-content:center; + border-radius:var(--mx-r-xs); + cursor:pointer;color:var(--mx-t3); + transition:all var(--mx-d1); + flex-shrink:0; +} +.mx-toast-close:hover{color:var(--mx-text)} + +/* Progress bar (auto dismiss) */ +.mx-toast-progress{ + position:absolute;bottom:0;left:0; + height:2px;border-radius:0 0 var(--mx-r-sm) var(--mx-r-sm); + background:var(--mx-primary); + opacity:0.4; + transition:width linear; +} + +/* Mobile — full width + 底部安全区 */ +@media(max-width:767px){ + .mx-toast-container{ + left:8px;right:8px; + transform:none; + align-items:stretch; + } + .mx-toast{border-radius:var(--mx-r)} +} + +/* Tablet */ +@media(min-width:768px) and (max-width:1023px){ + .mx-toast-container{bottom:20px} +} diff --git a/vendor/miuix-console/components/tooltip.css b/vendor/miuix-console/components/tooltip.css new file mode 100644 index 0000000..aa374a8 --- /dev/null +++ b/vendor/miuix-console/components/tooltip.css @@ -0,0 +1,54 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Tooltip Component + ═══════════════════════════════════════════ */ + +.mx-tooltip{ + position:relative; +} +.mx-tooltip::after{ + content:attr(data-tooltip); + position:absolute; + bottom:calc(100% + 8px);left:50%; + transform:translateX(-50%) translateY(4px); + padding:5px 10px; + background:var(--mx-s4); + color:var(--mx-text); + font-size:12px;font-weight:500; + white-space:nowrap; + border-radius:var(--mx-r-xs); + box-shadow:var(--mx-sh2); + opacity:0;visibility:hidden; + pointer-events:none; + transition:all var(--mx-d2) var(--mx-spring); + z-index:500; +} +.mx-tooltip:hover::after{ + opacity:1;visibility:visible; + transform:translateX(-50%) translateY(0); +} + +/* Position variants */ +.mx-tooltip-bottom::after{ + bottom:auto;top:calc(100% + 8px); + transform:translateX(-50%) translateY(-4px); +} +.mx-tooltip-bottom:hover::after{ + transform:translateX(-50%) translateY(0); +} + +.mx-tooltip-left::after{ + bottom:auto;left:auto; + right:calc(100% + 8px);top:50%; + transform:translateY(-50%) translateX(4px); +} +.mx-tooltip-left:hover::after{ + transform:translateY(-50%) translateX(0); +} + +.mx-tooltip-right::after{ + bottom:auto;left:calc(100% + 8px);top:50%; + transform:translateY(-50%) translateX(-4px); +} +.mx-tooltip-right:hover::after{ + transform:translateY(-50%) translateX(0); +} diff --git a/vendor/miuix-console/core/animations.css b/vendor/miuix-console/core/animations.css new file mode 100644 index 0000000..10150f3 --- /dev/null +++ b/vendor/miuix-console/core/animations.css @@ -0,0 +1,71 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Animations + ═══════════════════════════════════════════ */ + +/* ── Entry animations ── */ +@keyframes mx-slide-up{ + from{opacity:0;transform:translateY(16px)} + to{opacity:1;transform:translateY(0)} +} +@keyframes mx-card-in{ + from{opacity:0;transform:scale(0.96) translateY(10px)} + to{opacity:1;transform:scale(1) translateY(0)} +} +@keyframes mx-fade-item{ + from{opacity:0;transform:translateX(-8px)} + to{opacity:1;transform:translateX(0)} +} +@keyframes mx-fade-row{ + from{opacity:0;transform:translateY(4px)} + to{opacity:1;transform:translateY(0)} +} +@keyframes mx-bar-grow{ + from{transform:scaleY(0);opacity:0} + to{transform:scaleY(1);opacity:1} +} +@keyframes mx-pulse{ + 0%,100%{opacity:1;transform:scale(1)} + 50%{opacity:0.5;transform:scale(1.3)} +} +@keyframes mx-dot-in{ + from{transform:translateX(-50%) scale(0);opacity:0} + to{transform:translateX(-50%) scale(1);opacity:1} +} +@keyframes mx-ripple{ + to{transform:scale(4);opacity:0} +} +@keyframes mx-shimmer{ + from{background-position:200% 0} + to{background-position:-200% 0} +} + +/* ── Animation classes ── */ +.mx-anim-slide-up{animation:mx-slide-up var(--mx-d3) var(--mx-ease)} +.mx-anim-card-in{animation:mx-card-in var(--mx-d3) var(--mx-ease) both} +.mx-anim-fade-item{animation:mx-fade-item var(--mx-d3) var(--mx-ease) both} +.mx-anim-fade-row{animation:mx-fade-row var(--mx-d3) var(--mx-ease) both} +.mx-anim-bar-grow{ + transform-origin:bottom; + animation:mx-bar-grow var(--mx-d4) var(--mx-ease) both; +} + +/* ── Stagger delays ── */ +.mx-delay-0{animation-delay:0ms} +.mx-delay-1{animation-delay:50ms} +.mx-delay-2{animation-delay:100ms} +.mx-delay-3{animation-delay:150ms} +.mx-delay-4{animation-delay:200ms} +.mx-delay-5{animation-delay:250ms} +.mx-delay-6{animation-delay:300ms} +.mx-delay-7{animation-delay:350ms} +.mx-delay-8{animation-delay:400ms} + +/* ── Skeleton loading — dual-layer overlay ── */ +.mx-skeleton{ + background: + linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.06) 50%, transparent 75%), + var(--mx-s3); + background-size:200% 100%, 100% 100%; + animation:mx-shimmer 1.5s ease-in-out infinite; + border-radius:var(--mx-r-sm); +} diff --git a/vendor/miuix-console/core/base.css b/vendor/miuix-console/core/base.css new file mode 100644 index 0000000..63c6549 --- /dev/null +++ b/vendor/miuix-console/core/base.css @@ -0,0 +1,50 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Base Reset & Globals + ═══════════════════════════════════════════ */ +*,*::before,*::after{margin:0;padding:0;box-sizing:border-box} +html{font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} +body{ + font-family:var(--mx-font); + background:var(--mx-bg); + color:var(--mx-text); + overflow-x:hidden; + min-height:100dvh; +} +button,input,textarea,select{font-family:inherit;border:none;outline:none;background:none} +ul,ol{list-style:none} +a{text-decoration:none;color:inherit} +img{display:block;max-width:100%} + +/* Smooth scroll */ +html{scroll-behavior:smooth} + +/* Selection */ +::selection{background:var(--mx-primary-glow);color:var(--mx-text)} + +/* Scrollbar — Miuix thin style, auto-hide */ +::-webkit-scrollbar{width:4px;height:4px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--mx-s4);border-radius:2px;opacity:0;transition:opacity 0.3s} +::-webkit-scrollbar-thumb:hover{background:var(--mx-t3)} +body:hover ::-webkit-scrollbar-thumb{opacity:1} + +/* Firefox scrollbar auto-hide */ +*{scrollbar-color:transparent transparent} +body:hover *{scrollbar-color:var(--mx-s4) transparent} + +/* Prevent selection on interactive */ +.no-select{-webkit-user-select:none;user-select:none} + +/* ═══ DARK MODE INNER GLOW — Miuix signature depth ═══ */ +[data-theme="dark"] .mx-card, +[data-theme="dark"] .mx-stat{ + box-shadow:var(--mx-sh1),inset 0 1px 0 rgba(255,255,255,0.04); +} + +/* ═══ prefers-reduced-motion — kill all animation ═══ */ +@media(prefers-reduced-motion:reduce){ + *,*::before,*::after{ + animation-duration:0.01ms !important; + transition-duration:0.01ms !important; + } +} diff --git a/vendor/miuix-console/core/glass.css b/vendor/miuix-console/core/glass.css new file mode 100644 index 0000000..473e6e7 --- /dev/null +++ b/vendor/miuix-console/core/glass.css @@ -0,0 +1,230 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Liquid Glass Effect v2 + 液体玻璃效果系统(完整版) + 通过 [data-glass="liquid"] 属性启用 + ═══════════════════════════════════════════ */ + +/* ── Glass Variables ── */ +:root { + --mx-glass-blur: 12px; + --mx-glass-saturation: 180%; + --mx-glass-displacement: 25; + --mx-glass-aberration: 2; + --mx-glass-elasticity: 0.15; + --mx-glass-corner-radius: 16px; + --mx-glass-activation-zone: 200; + --mx-glass-bg-light: rgba(255, 255, 255, 0.25); + --mx-glass-bg-dark: rgba(0, 0, 0, 0.35); + --mx-glass-border-light: rgba(255, 255, 255, 0.2); + --mx-glass-border-dark: rgba(255, 255, 255, 0.08); + --mx-glass-mouse-x: 0; + --mx-glass-mouse-y: 0; +} + +/* ═══════════════════════════════════════════ + DARK MODE (default) + ═══════════════════════════════════════════ */ + +/* ── TopBar ── */ +[data-glass="liquid"] .mx-topbar { + background: rgba(22, 22, 22, 0.45); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border-bottom: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Sidebar ── */ +[data-glass="liquid"] .mx-sidebar { + background: rgba(22, 22, 22, 0.45); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border-right: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Bottom Nav ── */ +[data-glass="liquid"] .mx-bottom-nav { + background: rgba(22, 22, 22, 0.45); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border-top: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Modal ── */ +[data-glass="liquid"] .mx-modal { + background: rgba(30, 30, 30, 0.5); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Drawer ── */ +[data-glass="liquid"] .mx-drawer { + background: rgba(22, 22, 22, 0.5); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border-left: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Popover ── */ +[data-glass="liquid"] .mx-popover { + background: rgba(30, 30, 30, 0.5); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Select Dropdown ── */ +[data-glass="liquid"] .mx-select-dropdown { + background: rgba(30, 30, 30, 0.5); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ── Card (opt-in via .mx-card-glass) ── */ +[data-glass="liquid"] .mx-card-glass { + background: rgba(30, 30, 30, 0.4); + backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + -webkit-backdrop-filter: blur(var(--mx-glass-blur)) saturate(var(--mx-glass-saturation)); + border: 1px solid var(--mx-glass-border-dark); + filter: url(#mx-glass-filter); +} + +/* ═══════════════════════════════════════════ + LIGHT MODE + ═══════════════════════════════════════════ */ + +[data-theme="light"][data-glass="liquid"] .mx-topbar { + background: rgba(255, 255, 255, 0.45); + border-bottom: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-sidebar { + background: rgba(255, 255, 255, 0.45); + border-right: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-bottom-nav { + background: rgba(255, 255, 255, 0.45); + border-top: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-modal { + background: rgba(255, 255, 255, 0.5); + border: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-drawer { + background: rgba(255, 255, 255, 0.5); + border-left: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-popover { + background: rgba(255, 255, 255, 0.5); + border: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-select-dropdown { + background: rgba(255, 255, 255, 0.5); + border: 1px solid var(--mx-glass-border-light); +} + +[data-theme="light"][data-glass="liquid"] .mx-card-glass { + background: rgba(255, 255, 255, 0.4); + border: 1px solid var(--mx-glass-border-light); +} + +/* ═══════════════════════════════════════════ + OVER-LIGHT 模式 + 亮色背景上使用的深色玻璃 + ═══════════════════════════════════════════ */ + +[data-glass="liquid"][data-glass-over-light="true"] .mx-topbar, +[data-glass="liquid"][data-glass-over-light="true"] .mx-sidebar, +[data-glass="liquid"][data-glass-over-light="true"] .mx-bottom-nav, +[data-glass="liquid"][data-glass-over-light="true"] .mx-modal, +[data-glass="liquid"][data-glass-over-light="true"] .mx-drawer, +[data-glass="liquid"][data-glass-over-light="true"] .mx-popover, +[data-glass="liquid"][data-glass-over-light="true"] .mx-select-dropdown, +[data-glass="liquid"][data-glass-over-light="true"] .mx-card-glass { + background: rgba(0, 0, 0, 0.45); + border-color: rgba(255, 255, 255, 0.1); +} + +/* ═══════════════════════════════════════════ + GLASS 可追踪元素 + 鼠标跟踪 + 弹性变换 + 双层边框 + ═══════════════════════════════════════════ */ + +.mx-glass-trackable { + position: relative; + transition: transform 0.2s ease-out; + will-change: transform; +} + +.mx-glass-trackable .mx-glass-border-layer { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + z-index: 2; +} + +/* Hover 高光层 */ +.mx-glass-trackable::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-out; + background-image: radial-gradient(circle at 50% 0%, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0) 50%); + mix-blend-mode: overlay; + z-index: 3; +} + +.mx-glass-trackable:hover::after { + opacity: 0.5; +} + +.mx-glass-trackable:active::after { + opacity: 0.8; + background-image: radial-gradient(circle at 50% 0%, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 80%); +} + +/* ═══════════════════════════════════════════ + FALLBACK — browsers without SVG filter support + ═══════════════════════════════════════════ */ +@supports not (filter: url(#mx-glass-filter)) { + [data-glass="liquid"] .mx-topbar, + [data-glass="liquid"] .mx-sidebar, + [data-glass="liquid"] .mx-bottom-nav, + [data-glass="liquid"] .mx-modal, + [data-glass="liquid"] .mx-drawer, + [data-glass="liquid"] .mx-popover, + [data-glass="liquid"] .mx-select-dropdown, + [data-glass="liquid"] .mx-card-glass { + filter: none; + } +} + +/* ═══════════════════════════════════════════ + REDUCED MOTION + ═══════════════════════════════════════════ */ +@media (prefers-reduced-motion: reduce) { + .mx-glass-trackable { + transition: none !important; + transform: none !important; + } + .mx-glass-trackable::after { + transition: none !important; + } +} diff --git a/vendor/miuix-console/core/tokens.css b/vendor/miuix-console/core/tokens.css new file mode 100644 index 0000000..6559c3e --- /dev/null +++ b/vendor/miuix-console/core/tokens.css @@ -0,0 +1,198 @@ +/* ═══════════════════════════════════════════ + MIUIX CONSOLE — Design Tokens + 直接从 Miuix Colors.kt lightColorScheme() / darkColorScheme() 移植 + ═══════════════════════════════════════════ */ + +:root { + /* ── Primary — from Miuix lightColorScheme primary = Color(0xFF3482FF) ── */ + --mx-primary: #3482FF; + --mx-primary-hover: #5D9BFF; + --mx-primary-active: #277AF7; + --mx-primary-on: #FFFFFF; + --mx-primary-glow: rgba(52,130,255,0.12); + --mx-primary-container: #5D9BFF; + --mx-primary-container-on: #FFFFFF; + --mx-primary-variant: #3482FF; + --mx-primary-variant-on: #AECDFF; + --mx-tertiary-container: #EAF2FF; + --mx-tertiary-container-on: #3482FF; + + /* ── Error — from Miuix error = Color(0xFFE94634) ── */ + --mx-error: #E94634; + --mx-error-on: #FFFFFF; + --mx-error-container: #FDF6F4; + --mx-error-container-on: #410002; + + /* ── Semantic ── */ + --mx-green: #34D399; + --mx-amber: #FBBF24; + --mx-blue: #60A5FA; + + /* ── Surface — from Miuix lightColorScheme() 源码 ── */ + --mx-bg: #FFFFFF; /* background = White */ + --mx-surface: #F7F7F7; /* surface = 0xFFF7F7F7 */ + --mx-surface-variant: #FFFFFF; /* surfaceVariant = White */ + --mx-s1: #F7F7F7; /* surface */ + --mx-s2: #FFFFFF; /* surfaceContainer = White */ + --mx-s3: #E8E8E8; /* surfaceContainerHigh = 0xFFE8E8E8 */ + --mx-s4: #E8E8E8; /* surfaceContainerHighest = 0xFFE8E8E8 */ + --mx-s-hover: rgba(0,0,0,0.04); + --mx-s-active: rgba(0,0,0,0.07); + + /* ── Text — from Miuix 源码 ── */ + --mx-text: #000000; /* onSurface = Black */ + --mx-text-on-surface: rgba(0,0,0,0.8); /* onSurfaceSecondary = 0xCC000000 */ + --mx-text-variant: rgba(0,0,0,0.6); /* onSurfaceVariantSummary = 0x99000000 */ + --mx-text-actions: rgba(0,0,0,0.4); /* onSurfaceVariantActions = 0x66000000 */ + --mx-t2: rgba(0,0,0,0.6); + --mx-t3: rgba(0,0,0,0.4); + --mx-text-bg-variant: #8C93B0; /* onBackgroundVariant */ + --mx-text-container-variant: #959595; /* onSurfaceContainerVariant */ + --mx-text-container-high: #A2A2A2; /* onSurfaceContainerHigh */ + --mx-text-disabled: #B2B2B2; /* disabledOnSurface */ + + /* ── Secondary — from Miuix 源码 ── */ + --mx-secondary: #E6E6E6; /* secondary */ + --mx-secondary-variant: #F0F0F0; /* secondaryVariant */ + --mx-secondary-container: #F0F0F0; + --mx-secondary-container-on: #A9A9A9; + + /* ── Border / Divider — from Miuix 源码 ── */ + --mx-border: #D9D9D9; /* outline = 0xFFD9D9D9 */ + --mx-divider: #E0E0E0; /* dividerLine = 0xFFE0E0E0 */ + + /* ── Disabled — from Miuix 源码 ── */ + --mx-disabled-primary: #C2D9FF; + --mx-disabled-primary-on: #F3F8FF; + --mx-disabled-secondary: #F0F0F0; + --mx-disabled-secondary-on: #FCFCFC; + + /* ── Window ── */ + --mx-window-dim: rgba(0,0,0,0.3); /* windowDimming */ + + /* ── Slider — from Miuix 源码 ── */ + --mx-slider-bg: rgba(0,0,0,0.06); /* 0x0F000000 */ + --mx-slider-key: rgba(163,179,205,0.3); + --mx-slider-key-fg: #6EB5FF; + + /* ── Radius (Miuix Smooth Corner — 16dp default) ── */ + --mx-r: 16px; + --mx-r-sm: 12px; + --mx-r-xs: 8px; + --mx-r-pill: 9999px; + + /* ── Shadow (Miuix multi-layer soft shadow) ── */ + --mx-sh1: 0 1px 4px rgba(0,0,0,0.08); + --mx-sh2: 0 4px 16px rgba(0,0,0,0.06); + --mx-sh3: 0 8px 32px rgba(0,0,0,0.08); + --mx-glow: 0 0 0 1px rgba(52,130,255,0.06), 0 2px 12px rgba(52,130,255,0.04); + + /* ── Motion (Miuix spring + ease) ── */ + --mx-ease: cubic-bezier(0.16, 1, 0.3, 1); + --mx-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --mx-d1: 100ms; + --mx-d2: 200ms; + --mx-d3: 320ms; + --mx-d4: 500ms; + + /* ── Typography ── */ + --mx-font: "MiSans", "Mi Sans", -apple-system, "PingFang SC", "Noto Sans SC", sans-serif; + --mx-mono: "JetBrains Mono", "SF Mono", monospace; + + /* Typography scale */ + --mx-text-xs: 11px; + --mx-text-sm: 12px; + --mx-text-base: 13px; + --mx-text-md: 14px; + --mx-text-lg: 15px; + --mx-text-xl: 16px; + --mx-text-2xl: 20px; + --mx-text-3xl: 24px; + + /* ── Safe area ── */ + --mx-safe-t: env(safe-area-inset-top, 0px); + --mx-safe-b: env(safe-area-inset-bottom, 0px); +} + +/* ═══════════════════════════════════════════ + DARK MODE — from Miuix darkColorScheme() 源码 + ═══════════════════════════════════════════ */ +[data-theme="dark"] { + /* Primary — darkColorScheme primary = Color(0xFF277AF7) */ + --mx-primary: #277AF7; + --mx-primary-hover: #338FE4; + --mx-primary-active: #0073DD; + --mx-primary-on: #FFFFFF; + --mx-primary-glow: rgba(39,122,247,0.15); + --mx-primary-container: #338FE4; + --mx-primary-container-on: #FFFFFF; + --mx-primary-variant: #0073DD; + --mx-primary-variant-on: #99C7F1; + --mx-tertiary-container: #2B3B54; + --mx-tertiary-container-on: #4788FF; + + /* Error — darkColorScheme error = Color(0xFFF12522) */ + --mx-error: #F12522; + --mx-error-on: #FFFFFF; + --mx-error-container: #2E0603; + --mx-error-container-on: #FFDAD6; + + /* Surface — 5-level gradient with 3-5% brightness steps */ + --mx-bg: #161616; /* 页面背景 — 最底层 */ + --mx-surface: #0C0C0C; /* surface = s1 */ + --mx-surface-variant: #1C1C1C; /* surfaceVariant = s2 */ + --mx-s1: #0C0C0C; /* 导航栏、侧边栏 — 比 bg 深 */ + --mx-s2: #1C1C1C; /* 卡片 — 比 bg 浅 */ + --mx-s3: #242424; /* 输入框、浮层 — 比 s2 浅 */ + --mx-s4: #2C2C2C; /* 下拉菜单 — 最亮 */ + --mx-s-hover: rgba(255,255,255,0.04); + --mx-s-active: rgba(255,255,255,0.07); + + /* Text — from Miuix darkColorScheme() 源码 */ + --mx-text: #F2F2F2; /* onSurface = 0xFFF2F2F2 */ + --mx-text-on-surface: rgba(255,255,255,0.8); /* 0xCCFFFFFF */ + --mx-text-variant: rgba(255,255,255,0.5); /* 0x80FFFFFF */ + --mx-text-actions: rgba(255,255,255,0.4); /* 0x66FFFFFF */ + --mx-t2: rgba(255,255,255,0.6); + --mx-t3: rgba(255,255,255,0.4); + --mx-text-bg-variant: #787E96; + --mx-text-container-variant: #737373; + --mx-text-container-high: #666666; + --mx-text-disabled: #666666; + + /* Secondary — from Miuix darkColorScheme() 源码 */ + --mx-secondary: #505050; + --mx-secondary-variant: #434343; + --mx-secondary-container: #434343; + --mx-secondary-container-on: #7C7C7C; + + /* Border / Divider — 提升对比度 */ + --mx-border: #484848; /* outline = 提升到可见 */ + --mx-divider: #404040; /* dividerLine = 提升到可见 */ + + /* Disabled */ + --mx-disabled-primary: #253E64; + --mx-disabled-primary-on: #677993; + --mx-disabled-secondary: #3F3F3F; + --mx-disabled-secondary-on: #797979; + + /* Window */ + --mx-window-dim: rgba(0,0,0,0.6); + + /* Slider */ + --mx-slider-bg: rgba(255,255,255,0.15); + --mx-slider-key: rgba(122,138,166,0.3); + --mx-slider-key-fg: #5DAAFF; + + /* Shadow — dark mode 阴影更重 */ + --mx-sh1: 0 1px 4px rgba(0,0,0,0.5); + --mx-sh2: 0 4px 16px rgba(0,0,0,0.4); + --mx-sh3: 0 8px 32px rgba(0,0,0,0.45); + --mx-glow: 0 0 0 1px rgba(39,122,247,0.08), 0 2px 12px rgba(39,122,247,0.06); +} + +/* ═══ COMPACT DENSITY ═══ */ +[data-density="compact"] { + --mx-r: 14px; + --mx-r-sm: 10px; +} diff --git a/web/src/admin/App.vue b/web/src/admin/App.vue index cb54af9..a78fb6f 100644 --- a/web/src/admin/App.vue +++ b/web/src/admin/App.vue @@ -1,65 +1,7 @@ - - diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts new file mode 100644 index 0000000..c9bfd52 --- /dev/null +++ b/web/src/admin/api/client.ts @@ -0,0 +1,172 @@ +import { ref } from 'vue' +import { ApiError, createApiClient } from '@/shared/api' +import type { + AthleteStanding, + AuditLog, + CurrentUser, + DisplayCommand, + EntryWithResult, + Event, + EventSettings, + HealthInfo, + Page, + PageQuery, + PointAdjustment, + PublishPayload, + RecordEntry, + Result, + SaveDraftPayload, + ScoringProfile, + Sport, + StandingRow, + Team, + TeamBreakdown, +} from '@/shared' +import { mockDb } from './mock' + +/** + * 管理端数据入口。 + * + * 后端未就绪(连接失败 / 超时 / 响应不是约定结构)时自动降级到内存示例数据, + * 并把 `mockMode` 置为 true,界面据此显示「示例数据」标记。 + * 业务错误(鉴权失败、校验失败、冲突)不降级——那些是真实服务端的回答,必须原样呈现。 + */ + +const FALLBACK_CODES = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED'] + +export const mockMode = ref(false) +export const mockReason = ref(null) + +let unauthorizedHandler: (() => void) | null = null + +export function setUnauthorizedHandler(handler: () => void): void { + unauthorizedHandler = handler +} + +const client = createApiClient({ + onUnauthorized: () => unauthorizedHandler?.(), +}) + +function isFallback(error: unknown): boolean { + // 404 表示该接口在当前服务端尚未实现(或整个后端没起来),同样按「未就绪」降级。 + return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404) +} + +async function call(real: () => Promise, mock: () => T | Promise): Promise { + try { + const value = await real() + if (mockMode.value) { + mockMode.value = false + mockReason.value = null + } + return value + } catch (error) { + if (!isFallback(error)) throw error + mockMode.value = true + mockReason.value = error instanceof Error ? error.message : '无法连接服务端' + return await mock() + } +} + +export interface Bootstrap { + settings: EventSettings + teams: Team[] + sports: Sport[] + events: Event[] + profiles: ScoringProfile[] + standings: StandingRow[] + athleteStandings: AthleteStanding[] +} + +async function bootstrap(): Promise { + return call( + async () => { + const [settings, teams, sports, events, profiles, standings, athleteStandings] = + await Promise.all([ + client.settings.get(), + client.teams.list(), + client.sports.list(), + client.events.list(), + client.scoringProfiles.list(), + client.standings.teams(), + client.standings.athletes(), + ]) + return { settings, teams, sports, events, profiles, standings, athleteStandings } + }, + () => mockDb.bootstrap(), + ) +} + +export const adminApi = { + client, + bootstrap, + + eventDetail: (id: number): Promise => + call(() => client.events.get(id), () => mockDb.eventDetail(id)), + + entries: (eventId: number): Promise => + call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)), + + saveDraft: (payload: SaveDraftPayload): Promise => + call(() => client.results.saveDraft(payload), () => mockDb.saveDraft(payload)), + + publish: (payload: PublishPayload): Promise => + call(() => client.results.publish(payload), () => mockDb.publish(payload)), + + unpublish: (payload: PublishPayload): Promise => + call(() => client.results.unpublish(payload), () => mockDb.unpublish(payload)), + + standings: (): Promise => + call(() => client.standings.teams(), () => mockDb.standings()), + + athleteStandings: (): Promise => + call(() => client.standings.athletes(), () => mockDb.athleteStandings()), + + breakdown: (teamId: number): Promise => + call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)), + + records: (): Promise => + call(() => client.records.list(), () => mockDb.records()), + + adjustments: (query: PageQuery = {}): Promise> => + call(() => client.adjustments.list(query), () => mockDb.adjustments()), + + auditLogs: (query: PageQuery = {}): Promise> => + call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)), + + displayCommand: (command: DisplayCommand): Promise => + call(() => client.display.command(command), () => undefined), + + login: (username: string, password: string): Promise => + call( + () => client.auth.login(username, password), + () => mockDb.login(username), + ), + + logout: (): Promise => call(() => client.auth.logout(), () => undefined), + + me: (): Promise => + call( + () => client.auth.me(), + () => { + // 示例数据模式下没有真实会话,统一按「未登录」处理。 + throw new ApiError('AUTH_REQUIRED', '示例数据模式下无会话', 0) + }, + ), + + health: (): Promise => + call( + () => client.health(), + () => mockDb.health(), + ), + + exportUrl: (kind: 'standings' | 'breakdown' | 'results'): string | null => { + if (mockMode.value) return null + if (kind === 'standings') return client.exports.standingsUrl() + if (kind === 'breakdown') return client.exports.breakdownUrl({}) + return client.exports.resultsUrl() + }, +} + +export type AdminApi = typeof adminApi +export { ApiError } diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts new file mode 100644 index 0000000..b5250c5 --- /dev/null +++ b/web/src/admin/api/mock.ts @@ -0,0 +1,731 @@ +import type { + AthleteStanding, + AuditLog, + CurrentUser, + DraftResultInput, + Entry, + EntryWithResult, + Event, + EventCategory, + EventSettings, + HealthInfo, + Page, + PointAdjustment, + PublishPayload, + RecordEntry, + Result, + ResultStatus, + ResultType, + SaveDraftPayload, + ScoreChannel, + ScoringProfile, + Sport, + StandingRow, + Team, + TeamBreakdown, + BreakdownItem, +} from '@/shared' +import { computeEventScore, type ScorableRow } from './scoring' + +/** + * 后端未就绪时的本地示例数据源。 + * + * 只覆盖管理端真正用到的读 / 写路径,并在内存里模拟草稿 → 发布 → 积分榜变化, + * 保证「后端没起来」时录入页依然能完整走通交互。所有界面必须显式标注「示例数据」。 + */ + +const ATHLETES = ['张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛', '吴敏', '郑浩'] + +const TEAMS: Team[] = [ + ['JG', '机关本部', '机关'], + ['F1', '第一分公司', '一分公司'], + ['F2', '第二分公司', '二分公司'], + ['F3', '第三分公司', '三分公司'], + ['F4', '第四分公司', '四分公司'], + ['F5', '第五分公司', '五分公司'], + ['F6', '第六分公司', '六分公司'], +].map(([code, name, short], index) => ({ + id: index + 1, + code: code as string, + name: name as string, + shortName: short as string, + logoPath: null, + color: ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C'][index] ?? null, + sortOrder: index + 1, + active: true, +})) + +const 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 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 { + id: number + sportId: number + code: string + name: string + eventType: Event['eventType'] + resultMode: Event['resultMode'] + resultType: ResultType + compare: Event['compare'] + scored: boolean + categoryName: string + /** 已发布的成绩:单位序号(从 1 开始)+ 成绩文本 + 手动名次(团体项目用)。 */ + published?: Array<[number, string] | [number, string, number]> +} + +const EVENT_SEEDS: EventSeed[] = [ + { + id: 101, + sportId: 1, + code: 'M100', + name: '男子 100 米', + eventType: 'individual', + resultMode: 'auto', + resultType: 'time', + compare: 'asc', + scored: true, + categoryName: '男子组', + published: [ + [1, '11.58'], + [2, '11.92'], + [3, '12.01'], + [4, '12.20'], + [5, '12.35'], + [6, '12.48'], + [7, '12.60'], + ], + }, + { + id: 102, + sportId: 1, + code: 'W100', + name: '女子 100 米', + eventType: 'individual', + resultMode: 'auto', + resultType: 'time', + compare: 'asc', + scored: true, + categoryName: '女子组', + }, + { + id: 103, + sportId: 1, + code: 'M800', + name: '男子 800 米', + eventType: 'individual', + resultMode: 'auto', + resultType: 'time', + compare: 'asc', + scored: true, + categoryName: '男子组', + published: [ + [2, '2:14.30'], + [1, '2:16.05'], + [4, '2:18.77'], + [3, '2:20.10'], + [5, '2:22.44'], + ], + }, + { + id: 104, + sportId: 1, + code: 'LJ', + name: '跳远', + eventType: 'individual', + resultMode: 'auto', + resultType: 'distance', + compare: 'desc', + scored: true, + categoryName: '男子组', + }, + { + id: 105, + sportId: 3, + code: 'PU', + name: '引体向上', + eventType: 'individual', + resultMode: 'auto', + resultType: 'count', + compare: 'desc', + scored: true, + categoryName: '男子组', + }, + { + id: 106, + sportId: 2, + code: 'BB', + name: '篮球(团体)', + eventType: 'team', + resultMode: 'manual', + resultType: 'score', + compare: 'none', + scored: true, + categoryName: '混合组', + published: [ + [3, '第一名', 1], + [1, '第二名', 2], + [5, '第三名', 3], + ], + }, + { + id: 107, + sportId: 3, + code: 'FUN', + name: '趣味接力(不计分)', + eventType: 'team', + resultMode: 'manual', + resultType: 'none', + compare: 'none', + scored: false, + categoryName: '混合组', + }, +] + +const SETTINGS: EventSettings = { + event: { + name: '2026 职工运动会', + subtitle: '示例数据', + host: '示例主办方', + logoPath: null, + primaryColor: '#3482FF', + }, + display: { + background: null, + logo: null, + title: '2026 职工运动会', + subtitle: '积分榜', + primaryColor: '#3482FF', + rotateSeconds: 15, + }, + standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true }, + defaultProfileId: 1, + requireApproval: false, +} + +function parseValue(text: string, type: ResultType): number | null { + const trimmed = text.trim() + if (trimmed === '') return null + if (type === 'time') { + const parts = trimmed.split(':') + const numbers = parts.map((part) => Number(part)) + if (numbers.some((value) => !Number.isFinite(value))) return null + if (numbers.length === 1) return numbers[0] ?? null + return (numbers[0] ?? 0) * 60 + (numbers[1] ?? 0) + } + const cleaned = trimmed.replace(/[^\d.+-]/g, '') + if (cleaned === '') return null + const numeric = Number(cleaned) + return Number.isFinite(numeric) ? numeric : null +} + +function unitOf(type: ResultType): string { + if (type === 'time') return 's' + if (type === 'distance') return 'm' + if (type === 'count') return '次' + if (type === 'score') return 'pts' + return '' +} + +class MockDb { + private readonly teams = TEAMS + private readonly sports = SPORTS + private readonly profiles = PROFILES + private readonly events: Event[] + private readonly categories: EventCategory[] + private readonly channels: ScoreChannel[] + private readonly entries: Entry[] = [] + private readonly results = new Map() + private readonly logs: AuditLog[] = [] + private nextResultId = 9000 + private nextLogId = 1 + + constructor() { + this.events = EVENT_SEEDS.map((seed, index) => ({ + id: seed.id, + sportId: seed.sportId, + code: seed.code, + name: seed.name, + eventType: seed.eventType, + resultMode: seed.resultMode, + resultType: seed.resultType, + compare: seed.compare, + recordEligible: seed.resultType === 'time' || seed.resultType === 'distance', + scored: seed.scored, + multiplier: 1, + status: seed.published === undefined ? 'pending' : 'published', + scheduledAt: null, + venue: seed.sportId === 1 ? '田径场' : '体育馆', + sortOrder: index + 1, + remark: null, + })) + + this.categories = EVENT_SEEDS.map((seed) => ({ + id: seed.id * 10 + 1, + eventId: seed.id, + name: seed.categoryName, + multiplier: 1, + gender: seed.categoryName.startsWith('男子') ? '男' : seed.categoryName.startsWith('女子') ? '女' : null, + ageMin: null, + ageMax: null, + sortOrder: 1, + })) + + this.channels = EVENT_SEEDS.map((seed) => ({ + id: seed.id * 10 + 1, + eventId: seed.id, + scope: seed.eventType === 'individual' ? 'athlete' : 'team', + basis: seed.resultMode === 'manual' ? 'manual' : 'result', + profileId: seed.eventType === 'individual' ? 1 : 2, + sortOrder: 1, + })) + + for (const seed of EVENT_SEEDS) this.seedEntries(seed) + for (const seed of EVENT_SEEDS) this.seedResults(seed) + } + + private seedEntries(seed: EventSeed): void { + const categoryId = seed.id * 10 + 1 + this.teams.forEach((team, index) => { + const entryId = seed.id * 1000 + team.id * 10 + 1 + const athleteName = seed.eventType === 'individual' ? ATHLETES[(seed.id + index) % ATHLETES.length] ?? '运动员' : null + this.entries.push({ + id: entryId, + eventId: seed.id, + categoryId, + teamId: team.id, + athleteId: seed.eventType === 'individual' ? entryId : null, + memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`], + bib: seed.eventType === 'individual' ? String(entryId % 1000) : null, + seed: null, + status: 'registered', + remark: null, + athleteName, + teamCode: team.code, + teamName: team.name, + shortName: team.shortName, + categoryName: seed.categoryName, + }) + }) + } + + private seedResults(seed: EventSeed): void { + if (seed.published === undefined) return + const categoryId = seed.id * 10 + 1 + const rows: DraftResultInput[] = seed.published.map((entry) => ({ + entryId: seed.id * 1000 + entry[0] * 10 + 1, + resultText: entry[1], + resultValue: parseValue(entry[1], seed.resultType), + rank: entry.length === 3 ? entry[2] : null, + status: 'ok', + })) + const saved = this.writeResults(seed.id, categoryId, rows, 'published') + for (const result of saved) this.results.set(result.entryId, result) + } + + private eventOf(eventId: number): Event { + const event = this.events.find((item) => item.id === eventId) + if (event === undefined) throw new Error(`示例数据里没有项目 ${eventId}`) + return event + } + + private scorable(eventId: number, categoryId: number | undefined): ScorableRow[] { + return this.entries + .filter((entry) => entry.eventId === eventId && (categoryId === undefined || entry.categoryId === categoryId)) + .map((entry) => { + const result = this.results.get(entry.id) + return { + entryId: entry.id, + teamId: entry.teamId, + resultValue: result?.resultValue ?? null, + rank: result?.rank ?? null, + status: result?.status ?? 'ok', + isTie: result?.isTie ?? false, + } + }) + } + + private scoreContext(eventId: number, categoryId: number | undefined) { + const event = this.eventOf(eventId) + const category = this.categories.find((item) => item.id === categoryId) ?? null + return { + event, + category, + channels: this.channels.filter((item) => item.eventId === eventId), + profiles: this.profiles, + weight: SETTINGS.standings.individualToTeamWeight, + } + } + + private writeResults( + eventId: number, + categoryId: number | undefined, + inputs: readonly DraftResultInput[], + state: Result['state'], + ): Result[] { + const event = this.eventOf(eventId) + const unit = unitOf(event.resultType) + const rows: ScorableRow[] = inputs.map((input) => { + const existing = this.results.get(input.entryId) + const entry = this.entries.find((item) => item.id === input.entryId) + const resultValue = + input.resultValue !== undefined + ? input.resultValue + : input.resultText !== undefined + ? parseValue(input.resultText, event.resultType) + : (existing?.resultValue ?? null) + return { + entryId: input.entryId, + teamId: entry?.teamId ?? 0, + resultValue, + rank: input.rank ?? existing?.rank ?? null, + status: input.status ?? existing?.status ?? 'ok', + isTie: input.isTie ?? existing?.isTie ?? false, + } + }) + const ranks = computeEventScore(rows, this.scoreContext(eventId, categoryId)).ranks + + return inputs.map((input) => { + const existing = this.results.get(input.entryId) + const resultText = input.resultText ?? existing?.resultText ?? '' + const resultValue = + input.resultValue !== undefined + ? input.resultValue + : parseValue(resultText, event.resultType) + const status: ResultStatus = input.status ?? existing?.status ?? 'ok' + const rank = event.resultMode === 'manual' || event.compare === 'none' + ? (input.rank ?? existing?.rank ?? null) + : (status === 'ok' ? (ranks.get(input.entryId) ?? null) : null) + const result: Result = { + id: existing?.id ?? (this.nextResultId += 1), + eventId, + entryId: input.entryId, + rank, + resultText, + resultValue, + unit, + isTie: input.isTie ?? existing?.isTie ?? false, + pointsOverride: input.pointsOverride ?? null, + status, + state, + recordedBy: state === 'published' ? 1 : null, + recordedAt: new Date().toISOString(), + verifiedBy: state === 'published' ? 1 : null, + verifiedAt: state === 'published' ? new Date().toISOString() : null, + note: input.note ?? existing?.note ?? null, + points: null, + } + this.results.set(input.entryId, result) + return result + }) + } + + private log(action: string, entityType: string, entityId: number, detail: string): void { + this.logs.unshift({ + id: (this.nextLogId += 1), + userId: 1, + action, + entityType, + entityId, + beforeJson: null, + afterJson: JSON.stringify({ detail }), + ip: '127.0.0.1', + requestId: null, + createdAt: new Date().toISOString(), + }) + } + + // ── 对外方法 ──────────────────────────────────────────────────────────── + + bootstrap(): { + settings: EventSettings + teams: Team[] + sports: Sport[] + events: Event[] + profiles: ScoringProfile[] + standings: StandingRow[] + athleteStandings: AthleteStanding[] + } { + return { + settings: SETTINGS, + teams: this.teams, + sports: this.sports, + events: this.events, + profiles: this.profiles, + standings: this.standings(), + athleteStandings: this.athleteStandings(), + } + } + + eventDetail(id: number): Event { + const event = this.eventOf(id) + return { + ...event, + categories: this.categories.filter((item) => item.eventId === id), + channels: this.channels.filter((item) => item.eventId === id), + } + } + + listEntries(eventId: number): EntryWithResult[] { + return this.entries + .filter((entry) => entry.eventId === eventId) + .map((entry) => { + const result = this.results.get(entry.id) ?? null + return { ...entry, result: result === null ? null : this.withPoints(result) } + }) + } + + saveDraft(payload: SaveDraftPayload): Result[] { + const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft') + this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`) + return saved.map((result) => this.withPoints(result)) + } + + publish(payload: PublishPayload): Result[] { + const targets = this.entries.filter( + (entry) => + entry.eventId === payload.eventId && + (payload.categoryId === undefined || entry.categoryId === payload.categoryId), + ) + const changed = targets + .map((entry) => this.results.get(entry.id)) + .filter((result): result is Result => result !== undefined) + for (const result of changed) { + result.state = 'published' + result.recordedAt = new Date().toISOString() + result.verifiedAt = result.recordedAt + } + const event = this.eventOf(payload.eventId) + event.status = 'published' + this.log('result.publish', 'event', payload.eventId, `发布 ${changed.length} 条成绩`) + return changed.map((result) => this.withPoints(result)) + } + + unpublish(payload: PublishPayload): Result[] { + const targets = this.entries.filter( + (entry) => + entry.eventId === payload.eventId && + (payload.categoryId === undefined || entry.categoryId === payload.categoryId), + ) + const changed = targets + .map((entry) => this.results.get(entry.id)) + .filter((result): result is Result => result !== undefined) + for (const result of changed) result.state = 'draft' + const event = this.eventOf(payload.eventId) + event.status = 'ongoing' + this.log('result.unpublish', 'event', payload.eventId, `撤销发布 ${changed.length} 条成绩`) + return changed.map((result) => this.withPoints(result)) + } + + private withPoints(result: Result): Result { + const points = computeEventScore( + this.scorable(result.eventId, undefined), + this.scoreContext(result.eventId, undefined), + ).rowPoints.get(result.entryId) + return { ...result, points: points ?? 0 } + } + + standings(): StandingRow[] { + const totals = new Map() + for (const team of this.teams) totals.set(team.id, { points: 0, gold: 0, silver: 0, bronze: 0 }) + + for (const event of this.events) { + const scorable = this.scorable(event.id, undefined) + const score = computeEventScore(scorable, this.scoreContext(event.id, undefined)) + for (const [teamId, points] of score.teamPoints) { + const bucket = totals.get(teamId) + if (bucket !== undefined) bucket.points += points + } + for (const row of scorable) { + if (row.status !== 'ok') continue + const rank = score.ranks.get(row.entryId) + const bucket = totals.get(row.teamId) + if (bucket === undefined || rank === null) continue + if (rank === 1) bucket.gold += 1 + else if (rank === 2) bucket.silver += 1 + else if (rank === 3) bucket.bronze += 1 + } + } + + return this.teams + .map((team) => { + const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 } + return { + teamId: team.id, + teamCode: team.code, + teamName: team.name, + shortName: team.shortName, + color: team.color, + logoPath: team.logoPath, + rank: 0, + gold: bucket.gold, + silver: bucket.silver, + bronze: bucket.bronze, + medalTotal: bucket.gold + bucket.silver + bucket.bronze, + totalPoints: round(bucket.points), + rankChange: null, + } + }) + .sort( + (a, b) => + b.totalPoints - a.totalPoints || + b.gold - a.gold || + b.silver - a.silver || + b.bronze - a.bronze || + a.teamCode.localeCompare(b.teamCode), + ) + .map((row, index) => ({ ...row, rank: index + 1 })) + } + + athleteStandings(): AthleteStanding[] { + const rows: AthleteStanding[] = [] + for (const event of this.events) { + if (event.eventType !== 'individual') continue + const scorable = this.scorable(event.id, undefined) + const score = computeEventScore(scorable, this.scoreContext(event.id, undefined)) + for (const entry of this.entries.filter((item) => item.eventId === event.id)) { + const points = score.rowPoints.get(entry.id) ?? 0 + if (points === 0) continue + const rank = score.ranks.get(entry.id) ?? null + rows.push({ + rank: 0, + athleteId: entry.athleteId ?? entry.id, + athleteName: entry.athleteName ?? '运动员', + gender: null, + teamId: entry.teamId, + teamCode: entry.teamCode ?? '', + teamName: entry.teamName ?? '', + shortName: entry.shortName ?? null, + totalPoints: points, + gold: rank === 1 ? 1 : 0, + silver: rank === 2 ? 1 : 0, + bronze: rank === 3 ? 1 : 0, + }) + } + } + return rows + .sort((a, b) => b.totalPoints - a.totalPoints || a.athleteName.localeCompare(b.athleteName)) + .map((row, index) => ({ ...row, rank: index + 1 })) + } + + breakdown(teamId: number): TeamBreakdown { + const team = this.teams.find((item) => item.id === teamId) + const items: BreakdownItem[] = [] + for (const event of this.events) { + const score = computeEventScore( + this.scorable(event.id, undefined), + this.scoreContext(event.id, undefined), + ) + for (const entry of this.entries.filter((item) => item.eventId === event.id && item.teamId === teamId)) { + const result = this.results.get(entry.id) + if (result === undefined || result.state !== 'published') continue + const points = score.rowPoints.get(entry.id) ?? 0 + items.push({ + ledgerId: result.id, + sourceType: 'result', + eventId: event.id, + eventCode: event.code, + eventName: event.name, + categoryName: entry.categoryName ?? null, + athleteId: entry.athleteId, + athleteName: entry.athleteName ?? null, + rank: score.ranks.get(entry.id) ?? null, + resultText: result.resultText, + points, + createdAt: result.recordedAt ?? new Date().toISOString(), + }) + } + } + return { + teamId, + teamCode: team?.code ?? '', + teamName: team?.name ?? '未知单位', + totalPoints: round(items.reduce((sum, item) => sum + item.points, 0)), + items, + } + } + + records(): RecordEntry[] { + return [ + { + id: 1, + eventId: 101, + level: 'meet', + resultValue: 11.2, + resultText: '11.20', + holderName: '示例纪录保持者', + holderTeamId: 1, + achievedAt: '2025-09-01', + isCurrent: true, + source: 'history', + }, + ] + } + + adjustments(): Page { + return { items: [], total: 0, page: 1, size: 20 } + } + + auditLogs(page = 1, size = 20): Page { + return { items: this.logs.slice((page - 1) * size, page * size), total: this.logs.length, page, size } + } + + login(username: string): CurrentUser { + return { + id: 1, + username, + displayName: username === '' ? '示例用户' : username, + role: 'admin', + lastLoginAt: null, + } + } + + health(): HealthInfo { + return { version: 'mock' } + } +} + +function round(value: number): number { + return Number(value.toFixed(2)) +} + +export const mockDb = new MockDb() diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts new file mode 100644 index 0000000..da034ad --- /dev/null +++ b/web/src/admin/api/scoring.ts @@ -0,0 +1,176 @@ +import type { + CompareMode, + Event, + EventCategory, + ResultStatus, + ScoreChannel, + ScoringProfile, + TieMode, +} from '@/shared' + +/** + * 名次与积分的纯函数实现。 + * + * 录入页的「实时预览」和 mock 数据源的发布结果必须用同一套算法, + * 否则演示态与真实态算出的分会不一致,预览就失去意义。 + * 服务端就绪后这里只用于预览,真值仍以 /api/standings 为准。 + */ + +export interface ScorableRow { + entryId: number + teamId: number + resultValue: number | null + rank: number | null + status: ResultStatus + isTie: boolean +} + +const EPSILON = 1e-9 + +function isRanked(row: ScorableRow): boolean { + return row.status === 'ok' && row.resultValue !== null +} + +/** 按比较方向排序并赋名次;并列按 tieMode 处理。manual / compare=none 时直接用录入的名次。 */ +export function assignRanks( + rows: readonly ScorableRow[], + compare: CompareMode, + tieMode: TieMode, + manual: boolean, +): Map { + const result = new Map() + const ranked = rows.filter(isRanked) + + if (manual || compare === 'none') { + for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null) + return result + } + + const direction = compare === 'asc' ? 1 : -1 + const sorted = [...ranked].sort( + (a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)), + ) + + let index = 0 + while (index < sorted.length) { + const group: ScorableRow[] = [sorted[index] as ScorableRow] + const head = group[0] as ScorableRow + let cursor = index + 1 + while ( + cursor < sorted.length && + Math.abs((sorted[cursor] as ScorableRow).resultValue! - head.resultValue!) < EPSILON + ) { + group.push(sorted[cursor] as ScorableRow) + cursor += 1 + } + + const position = index + 1 + const tied = group.length > 1 + let rank = position + if (tied && tieMode === 'average') { + rank = (position + (position + group.length - 1)) / 2 + } + // skip / higher:并列者都拿靠前的名次,后续名次顺延(02 第 2.2 节)。 + + for (const row of group) result.set(row.entryId, rank) + index = cursor + } + + for (const row of rows) { + if (!result.has(row.entryId)) result.set(row.entryId, null) + } + return result +} + +/** 名次 → 分值;并列平均产生小数名次时,取相邻两档的均值。 */ +export function pointsForRank(rank: number | null, profile: ScoringProfile | null): number { + if (rank === null || profile === null || !Number.isFinite(rank)) return 0 + const exact = profile.rules.find((rule) => rule.rank === rank) + if (exact !== undefined) return exact.points + + const lower = profile.rules.find((rule) => rule.rank === Math.floor(rank)) + const upper = profile.rules.find((rule) => rule.rank === Math.ceil(rank)) + if (lower === undefined && upper === undefined) return 0 + if (lower === undefined) return upper?.points ?? 0 + if (upper === undefined) return lower.points + return (lower.points + upper.points) / 2 +} + +export interface ChannelPlan { + athleteProfile: ScoringProfile | null + teamProfile: ScoringProfile | null +} + +/** 一个项目可挂多个通道;预览只关心「个人表」和「团体表」两张。 */ +export function planChannels( + channels: readonly ScoreChannel[], + profiles: readonly ScoringProfile[], +): ChannelPlan { + const pick = (scope: ScoreChannel['scope']): ScoringProfile | null => { + const channel = channels.find((item) => item.scope === scope) + if (channel === undefined) return null + return profiles.find((profile) => profile.id === channel.profileId) ?? null + } + return { athleteProfile: pick('athlete'), teamProfile: pick('team') } +} + +export interface EventScoreResult { + /** 每行「直接产生」的积分,用于表格里的积分列。 */ + rowPoints: Map + /** 每行名次,用于自动排名展示。 */ + ranks: Map + /** 按单位聚合的得分增量,用于预览面板。 */ + teamPoints: Map +} + +export interface ScoreContext { + event: Event + category: EventCategory | null + channels: readonly ScoreChannel[] + profiles: readonly ScoringProfile[] + /** standings.individualToTeamWeight,个人分折算到单位的系数。 */ + weight: number +} + +/** + * 计算一组成绩的积分。 + * + * 个人项目:行分记给个人,单位获得 `个人分 × 系数`; + * 团体 / 对抗项目:行分直接记给单位(02 第 2.4 节)。 + */ +export function computeEventScore( + rows: readonly ScorableRow[], + context: ScoreContext, +): EventScoreResult { + const manual = context.event.resultMode === 'manual' + const ranks = assignRanks(rows, context.event.compare, tieModeOf(context), manual) + const plan = planChannels(context.channels, context.profiles) + const multiplier = context.event.multiplier * (context.category?.multiplier ?? 1) + const isIndividual = context.event.eventType === 'individual' + + const rowPoints = new Map() + const teamPoints = new Map() + const addTeam = (teamId: number, points: number): void => { + if (points === 0) return + teamPoints.set(teamId, (teamPoints.get(teamId) ?? 0) + points) + } + + for (const row of rows) { + const rank = ranks.get(row.entryId) ?? null + const profile = isIndividual + ? (plan.athleteProfile ?? plan.teamProfile) + : (plan.teamProfile ?? plan.athleteProfile) + const points = pointsForRank(rank, profile) * multiplier + rowPoints.set(row.entryId, points) + + if (isIndividual) addTeam(row.teamId, points * context.weight) + else addTeam(row.teamId, points) + } + + return { rowPoints, ranks, teamPoints } +} + +function tieModeOf(context: ScoreContext): TieMode { + const profile = planChannels(context.channels, context.profiles).athleteProfile + return profile?.tieMode ?? 'skip' +} diff --git a/web/src/admin/components/ConfirmDialog.vue b/web/src/admin/components/ConfirmDialog.vue new file mode 100644 index 0000000..36f455b --- /dev/null +++ b/web/src/admin/components/ConfirmDialog.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/web/src/admin/components/MxIcon.vue b/web/src/admin/components/MxIcon.vue new file mode 100644 index 0000000..e6e3d6b --- /dev/null +++ b/web/src/admin/components/MxIcon.vue @@ -0,0 +1,23 @@ + + + diff --git a/web/src/admin/components/PageHeader.vue b/web/src/admin/components/PageHeader.vue new file mode 100644 index 0000000..9ae6feb --- /dev/null +++ b/web/src/admin/components/PageHeader.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/web/src/admin/components/ToastHost.vue b/web/src/admin/components/ToastHost.vue new file mode 100644 index 0000000..fa7eb5a --- /dev/null +++ b/web/src/admin/components/ToastHost.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/web/src/admin/components/icons.ts b/web/src/admin/components/icons.ts new file mode 100644 index 0000000..b5c227d --- /dev/null +++ b/web/src/admin/components/icons.ts @@ -0,0 +1,32 @@ +/** 管理端用到的内联图标路径。现场无外网,不引图标库。 */ +export const ICON_PATHS = { + dashboard: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z', + entry: 'M4 5h16M4 12h10M4 19h7M17 15l3 3-3 3', + review: 'M5 4h14v16H5zM8 9h8M8 13h8M8 17h4', + record: 'M8 21h8M12 17v4M6 4h12v6a6 6 0 0 1-12 0V4Z', + adjust: 'M4 8h10M18 8h2M4 16h2M10 16h10M15 5v6M9 13v6', + standings: 'M4 20V10h4v10H4Zm6 0V4h4v16h-4Zm6 0v-7h4v7h-4Z', + display: 'M3 4h18v12H3zM8 20h8M12 16v4', + database: + 'M12 3c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3Zm8 3v12c0 1.7-3.6 3-8 3s-8-1.3-8-3V6M20 12c0 1.7-3.6 3-8 3s-8-1.3-8-3', + export: 'M12 3v12M8 11l4 4 4-4M4 19h16', + history: 'M4 12a8 8 0 1 0 3-6.2M4 4v4h4M12 8v4l3 2', + logs: 'M5 4h14v16H5zM8 8h8M8 12h8M8 16h5', + system: + 'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm8.4 3-1.8-1 .3-2.1-2-1.2-1.7 1.3-1.8-1 .2-2.1-2.3-.7-1 1.9-2.1-.2-1.2 2 1.3 1.7-1 1.8-2.1.2-.7 2.3 1.9 1-.2 2.1 2 1.2 1.7-1.3 1.8 1-.2 2.1 2.3.7 1-1.9 2.1.2 1.2-2-1.3-1.7 1-1.8 2.1-.2.7-2.3Z', + refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v4h-4', + lock: 'M6 11h12v9H6zM9 11V8a3 3 0 0 1 6 0v3', + unlock: 'M6 11h12v9H6zM9 11V8a3 3 0 0 1 5.7-1.4', + bell: 'M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM10 19a2 2 0 0 0 4 0', + push: 'M12 20V6M8 10l4-4 4 4M4 20h16', + close: 'M6 6l12 12M18 6 6 18', + check: 'M5 12.5 10 17l9-10', + warning: 'M12 4l9 16H3l9-16Zm0 6v4m0 3v.5', + search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3', + chevron: 'M8 10l4 4 4-4', + logout: 'M15 4h4v16h-4M11 8l-4 4 4 4M7 12h9', + user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8a8 8 0 0 1 16 0', + keyboard: 'M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10', +} as const + +export type IconName = keyof typeof ICON_PATHS diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue new file mode 100644 index 0000000..7e49a53 --- /dev/null +++ b/web/src/admin/layouts/AdminLayout.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/web/src/admin/main.ts b/web/src/admin/main.ts index 7dcacf9..5446837 100644 --- a/web/src/admin/main.ts +++ b/web/src/admin/main.ts @@ -1,5 +1,8 @@ import { createApp } from 'vue' +import { createPinia } from 'pinia' import App from './App.vue' +import { router } from './router' import '@/shared/styles/base.css' +import './styles/miuix.css' -createApp(App).mount('#app') +createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/web/src/admin/pages/DashboardPage.vue b/web/src/admin/pages/DashboardPage.vue new file mode 100644 index 0000000..e903a1a --- /dev/null +++ b/web/src/admin/pages/DashboardPage.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/web/src/admin/pages/DisplayPage.vue b/web/src/admin/pages/DisplayPage.vue new file mode 100644 index 0000000..7ee820a --- /dev/null +++ b/web/src/admin/pages/DisplayPage.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue new file mode 100644 index 0000000..81f0845 --- /dev/null +++ b/web/src/admin/pages/EntryPage.vue @@ -0,0 +1,1007 @@ + + + + + diff --git a/web/src/admin/pages/LoginPage.vue b/web/src/admin/pages/LoginPage.vue new file mode 100644 index 0000000..94e5d32 --- /dev/null +++ b/web/src/admin/pages/LoginPage.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/web/src/admin/pages/PlaceholderPage.vue b/web/src/admin/pages/PlaceholderPage.vue new file mode 100644 index 0000000..af85e12 --- /dev/null +++ b/web/src/admin/pages/PlaceholderPage.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/web/src/admin/pages/StandingsPage.vue b/web/src/admin/pages/StandingsPage.vue new file mode 100644 index 0000000..2f0f3a3 --- /dev/null +++ b/web/src/admin/pages/StandingsPage.vue @@ -0,0 +1,465 @@ + + + + + diff --git a/web/src/admin/router.ts b/web/src/admin/router.ts new file mode 100644 index 0000000..192858b --- /dev/null +++ b/web/src/admin/router.ts @@ -0,0 +1,106 @@ +import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router' +import AdminLayout from './layouts/AdminLayout.vue' + +/** + * 路由表。占位页统一走 PlaceholderPage,避免为每个未实现页面各写一个空组件。 + */ +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'login', + component: () => import('./pages/LoginPage.vue'), + meta: { title: '登录' }, + }, + { + path: '/', + component: AdminLayout, + children: [ + { path: '', redirect: { name: 'dashboard' } }, + { + path: 'dashboard', + name: 'dashboard', + component: () => import('./pages/DashboardPage.vue'), + meta: { title: '仪表盘' }, + }, + { + path: 'entry', + name: 'entry', + component: () => import('./pages/EntryPage.vue'), + meta: { title: '成绩录入' }, + }, + { + path: 'review', + name: 'review', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '成绩审核', note: '草稿列表 → 对比 → 发布 / 退回' }, + }, + { + path: 'records', + name: 'records', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '破纪录', note: '疑似破纪录列表 → 确认 → 加分' }, + }, + { + path: 'adjustments', + name: 'adjustments', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '积分调整', note: '人工加减分(必填理由),本期规则关闭' }, + }, + { + path: 'standings', + name: 'standings', + component: () => import('./pages/StandingsPage.vue'), + meta: { title: '积分榜' }, + }, + { + path: 'display', + name: 'display', + component: () => import('./pages/DisplayPage.vue'), + meta: { title: '大屏控制' }, + }, + { + path: 'base-data', + name: 'base-data', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '基础数据', note: '单位 / 大项 / 小项 / 积分模板 / 运动员 / 报名导入' }, + }, + { + path: 'export', + name: 'export', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '导出', note: '积分榜 / 明细 / 成绩册 / 赛事项目包' }, + }, + { + path: 'history', + name: 'history', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '变更历史', note: '仿 Git Graph 的操作图谱(只读)' }, + }, + { + path: 'logs', + name: 'logs', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '日志', note: '审计日志:谁在什么时候改了什么' }, + }, + { + path: 'system', + name: 'system', + component: () => import('./pages/PlaceholderPage.vue'), + meta: { title: '系统', note: '用户管理 / 备份恢复 / 赛事信息' }, + }, + ], + }, + { path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } }, +] + +export const router = createRouter({ + // 服务端只在 /admin 精确路径返回 admin.html(见 httpapi/server.go 静态路由), + // 用 hash 模式才能让刷新 / 深链(#/entry?eventId=101)不落到 404。 + history: createWebHashHistory(), + routes, +}) + +router.afterEach((to) => { + const title = typeof to.meta.title === 'string' ? to.meta.title : '' + document.title = title === '' ? '管理面板' : `${title} · 管理面板` +}) diff --git a/web/src/admin/stores/auth.ts b/web/src/admin/stores/auth.ts new file mode 100644 index 0000000..3bc4176 --- /dev/null +++ b/web/src/admin/stores/auth.ts @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import type { CurrentUser } from '@/shared' +import { adminApi, mockMode, setUnauthorizedHandler } from '../api/client' + +/** + * 会话状态。 + * + * 未登录时不是「拦截」,而是降级为 guest 只读视图(07 第 1 节): + * 现场录入员换班、或服务端会话过期时,管理员仍能查看积分与成绩。 + */ +export const useAuthStore = defineStore('admin-auth', () => { + const user = ref(null) + const loading = ref(false) + const error = ref(null) + /** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */ + const initialized = ref(false) + + const isGuest = computed(() => user.value === null) + const readOnly = computed(() => user.value === null) + const displayName = computed(() => user.value?.displayName ?? '访客') + const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户'))) + + async function load(): Promise { + if (initialized.value) return + initialized.value = true + loading.value = true + error.value = null + try { + user.value = await adminApi.me() + } catch { + // 未登录 / 后端未就绪都走只读访客,不把用户挡在门外。 + user.value = null + } finally { + loading.value = false + } + } + + async function login(username: string, password: string): Promise { + loading.value = true + error.value = null + try { + user.value = await adminApi.login(username, password) + initialized.value = true + return true + } catch (caught) { + error.value = caught instanceof Error ? caught.message : '登录失败' + return false + } finally { + loading.value = false + } + } + + async function logout(): Promise { + try { + await adminApi.logout() + } catch { + // 登出失败也要清本地态,否则用户会卡在已登录界面。 + } + user.value = null + } + + setUnauthorizedHandler(() => { + user.value = null + }) + + return { + user, + loading, + error, + isGuest, + readOnly, + displayName, + roleLabel, + mockMode, + load, + login, + logout, + } +}) diff --git a/web/src/admin/stores/event.ts b/web/src/admin/stores/event.ts new file mode 100644 index 0000000..a0b7e99 --- /dev/null +++ b/web/src/admin/stores/event.ts @@ -0,0 +1,178 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { RealtimeClient, type RealtimeStatus, type SnapshotMessage } from '@/shared' +import type { + AthleteStanding, + Event, + EventSettings, + ScoringProfile, + Sport, + StandingRow, + Team, +} from '@/shared' +import { adminApi, mockMode, mockReason } from '../api/client' + +/** + * 赛事全局数据(单位 / 项目 / 积分榜)。 + * + * 管理端断网时必须禁止写操作(05 第 4 节),所以连接状态放在这里, + * 各页面统一用 `canWrite` 判断按钮是否可点。 + */ +export const useEventStore = defineStore('admin-event', () => { + const settings = ref(null) + const teams = ref([]) + const sports = ref([]) + const events = ref([]) + const profiles = ref([]) + const standings = ref([]) + const athleteStandings = ref([]) + + const loading = ref(false) + const loaded = ref(false) + const error = ref(null) + const lastRefreshAt = ref(null) + + const realtimeStatus = ref('idle') + const serverVersion = ref(null) + const serverTime = ref(null) + const serverHealth = ref(null) + + let realtime: RealtimeClient | null = null + + const isMock = computed(() => mockMode.value) + const mockLabel = computed(() => + mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null, + ) + const online = computed(() => realtimeStatus.value === 'online') + /** + * 写操作门槛:真实后端需实时在线;示例数据模式允许本地写入以便走通交互。 + * 登录态由 auth store 叠加判断,这里只管连接。 + */ + const canWrite = computed(() => mockMode.value || realtimeStatus.value === 'online') + + function applySnapshot(snapshot: SnapshotMessage): void { + settings.value = snapshot.settings + teams.value = snapshot.teams + events.value = snapshot.events + standings.value = snapshot.standings + } + + async function load(): Promise { + loading.value = true + error.value = null + try { + const data = await adminApi.bootstrap() + settings.value = data.settings + teams.value = data.teams + sports.value = data.sports + events.value = data.events + profiles.value = data.profiles + standings.value = data.standings + athleteStandings.value = data.athleteStandings + loaded.value = true + lastRefreshAt.value = new Date().toISOString() + if (!mockMode.value) startRealtime() + void checkHealth() + } catch (caught) { + error.value = caught instanceof Error ? caught.message : '加载失败' + } finally { + loading.value = false + } + } + + async function checkHealth(): Promise { + try { + const info = await adminApi.health() + serverHealth.value = info.version + } catch { + serverHealth.value = null + } + } + + function startRealtime(): void { + if (realtime !== null) return + realtime = new RealtimeClient({ + role: 'admin', + onSnapshot: applySnapshot, + onChange: () => { + // 增量只用于触发重拉,管理端不自己合并积分榜,避免与服务端算出的真值分叉。 + void refreshStandings() + }, + onStatusChange: (status) => { + realtimeStatus.value = status + }, + onVersionChange: (version) => { + serverVersion.value = version + }, + onError: () => { + // 连接错误由 onStatusChange 统一呈现,这里不弹 toast,避免刷屏。 + }, + }) + realtime.connect() + } + + function stopRealtime(): void { + realtime?.close() + realtime = null + realtimeStatus.value = 'idle' + } + + async function refreshStandings(): Promise { + try { + const [next, athletes] = await Promise.all([ + adminApi.standings(), + adminApi.athleteStandings(), + ]) + standings.value = next + athleteStandings.value = athletes + lastRefreshAt.value = new Date().toISOString() + } catch (caught) { + error.value = caught instanceof Error ? caught.message : '刷新积分榜失败' + } + } + + async function refreshEvents(): Promise { + try { + events.value = await adminApi.client.events.list() + } catch { + // 项目列表刷新失败保留旧数据,页面仍有内容可操作。 + } + } + + function eventById(id: number): Event | undefined { + return events.value.find((item) => item.id === id) + } + + function teamById(id: number): Team | undefined { + return teams.value.find((item) => item.id === id) + } + + return { + settings, + teams, + sports, + events, + profiles, + standings, + athleteStandings, + loading, + loaded, + error, + lastRefreshAt, + realtimeStatus, + serverVersion, + serverTime, + serverHealth, + isMock, + mockLabel, + online, + canWrite, + load, + checkHealth, + refreshStandings, + refreshEvents, + eventById, + teamById, + stopRealtime, + } +}) diff --git a/web/src/admin/stores/toast.ts b/web/src/admin/stores/toast.ts new file mode 100644 index 0000000..29d00aa --- /dev/null +++ b/web/src/admin/stores/toast.ts @@ -0,0 +1,38 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type ToastKind = 'success' | 'warning' | 'error' | 'info' + +export interface ToastItem { + id: number + kind: ToastKind + message: string + detail: string | null +} + +let nextId = 1 + +export const useToastStore = defineStore('admin-toast', () => { + const items = ref([]) + + function dismiss(id: number): void { + items.value = items.value.filter((item) => item.id !== id) + } + + function push(kind: ToastKind, message: string, detail: string | null = null, ttlMs = 5000): number { + const id = nextId++ + items.value = [...items.value, { id, kind, message, detail }] + if (ttlMs > 0) window.setTimeout(() => dismiss(id), ttlMs) + return id + } + + return { + items, + dismiss, + push, + success: (message: string, detail: string | null = null) => push('success', message, detail), + warning: (message: string, detail: string | null = null) => push('warning', message, detail), + error: (message: string, detail: string | null = null) => push('error', message, detail, 8000), + info: (message: string, detail: string | null = null) => push('info', message, detail), + } +}) diff --git a/web/src/admin/styles/miuix.css b/web/src/admin/styles/miuix.css new file mode 100644 index 0000000..54e6a49 --- /dev/null +++ b/web/src/admin/styles/miuix.css @@ -0,0 +1,105 @@ +/* + * MIUIx 样式入口(管理端专用)。 + * + * vendor/miuix-console 是现场离线环境的唯一来源,禁止 CDN。 + * 这里按「tokens → base → 组件」的顺序引入,保证组件里的 var() 都能解析。 + */ +@import '../../../../vendor/miuix-console/core/tokens.css'; +@import '../../../../vendor/miuix-console/core/base.css'; +@import '../../../../vendor/miuix-console/core/animations.css'; +@import '../../../../vendor/miuix-console/components/layout.css'; +@import '../../../../vendor/miuix-console/components/navigation.css'; +@import '../../../../vendor/miuix-console/components/button.css'; +@import '../../../../vendor/miuix-console/components/card.css'; +@import '../../../../vendor/miuix-console/components/input.css'; +@import '../../../../vendor/miuix-console/components/select.css'; +@import '../../../../vendor/miuix-console/components/table.css'; +@import '../../../../vendor/miuix-console/components/badge.css'; +@import '../../../../vendor/miuix-console/components/tag.css'; +@import '../../../../vendor/miuix-console/components/tabs.css'; +@import '../../../../vendor/miuix-console/components/toast.css'; +@import '../../../../vendor/miuix-console/components/modal.css'; +@import '../../../../vendor/miuix-console/components/stat.css'; +@import '../../../../vendor/miuix-console/components/progress.css'; +@import '../../../../vendor/miuix-console/components/switch.css'; +@import '../../../../vendor/miuix-console/components/empty.css'; +@import '../../../../vendor/miuix-console/components/tooltip.css'; +@import '../../../../vendor/miuix-console/components/pagination.css'; +@import '../../../../vendor/miuix-console/components/checkbox.css'; +@import '../../../../vendor/miuix-console/components/drawer.css'; +@import '../../../../vendor/miuix-console/components/popover.css'; +@import '../../../../vendor/miuix-console/components/avatar.css'; +@import '../../../../vendor/miuix-console/components/accordion.css'; +@import '../../../../vendor/miuix-console/components/activity.css'; + +/* + * 上游 tokens.css 只定义了 --mx-error,但 button/input/badge 等组件直接引用 + * --mx-red / --mx-accent。不改 vendor 文件(保持可整目录覆盖升级),在这里补齐别名。 + */ +:root, +[data-theme='dark'] { + --mx-red: var(--mx-error); + --mx-accent: var(--mx-primary); +} + +/* + * 管理端不需要 100dvh 的自适应 App Shell,页面滚动交给 .mx-desktop-main, + * 这里只保证 body 底色与 MIUIx 令牌一致(shared/base.css 的深蓝底不适用管理端)。 + */ +body { + background: var(--mx-bg); + color: var(--mx-text); + font-family: var(--mx-font); +} + +/* 表格内嵌输入框:用 MIUIx 的输入外观,但去掉外层 40px 高度限制,避免行高被撑爆。 */ +.mx-cell-input { + width: 100%; + height: 34px; + padding: 0 10px; + background: var(--mx-s3); + border: 1px solid transparent; + border-radius: var(--mx-r-xs); + color: var(--mx-text); + font-size: 13px; + font-family: var(--mx-font); + transition: + border-color var(--mx-d1) var(--mx-ease), + box-shadow var(--mx-d1) var(--mx-ease); +} +.mx-cell-input:focus { + border-color: var(--mx-primary); + box-shadow: 0 0 0 3px var(--mx-primary-glow); + outline: none; +} +.mx-cell-input.is-invalid { + border-color: var(--mx-error); +} +.mx-cell-input.is-dirty { + border-color: var(--mx-amber); +} +.mx-cell-input.mono { + font-family: var(--mx-mono); +} + +/* 紧凑表格里的单元格不再响应 hover 左推,否则录入时会跳。 */ +.mx-table-entry tbody tr:hover td { + padding-left: 16px; +} +.mx-table-entry td { + white-space: nowrap; + vertical-align: middle; +} +.mx-table-entry tbody tr.is-dirty { + background: rgba(251, 191, 36, 0.06); +} +.mx-table-entry tbody tr.is-focus { + background: var(--mx-primary-glow); +} + +/* 只读访客态:禁用写操作控件。 */ +.is-readonly .mx-btn-primary, +.is-readonly .mx-btn-danger { + opacity: 0.45; + pointer-events: none; +} diff --git a/web/src/display/App.vue b/web/src/display/App.vue index f21fca6..023fed5 100644 --- a/web/src/display/App.vue +++ b/web/src/display/App.vue @@ -1,69 +1,105 @@ diff --git a/web/src/display/color.ts b/web/src/display/color.ts new file mode 100644 index 0000000..d1dbc9e --- /dev/null +++ b/web/src/display/color.ts @@ -0,0 +1,77 @@ +/** + * 主色派生(06 第 2 节):亮色变体做标题渐变、暗色变体做背景光晕, + * 前景色按对比度自动选黑/白,保证主色换成任何颜色都仍然可读。 + */ + +export interface Rgb { + r: number + g: number + b: number +} + +const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i +const HEX_LONG = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i + +export function parseHexColor(value: string | null | undefined): Rgb | null { + if (value === null || value === undefined) return null + const text = value.trim() + const short = HEX_SHORT.exec(text) + if (short !== null) { + return { + r: Number.parseInt(`${short[1]}${short[1]}`, 16), + g: Number.parseInt(`${short[2]}${short[2]}`, 16), + b: Number.parseInt(`${short[3]}${short[3]}`, 16), + } + } + const long = HEX_LONG.exec(text) + if (long !== null) { + return { + r: Number.parseInt(long[1] ?? '0', 16), + g: Number.parseInt(long[2] ?? '0', 16), + b: Number.parseInt(long[3] ?? '0', 16), + } + } + return null +} + +export function mix(a: Rgb, b: Rgb, ratio: number): Rgb { + const t = Math.min(1, Math.max(0, ratio)) + return { + r: Math.round(a.r + (b.r - a.r) * t), + g: Math.round(a.g + (b.g - a.g) * t), + b: Math.round(a.b + (b.b - a.b) * t), + } +} + +export function toCss(color: Rgb): string { + return `rgb(${color.r} ${color.g} ${color.b})` +} + +function channelLuminance(channel: number): number { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +export function relativeLuminance(color: Rgb): number { + return ( + 0.2126 * channelLuminance(color.r) + + 0.7152 * channelLuminance(color.g) + + 0.0722 * channelLuminance(color.b) + ) +} + +function contrastRatio(a: Rgb, b: Rgb): number { + const la = relativeLuminance(a) + const lb = relativeLuminance(b) + const lighter = Math.max(la, lb) + const darker = Math.min(la, lb) + return (lighter + 0.05) / (darker + 0.05) +} + +const WHITE: Rgb = { r: 255, g: 255, b: 255 } +const INK: Rgb = { r: 11, g: 18, b: 32 } + +/** 主色上放文字:对比度更高的那个(目标 ≥ 4.5:1)。 */ +export function readableForeground(color: Rgb): string { + return contrastRatio(color, WHITE) >= contrastRatio(color, INK) ? '#ffffff' : '#0b1220' +} diff --git a/web/src/display/components/AnimatedNumber.vue b/web/src/display/components/AnimatedNumber.vue new file mode 100644 index 0000000..5fca5c4 --- /dev/null +++ b/web/src/display/components/AnimatedNumber.vue @@ -0,0 +1,53 @@ + + + diff --git a/web/src/display/components/DetailOverlay.vue b/web/src/display/components/DetailOverlay.vue new file mode 100644 index 0000000..eea11ac --- /dev/null +++ b/web/src/display/components/DetailOverlay.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/web/src/display/components/OfflineBadge.vue b/web/src/display/components/OfflineBadge.vue new file mode 100644 index 0000000..64a20f1 --- /dev/null +++ b/web/src/display/components/OfflineBadge.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/web/src/display/components/PageFrame.vue b/web/src/display/components/PageFrame.vue new file mode 100644 index 0000000..0fdaeda --- /dev/null +++ b/web/src/display/components/PageFrame.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/display/components/TickerBar.vue b/web/src/display/components/TickerBar.vue new file mode 100644 index 0000000..63ae28a --- /dev/null +++ b/web/src/display/components/TickerBar.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/web/src/display/components/TopBar.vue b/web/src/display/components/TopBar.vue new file mode 100644 index 0000000..17a857f --- /dev/null +++ b/web/src/display/components/TopBar.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/web/src/display/components/pages/AnnouncementPage.vue b/web/src/display/components/pages/AnnouncementPage.vue new file mode 100644 index 0000000..6446d31 --- /dev/null +++ b/web/src/display/components/pages/AnnouncementPage.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/web/src/display/components/pages/AthleteStandingsPage.vue b/web/src/display/components/pages/AthleteStandingsPage.vue new file mode 100644 index 0000000..2032011 --- /dev/null +++ b/web/src/display/components/pages/AthleteStandingsPage.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/web/src/display/components/pages/EventResultsPage.vue b/web/src/display/components/pages/EventResultsPage.vue new file mode 100644 index 0000000..b26689a --- /dev/null +++ b/web/src/display/components/pages/EventResultsPage.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/web/src/display/components/pages/StandingsPage.vue b/web/src/display/components/pages/StandingsPage.vue new file mode 100644 index 0000000..88a3c05 --- /dev/null +++ b/web/src/display/components/pages/StandingsPage.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/web/src/display/components/pages/TeamMedalsPage.vue b/web/src/display/components/pages/TeamMedalsPage.vue new file mode 100644 index 0000000..9158e18 --- /dev/null +++ b/web/src/display/components/pages/TeamMedalsPage.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/web/src/display/fitFont.ts b/web/src/display/fitFont.ts new file mode 100644 index 0000000..582eb44 --- /dev/null +++ b/web/src/display/fitFont.ts @@ -0,0 +1,12 @@ +/** + * 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。 + * + * 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号, + * 比例留出 padding 与行距,保证文字始终装得下。 + */ +export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string { + if (count <= 0) return '48px' + const perRow = availablePx / count + const size = Math.min(56, Math.max(16, perRow * ratio)) + return `${Math.floor(size)}px` +} diff --git a/web/src/display/guards.ts b/web/src/display/guards.ts new file mode 100644 index 0000000..5b862b8 --- /dev/null +++ b/web/src/display/guards.ts @@ -0,0 +1,313 @@ +import type { + AthleteStanding, + BreakdownItem, + CompareMode, + Event, + EventResultGroup, + EventResultRow, + EventSettings, + EventStatus, + EventType, + Gender, + LedgerSourceType, + RecordEntry, + RecordLevel, + RecordSource, + ResultMode, + ResultStatus, + ResultType, + SnapshotMessage, + StandingRow, + Team, + TeamBreakdown, +} from '@/shared' + +/** + * WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。 + * + * 大屏是无人值守的:一条脏数据不能把整页渲染炸掉,所以这里逐字段兜底, + * 而不是直接断言成后端类型。 + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function list(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +export function str(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +export function strOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +export function num(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +export function numOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function bool(value: unknown, fallback = false): boolean { + return typeof value === 'boolean' ? value : fallback +} + +export function strings(value: unknown): string[] | null { + const items = list(value).filter((item): item is string => typeof item === 'string') + return items.length === 0 ? null : items +} + +export function oneOf(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && (allowed as readonly string[]).includes(value) + ? (value as T) + : fallback +} + +const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[] +const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[] +const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[] +const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[] +const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[] +const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[] +const GENDERS = ['男', '女'] as const satisfies readonly Gender[] +const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[] +const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[] +const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[] + +function record(source: unknown): Record { + return isRecord(source) ? source : {} +} + +interface TeamRefShape { + teamId: number + teamCode: string + teamName: string + shortName: string | null + color: string | null + logoPath: string | null +} + +function parseTeamRef(source: unknown): TeamRefShape { + const rec = record(source) + return { + teamId: num(rec.teamId), + teamCode: str(rec.teamCode), + teamName: str(rec.teamName), + shortName: strOrNull(rec.shortName), + color: strOrNull(rec.color), + logoPath: strOrNull(rec.logoPath), + } +} + +export function parseTeam(source: unknown): Team { + const rec = record(source) + return { + id: num(rec.id), + code: str(rec.code), + name: str(rec.name), + shortName: strOrNull(rec.shortName), + logoPath: strOrNull(rec.logoPath), + color: strOrNull(rec.color), + sortOrder: num(rec.sortOrder), + active: bool(rec.active, true), + } +} + +export function parseEvent(source: unknown): Event { + const rec = record(source) + return { + id: num(rec.id), + sportId: num(rec.sportId), + code: str(rec.code), + name: str(rec.name), + eventType: oneOf(rec.eventType, EVENT_TYPES, 'individual'), + resultMode: oneOf(rec.resultMode, RESULT_MODES, 'auto'), + resultType: oneOf(rec.resultType, RESULT_TYPES, 'none'), + compare: oneOf(rec.compare, COMPARE_MODES, 'none'), + recordEligible: bool(rec.recordEligible), + scored: bool(rec.scored, true), + multiplier: num(rec.multiplier, 1), + status: oneOf(rec.status, EVENT_STATUSES, 'pending'), + scheduledAt: strOrNull(rec.scheduledAt), + venue: strOrNull(rec.venue), + sortOrder: num(rec.sortOrder), + remark: strOrNull(rec.remark), + } +} + +export function parseStandingRow(source: unknown): StandingRow { + const rec = record(source) + const gold = num(rec.gold) + const silver = num(rec.silver) + const bronze = num(rec.bronze) + return { + ...parseTeamRef(rec), + rank: num(rec.rank), + gold, + silver, + bronze, + medalTotal: num(rec.medalTotal, gold + silver + bronze), + totalPoints: num(rec.totalPoints), + rankChange: numOrNull(rec.rankChange), + } +} + +export function parseStandingRows(value: unknown): StandingRow[] { + return list(value).map(parseStandingRow) +} + +export function parseAthleteStanding(source: unknown): AthleteStanding { + const rec = record(source) + return { + rank: num(rec.rank), + athleteId: num(rec.athleteId), + athleteName: str(rec.athleteName), + gender: rec.gender === null || rec.gender === undefined ? null : oneOf(rec.gender, GENDERS, '男'), + teamId: num(rec.teamId), + teamCode: str(rec.teamCode), + teamName: str(rec.teamName), + shortName: strOrNull(rec.shortName), + totalPoints: num(rec.totalPoints), + gold: num(rec.gold), + silver: num(rec.silver), + bronze: num(rec.bronze), + } +} + +export function parseAthleteStandings(value: unknown): AthleteStanding[] { + return list(value).map(parseAthleteStanding) +} + +export function parseEventResultRow(source: unknown): EventResultRow { + const rec = record(source) + return { + entryId: num(rec.entryId), + rank: numOrNull(rec.rank), + teamId: num(rec.teamId), + teamCode: str(rec.teamCode), + teamName: str(rec.teamName), + shortName: strOrNull(rec.shortName), + athleteId: numOrNull(rec.athleteId), + athleteName: strOrNull(rec.athleteName), + memberNames: strings(rec.memberNames), + resultText: str(rec.resultText, '—'), + resultValue: numOrNull(rec.resultValue), + unit: str(rec.unit), + isTie: bool(rec.isTie), + status: oneOf(rec.status, RESULT_STATUSES, 'ok'), + } +} + +export function parseEventResultGroup(source: unknown): EventResultGroup { + const rec = record(source) + return { + eventId: num(rec.eventId), + eventCode: str(rec.eventCode), + eventName: str(rec.eventName), + categoryId: num(rec.categoryId), + categoryName: str(rec.categoryName), + publishedAt: strOrNull(rec.publishedAt), + rows: list(rec.rows).map(parseEventResultRow), + } +} + +export function parseEventResultGroups(value: unknown): EventResultGroup[] { + return list(value).map(parseEventResultGroup) +} + +export function parseRecordEntry(source: unknown): RecordEntry { + const rec = record(source) + return { + id: num(rec.id), + eventId: num(rec.eventId), + level: oneOf(rec.level, RECORD_LEVELS, 'meet'), + resultValue: num(rec.resultValue), + resultText: str(rec.resultText, '—'), + holderName: strOrNull(rec.holderName), + holderTeamId: numOrNull(rec.holderTeamId), + achievedAt: strOrNull(rec.achievedAt), + isCurrent: bool(rec.isCurrent), + source: oneOf(rec.source, RECORD_SOURCES, 'history'), + } +} + +export function parseSettings(source: unknown): EventSettings { + const rec = record(source) + const event = record(rec.event) + const display = record(rec.display) + const standings = record(rec.standings) + return { + event: { + name: str(event.name), + subtitle: strOrNull(event.subtitle), + host: strOrNull(event.host), + logoPath: strOrNull(event.logoPath), + primaryColor: strOrNull(event.primaryColor), + }, + display: { + background: strOrNull(display.background), + logo: strOrNull(display.logo), + title: str(display.title), + subtitle: strOrNull(display.subtitle), + primaryColor: strOrNull(display.primaryColor), + rotateSeconds: num(display.rotateSeconds, 8), + }, + standings: { + individualToTeamWeight: num(standings.individualToTeamWeight, 1), + rankingMode: str(standings.rankingMode, 'points'), + showIndividualBoard: bool(standings.showIndividualBoard, true), + }, + defaultProfileId: numOrNull(rec.defaultProfileId), + requireApproval: bool(rec.requireApproval), + } +} + +export function parseBreakdownItem(source: unknown): BreakdownItem { + const rec = record(source) + return { + ledgerId: num(rec.ledgerId), + sourceType: oneOf(rec.sourceType, LEDGER_SOURCES, 'result'), + eventId: numOrNull(rec.eventId), + eventCode: strOrNull(rec.eventCode), + eventName: strOrNull(rec.eventName), + categoryName: strOrNull(rec.categoryName), + athleteId: numOrNull(rec.athleteId), + athleteName: strOrNull(rec.athleteName), + rank: numOrNull(rec.rank), + resultText: strOrNull(rec.resultText), + points: num(rec.points), + createdAt: str(rec.createdAt), + } +} + +export function parseTeamBreakdown(value: unknown): TeamBreakdown | null { + if (!isRecord(value)) return null + return { + teamId: num(value.teamId), + teamCode: str(value.teamCode), + teamName: str(value.teamName), + totalPoints: num(value.totalPoints), + items: list(value.items).map(parseBreakdownItem), + } +} + +/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */ +export function parseSnapshot(value: unknown): SnapshotMessage | null { + if (!isRecord(value)) return null + const version = numOrNull(value.version) + if (version === null) return null + return { + version, + settings: parseSettings(value.settings), + teams: list(value.teams).map(parseTeam), + events: list(value.events).map(parseEvent), + standings: parseStandingRows(value.standings), + records: list(value.records).map(parseRecordEntry), + latestResults: parseEventResultGroups(value.latestResults), + } +} diff --git a/web/src/display/mock.ts b/web/src/display/mock.ts new file mode 100644 index 0000000..6607bd2 --- /dev/null +++ b/web/src/display/mock.ts @@ -0,0 +1,288 @@ +import type { + AthleteStanding, + Event, + EventResultGroup, + EventResultRow, + SnapshotMessage, + StandingRow, + Team, +} from '@/shared' + +/** + * 后端未就绪时的降级数据(任务要求:降级 mock 并标注「示例数据」)。 + * + * 数值是手写的固定值而不是随机数:现场调试时截图对比不会因为每次刷新而变。 + */ + +const TEAM_NAMES: readonly string[] = [ + '第一分公司', + '机关本部', + '第二分公司', + '第三分公司', + '第四分公司', + '第五分公司', + '第六分公司', + '技术研究院', +] + +function team(index: number, code: string): Team { + const name = TEAM_NAMES[index] ?? `单位${index + 1}` + return { + id: index + 1, + code, + name, + shortName: null, + logoPath: null, + color: null, + sortOrder: index, + active: true, + } +} + +const MOCK_TEAMS: Team[] = [ + team(0, 'T01'), + team(1, 'T02'), + team(2, 'T03'), + team(3, 'T04'), + team(4, 'T05'), + team(5, 'T06'), + team(6, 'T07'), + team(7, 'T08'), +] + +interface StandingSeed { + rank: number + totalPoints: number + gold: number + silver: number + bronze: number + rankChange: number | null +} + +const STANDING_SEEDS: readonly StandingSeed[] = [ + { rank: 1, totalPoints: 186, gold: 8, silver: 5, bronze: 3, rankChange: 1 }, + { rank: 2, totalPoints: 172, gold: 6, silver: 7, bronze: 4, rankChange: -1 }, + { rank: 3, totalPoints: 158, gold: 5, silver: 4, bronze: 8, rankChange: 0 }, + { rank: 4, totalPoints: 141, gold: 4, silver: 5, bronze: 6, rankChange: 2 }, + { rank: 5, totalPoints: 127, gold: 3, silver: 4, bronze: 5, rankChange: null }, + { rank: 6, totalPoints: 103, gold: 2, silver: 3, bronze: 4, rankChange: 0 }, + { rank: 7, totalPoints: 95, gold: 2, silver: 2, bronze: 2, rankChange: -2 }, + { rank: 8, totalPoints: 78, gold: 1, silver: 1, bronze: 3, rankChange: 0 }, +] + +function buildStandings(): StandingRow[] { + return MOCK_TEAMS.map((item, index) => { + const seed = STANDING_SEEDS[index] ?? STANDING_SEEDS[STANDING_SEEDS.length - 1] + const row = seed ?? { rank: index + 1, totalPoints: 0, gold: 0, silver: 0, bronze: 0, rankChange: null } + return { + teamId: item.id, + teamCode: item.code, + teamName: item.name, + shortName: item.shortName, + color: item.color, + logoPath: item.logoPath, + rank: row.rank, + gold: row.gold, + silver: row.silver, + bronze: row.bronze, + medalTotal: row.gold + row.silver + row.bronze, + totalPoints: row.totalPoints, + rankChange: row.rankChange, + } + }) +} + +function buildEvents(): Event[] { + const seeds: readonly [string, string, Event['resultType'], Event['compare']][] = [ + ['E1001', '男子 100 米', 'time', 'asc'], + ['E1002', '女子 100 米', 'time', 'asc'], + ['E2001', '男子跳远', 'distance', 'desc'], + ['E3001', '女子 4×100 米接力', 'time', 'asc'], + ['E4001', '拔河', 'count', 'desc'], + ] + return seeds.map(([code, name, resultType, compare], index) => ({ + id: index + 1, + sportId: 1, + code, + name, + eventType: index === 3 || index === 4 ? 'team' : 'individual', + resultMode: 'auto', + resultType, + compare, + recordEligible: index < 3, + scored: true, + multiplier: 1, + status: index < 3 ? 'published' : 'finished', + scheduledAt: null, + venue: null, + sortOrder: index, + remark: null, + })) +} + +interface ResultSeed { + rank: number + teamIndex: number + athlete: string + resultText: string + resultValue: number +} + +function buildRows(seeds: readonly ResultSeed[], unit: string): EventResultRow[] { + return seeds.map((seed, index) => { + const team = MOCK_TEAMS[seed.teamIndex] + return { + entryId: (seed.rank + 1) * 100 + index, + rank: seed.rank, + teamId: team?.id ?? 0, + teamCode: team?.code ?? '', + teamName: team?.name ?? '', + shortName: team?.shortName ?? null, + athleteId: index + 1, + athleteName: seed.athlete, + memberNames: null, + resultText: seed.resultText, + resultValue: seed.resultValue, + unit, + isTie: false, + status: 'ok', + } + }) +} + +function buildLatestResults(): EventResultGroup[] { + return [ + { + eventId: 1, + eventCode: 'E1001', + eventName: '男子 100 米', + categoryId: 1, + categoryName: '青年组', + publishedAt: '2026-09-09T15:42:00+08:00', + rows: buildRows( + [ + { rank: 1, teamIndex: 0, athlete: '张伟', resultText: '11.58', resultValue: 11.58 }, + { rank: 2, teamIndex: 2, athlete: '李强', resultText: '11.72', resultValue: 11.72 }, + { rank: 3, teamIndex: 1, athlete: '王磊', resultText: '11.85', resultValue: 11.85 }, + { rank: 4, teamIndex: 4, athlete: '刘洋', resultText: '11.94', resultValue: 11.94 }, + { rank: 5, teamIndex: 3, athlete: '陈晨', resultText: '12.03', resultValue: 12.03 }, + { rank: 6, teamIndex: 5, athlete: '赵鹏', resultText: '12.15', resultValue: 12.15 }, + ], + 's', + ), + }, + { + eventId: 2, + eventCode: 'E1002', + eventName: '女子 100 米', + categoryId: 2, + categoryName: '青年组', + publishedAt: '2026-09-09T15:10:00+08:00', + rows: buildRows( + [ + { rank: 1, teamIndex: 1, athlete: '李娜', resultText: '13.04', resultValue: 13.04 }, + { rank: 2, teamIndex: 0, athlete: '孙悦', resultText: '13.21', resultValue: 13.21 }, + { rank: 3, teamIndex: 3, athlete: '周敏', resultText: '13.36', resultValue: 13.36 }, + { rank: 4, teamIndex: 6, athlete: '吴倩', resultText: '13.48', resultValue: 13.48 }, + { rank: 5, teamIndex: 2, athlete: '郑爽', resultText: '13.55', resultValue: 13.55 }, + { rank: 6, teamIndex: 7, athlete: '冯雪', resultText: '13.62', resultValue: 13.62 }, + ], + 's', + ), + }, + { + eventId: 3, + eventCode: 'E2001', + eventName: '男子跳远', + categoryId: 3, + categoryName: '中年组', + publishedAt: '2026-09-09T14:35:00+08:00', + rows: buildRows( + [ + { rank: 1, teamIndex: 2, athlete: '黄涛', resultText: '6.42', resultValue: 6.42 }, + { rank: 2, teamIndex: 5, athlete: '徐斌', resultText: '6.18', resultValue: 6.18 }, + { rank: 3, teamIndex: 0, athlete: '马超', resultText: '6.05', resultValue: 6.05 }, + { rank: 4, teamIndex: 7, athlete: '朱峰', resultText: '5.96', resultValue: 5.96 }, + { rank: 5, teamIndex: 1, athlete: '胡军', resultText: '5.88', resultValue: 5.88 }, + { rank: 6, teamIndex: 4, athlete: '林凯', resultText: '5.71', resultValue: 5.71 }, + ], + 'm', + ), + }, + ] +} + +const ATHLETE_SEEDS: readonly [string, number, number, number, number, number][] = [ + ['张伟', 0, 42, 2, 1, 0], + ['李娜', 1, 38, 2, 0, 1], + ['李强', 2, 34, 1, 2, 0], + ['孙悦', 0, 30, 1, 1, 1], + ['黄涛', 2, 28, 1, 0, 2], + ['王磊', 1, 26, 1, 1, 0], + ['周敏', 3, 24, 1, 0, 1], + ['刘洋', 4, 22, 0, 2, 1], + ['徐斌', 5, 20, 1, 0, 1], + ['陈晨', 3, 18, 0, 1, 2], + ['马超', 0, 16, 0, 1, 1], + ['吴倩', 6, 14, 0, 0, 2], +] + +function buildAthletes(): AthleteStanding[] { + return ATHLETE_SEEDS.map(([name, teamIndex, totalPoints, gold, silver, bronze], index) => { + const item = MOCK_TEAMS[teamIndex] + return { + rank: index + 1, + athleteId: index + 1, + athleteName: name, + gender: null, + teamId: item?.id ?? 0, + teamCode: item?.code ?? '', + teamName: item?.name ?? '', + shortName: item?.shortName ?? null, + totalPoints, + gold, + silver, + bronze, + } + }) +} + +export function createMockSnapshot(): SnapshotMessage { + return { + // version 固定 0 且不写入缓存:真实快照一到就能直接覆盖,不会因为版本号更大而被丢弃。 + version: 0, + settings: { + event: { + name: '2026 职工运动会', + subtitle: null, + host: '示例组委会', + logoPath: null, + primaryColor: '#4fd8ff', + }, + display: { + background: null, + logo: null, + title: '2026 职工运动会', + subtitle: '积分榜 · 示例数据', + primaryColor: '#4fd8ff', + rotateSeconds: 8, + }, + standings: { + individualToTeamWeight: 1, + rankingMode: 'points', + showIndividualBoard: true, + }, + defaultProfileId: null, + requireApproval: false, + }, + teams: MOCK_TEAMS, + events: buildEvents(), + standings: buildStandings(), + records: [], + latestResults: buildLatestResults(), + } +} + +export function createMockAthletes(): AthleteStanding[] { + return buildAthletes() +} diff --git a/web/src/display/storage.ts b/web/src/display/storage.ts new file mode 100644 index 0000000..96f3fcf --- /dev/null +++ b/web/src/display/storage.ts @@ -0,0 +1,43 @@ +import type { SnapshotMessage } from '@/shared' +import { isRecord, num, parseSnapshot } from './guards' + +/** + * 断线时继续出屏靠这份 localStorage 快照(06 第 8 节)。 + * + * 只保留最近一份:现场大屏内存有限,历史版本没有回放价值; + * 读到的内容仍要过一遍 parseSnapshot,避免旧版本格式把渲染炸掉。 + */ + +const SNAPSHOT_KEY = 'scoreboard:display:snapshot' + +export interface CachedSnapshot { + version: number + savedAt: number + snapshot: SnapshotMessage +} + +export function readCachedSnapshot(): CachedSnapshot | null { + try { + const raw = localStorage.getItem(SNAPSHOT_KEY) + if (raw === null) return null + const parsed: unknown = JSON.parse(raw) + if (!isRecord(parsed)) return null + const snapshot = parseSnapshot(parsed.snapshot) + if (snapshot === null) return null + return { version: snapshot.version, savedAt: num(parsed.savedAt), snapshot } + } catch { + // 隐私模式或配额异常时静默降级:缓存只是兜底,不能因此不出屏。 + return null + } +} + +export function writeCachedSnapshot(snapshot: SnapshotMessage): void { + try { + localStorage.setItem( + SNAPSHOT_KEY, + JSON.stringify({ version: snapshot.version, savedAt: Date.now(), snapshot }), + ) + } catch { + // 写失败同样只影响断线兜底,不阻断实时数据上屏。 + } +} diff --git a/web/src/display/types.ts b/web/src/display/types.ts new file mode 100644 index 0000000..2ce490d --- /dev/null +++ b/web/src/display/types.ts @@ -0,0 +1,83 @@ +import type { TeamBreakdown } from '@/shared' + +/** + * 大屏页面清单。顺序即整页轮播顺序(公告是手动触发的页,不参与轮播)。 + */ +export const DISPLAY_PAGES = [ + { key: 'standings', title: '总积分榜' }, + { key: 'team-medals', title: '团体奖牌榜' }, + { key: 'athlete-standings', title: '个人积分奖牌榜' }, + { key: 'event-results', title: '分项成绩' }, + { key: 'announcement', title: '公告' }, +] as const + +export type DisplayPageKey = (typeof DISPLAY_PAGES)[number]['key'] + +export const PAGE_TITLES: Record = { + standings: '总积分榜', + 'team-medals': '团体奖牌榜', + 'athlete-standings': '个人积分奖牌榜', + 'event-results': '分项成绩', + announcement: '公告', +} + +/** 管理端 `goto` 下发的页名不保证与内部 key 一致,这里统一归一化。 */ +const PAGE_ALIASES: Record = { + standings: 'standings', + total: 'standings', + points: 'standings', + 积分榜: 'standings', + 总积分榜: 'standings', + 'team-medals': 'team-medals', + medals: 'team-medals', + medal: 'team-medals', + 奖牌榜: 'team-medals', + 团体奖牌榜: 'team-medals', + 'athlete-standings': 'athlete-standings', + athletes: 'athlete-standings', + individual: 'athlete-standings', + 个人积分奖牌榜: 'athlete-standings', + 个人榜: 'athlete-standings', + 'event-results': 'event-results', + results: 'event-results', + 分项成绩: 'event-results', + 成绩: 'event-results', + announcement: 'announcement', + announce: 'announcement', + 公告: 'announcement', +} + +export function normalizePage(value: string | null | undefined): DisplayPageKey | null { + if (value === null || value === undefined) return null + const key = value.trim() + if (key === '') return null + return PAGE_ALIASES[key] ?? PAGE_ALIASES[key.toLowerCase()] ?? null +} + +export interface DisplayUrlOptions { + page: DisplayPageKey | null + /** true = 主区域锁页不轮播(默认)。 */ + locked: boolean + screen: string | null + instance: string | null + /** 分项成绩页展示的已完成项目数,默认 3。 */ + resultsCount: number +} + +export interface DetailOverlayState { + title: string + detail: TeamBreakdown | null + autoCloseSeconds: number + openedAt: number + /** 管理端只给了 teamId、明细还在拉取时为 true。 */ + loading: boolean +} + +export interface AnnouncementState { + text: string + seconds: number + startedAt: number +} + +/** live = 实时/接口数据;cache = 断线时回放的本地快照;mock = 后端未就绪的示例数据。 */ +export type DisplaySource = 'live' | 'cache' | 'mock' diff --git a/web/src/display/useReducedMotion.ts b/web/src/display/useReducedMotion.ts new file mode 100644 index 0000000..b8853a9 --- /dev/null +++ b/web/src/display/useReducedMotion.ts @@ -0,0 +1,25 @@ +import { ref, type Ref } from 'vue' + +/** + * 「减少动态效果」系统偏好(06 第 7 节:所有动画在该偏好下自动关闭)。 + * + * 做成模块级单例:大屏上几十个数字组件各自 matchMedia 会白白多出几十个监听。 + */ +const reduced = ref(false) +let initialized = false + +function init(): void { + if (initialized) return + initialized = true + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + reduced.value = query.matches + query.addEventListener('change', (event) => { + reduced.value = event.matches + }) +} + +export function prefersReducedMotion(): Ref { + init() + return reduced +} diff --git a/web/src/display/useScoreboard.ts b/web/src/display/useScoreboard.ts new file mode 100644 index 0000000..19c03a3 --- /dev/null +++ b/web/src/display/useScoreboard.ts @@ -0,0 +1,507 @@ +import { computed, ref } from 'vue' +import { RealtimeClient, createApiClient } from '@/shared' +import type { + AthleteStanding, + Change, + DisplayCommand, + Event, + EventResultGroup, + EventSettings, + RealtimeStatus, + RecordEntry, + SnapshotMessage, + StandingRow, + Team, +} from '@/shared' +import { + isRecord, + list, + numOrNull, + parseAthleteStandings, + parseEvent, + parseEventResultGroups, + parseSettings, + parseStandingRow, + parseStandingRows, + parseTeam, + parseTeamBreakdown, +} from './guards' +import { createMockAthletes, createMockSnapshot } from './mock' +import { readCachedSnapshot, writeCachedSnapshot } from './storage' +import { DISPLAY_PAGES, normalizePage } from './types' +import type { + AnnouncementState, + DetailOverlayState, + DisplayPageKey, + DisplaySource, + DisplayUrlOptions, +} from './types' +import { prefersReducedMotion } from './useReducedMotion' + +/** 整页轮播间隔(06 第 4 节:后台可切换为整页轮播,每页 20s)。 */ +const PAGE_ROTATE_MS = 20_000 +/** 名次升降箭头保留时长(06 第 7 节:2s 后淡出)。 */ +const RANK_DELTA_MS = 2_000 + +const api = createApiClient() +const reducedMotion = prefersReducedMotion() + +const source = ref('live') +const status = ref('idle') +const version = ref(null) +const settings = ref(null) +const teams = ref([]) +const events = ref([]) +const standings = ref([]) +const athletes = ref([]) +const latestResults = ref([]) +const records = ref([]) +const rankDelta = ref>({}) +const page = ref('standings') +const locked = ref(true) +const options = ref({ + page: null, + locked: true, + screen: null, + instance: null, + resultsCount: 3, +}) +const detail = ref(null) +const announcement = ref(null) +const booted = ref(false) + +let client: RealtimeClient | null = null +let rankTimer: ReturnType | null = null +let detailTimer: ReturnType | null = null +let announceTimer: ReturnType | null = null +let pageTimer: ReturnType | null = null + +const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true) + +/** 参与整页轮播的页;公告是手动触发的页,个人榜按配置可隐藏。 */ +const rotationPages = computed(() => + DISPLAY_PAGES.map((item) => item.key).filter( + (key) => key !== 'announcement' && (key !== 'athlete-standings' || showAthleteBoard.value), + ), +) + +function compareStanding(a: StandingRow, b: StandingRow): number { + return a.rank - b.rank || b.totalPoints - a.totalPoints +} + +function clearTimer(timer: ReturnType | null): null { + if (timer !== null) clearTimeout(timer) + return null +} + +// --------------------------------------------------------------------------- +// 数据落地 +// --------------------------------------------------------------------------- + +/** + * 用新的积分榜替换本地榜单,并算出相对上一次的名次变化。 + * + * 服务端给的 rankChange 优先;没有基准(首屏 / 刚换页)时用本地前后对比兜底, + * 这样断线重连后即使服务端没带 rankChange,箭头也不会凭空消失。 + */ +function applyStandings(rows: StandingRow[]): void { + const previous = new Map(standings.value.map((row) => [row.teamId, row.rank])) + const deltas: Record = {} + const next = rows.map((row) => { + const before = previous.get(row.teamId) + const local = before === undefined ? null : before - row.rank + const delta = row.rankChange ?? local + if (delta !== null && delta !== 0) deltas[row.teamId] = delta + return { ...row, rankChange: delta } + }) + + standings.value = next.sort(compareStanding) + + if (Object.keys(deltas).length === 0) return + rankDelta.value = deltas + rankTimer = clearTimer(rankTimer) + rankTimer = setTimeout(() => { + rankDelta.value = {} + rankTimer = null + }, RANK_DELTA_MS) +} + +function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void { + settings.value = snapshot.settings + teams.value = snapshot.teams + events.value = snapshot.events + latestResults.value = snapshot.latestResults + records.value = snapshot.records + version.value = snapshot.version + applyStandings(snapshot.standings) + source.value = kind +} + +function mergeById( + current: T[], + change: Change, + parse: (value: unknown) => T, +): T[] { + if (change.action === 'replace') return list(change.data).map(parse) + + if (change.action === 'void') { + const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null) + return id === null ? current : current.filter((item) => item.id !== id) + } + + const item = parse(change.data) + return [...current.filter((entry) => entry.id !== item.id), item] +} + +function mergeStanding(row: StandingRow): void { + applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row]) +} + +/** + * 应用增量变更。 + * + * 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端, + * 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。 + */ +function applyChanges(changes: Change[]): void { + let resyncNeeded = false + + for (const change of changes) { + switch (change.entity) { + case 'standings': + if (change.action === 'replace') applyStandings(parseStandingRows(change.data)) + else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data)) + break + case 'athleteStandings': + case 'athlete_standings': + if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data) + break + case 'latestResults': + case 'eventResults': + if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data) + break + case 'settings': + if (change.action !== 'void') settings.value = parseSettings(change.data) + break + case 'teams': + teams.value = mergeById(teams.value, change, parseTeam) + break + case 'events': + events.value = mergeById(events.value, change, parseEvent) + break + default: + resyncNeeded = true + } + } + + if (resyncNeeded) client?.requestResync() +} + +// --------------------------------------------------------------------------- +// 断线兜底与启动 +// --------------------------------------------------------------------------- + +function hydrateFromCache(): void { + const cached = readCachedSnapshot() + if (cached === null) return + applySnapshot(cached.snapshot, 'cache') +} + +/** + * REST 兜底:快照不含个人榜,所以个人榜必须单独取; + * 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。 + */ +async function loadRest(): Promise { + const hasSnapshot = version.value !== null + const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([ + api.settings.get(), + api.standings.teams(), + api.standings.athletes(), + ]) + + let ok = false + if (settingsResult.status === 'fulfilled' && !hasSnapshot) { + settings.value = settingsResult.value + ok = true + } + if (standingsResult.status === 'fulfilled' && !hasSnapshot) { + applyStandings(standingsResult.value) + ok = true + } + if (athletesResult.status === 'fulfilled') { + athletes.value = athletesResult.value + ok = true + } + return ok +} + +function activateMock(): void { + const snapshot = createMockSnapshot() + settings.value = snapshot.settings + teams.value = snapshot.teams + events.value = snapshot.events + latestResults.value = snapshot.latestResults + records.value = snapshot.records + athletes.value = createMockAthletes() + standings.value = snapshot.standings + source.value = 'mock' + + // 示例数据也把升降箭头演示出来,方便验收动效。 + const deltas: Record = {} + for (const row of snapshot.standings) { + if (row.rankChange !== null && row.rankChange !== 0) deltas[row.teamId] = row.rankChange + } + rankDelta.value = deltas + rankTimer = clearTimer(rankTimer) + rankTimer = setTimeout(() => { + rankDelta.value = {} + rankTimer = null + }, RANK_DELTA_MS) +} + +function readUrlOptions(): DisplayUrlOptions { + const params = new URLSearchParams(window.location.search) + const lockParam = params.get('lock') + return { + page: normalizePage(params.get('page')), + // 主区域默认锁定总积分榜,只有显式 lock=0 才开整页轮播。 + locked: lockParam !== '0' && lockParam !== 'false', + screen: params.get('screen'), + instance: params.get('instance'), + resultsCount: clampInt(params.get('n'), 1, 6, 3), + } +} + +function clampInt(value: string | null, min: number, max: number, fallback: number): number { + if (value === null) return fallback + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +// --------------------------------------------------------------------------- +// 轮播 +// --------------------------------------------------------------------------- + +function stopPageRotation(): void { + if (pageTimer !== null) { + clearInterval(pageTimer) + pageTimer = null + } +} + +function startPageRotation(): void { + stopPageRotation() + if (locked.value || reducedMotion.value) return + const order = rotationPages.value + if (order.length < 2) return + + pageTimer = setInterval(() => { + const current = order.indexOf(page.value) + const next = order[(current + 1) % order.length] + if (next !== undefined) page.value = next + }, PAGE_ROTATE_MS) +} + +function setPage(next: DisplayPageKey): void { + page.value = next +} + +function setLocked(value: boolean): void { + locked.value = value + if (value) stopPageRotation() + else startPageRotation() +} + +// --------------------------------------------------------------------------- +// 覆盖层与公告 +// --------------------------------------------------------------------------- + +function clearDetailTimer(): void { + detailTimer = clearTimer(detailTimer) +} + +function closeDetail(): void { + clearDetailTimer() + detail.value = null + if (!locked.value) startPageRotation() +} + +async function openDetail(command: DisplayCommand): Promise { + const payload = command.payload + const provided = payload.detail === undefined ? null : parseTeamBreakdown(payload.detail) + const teamId = typeof payload.teamId === 'number' ? payload.teamId : null + const openedAt = Date.now() + + detail.value = { + title: payload.title ?? provided?.teamName ?? '积分明细', + detail: provided, + autoCloseSeconds: payload.autoCloseSeconds ?? 60, + openedAt, + loading: provided === null && teamId !== null, + } + // 明细覆盖期间暂停轮播(06 第 5 节)。 + stopPageRotation() + clearDetailTimer() + if (detail.value.autoCloseSeconds > 0) { + detailTimer = setTimeout(closeDetail, detail.value.autoCloseSeconds * 1000) + } + + if (!detail.value.loading || teamId === null) return + try { + const breakdown = parseTeamBreakdown(await api.standings.breakdown(teamId)) + // 请求期间可能已被 hide-detail 或自动关闭,用 openedAt 判断这份响应还属于当前覆盖层。 + if (detail.value !== null && detail.value.openedAt === openedAt) { + detail.value.detail = breakdown + detail.value.loading = false + } + } catch { + if (detail.value !== null && detail.value.openedAt === openedAt) detail.value.loading = false + } +} + +function announce(text: string, seconds: number): void { + const trimmed = text.trim() + if (trimmed === '') return + + announcement.value = { text: trimmed, seconds, startedAt: Date.now() } + const previous = page.value + page.value = 'announcement' + + announceTimer = clearTimer(announceTimer) + if (seconds > 0) { + announceTimer = setTimeout(() => { + announceTimer = null + if (page.value === 'announcement') page.value = previous + }, seconds * 1000) + } +} + +function handleCommand(command: DisplayCommand): void { + switch (command.action) { + case 'goto': { + const target = normalizePage(command.payload.page) + if (target !== null) { + closeDetail() + page.value = target + } + break + } + case 'lock': + setLocked(true) + break + case 'unlock': + setLocked(false) + break + case 'reload': + window.location.reload() + break + case 'announce': + announce(command.payload.text ?? '', command.payload.seconds ?? 0) + break + case 'show-detail': + void openDetail(command) + break + case 'hide-detail': + closeDetail() + break + } +} + +// --------------------------------------------------------------------------- +// 生命周期 +// --------------------------------------------------------------------------- + +function connect(): void { + if (client !== null) return + client = new RealtimeClient({ + role: 'display', + displayKey: options.value.instance ?? options.value.screen ?? undefined, + initialVersion: version.value, + onSnapshot: (message) => { + applySnapshot(message, 'live') + writeCachedSnapshot(message) + }, + onCatchup: (message) => { + applyChanges(message.changes) + }, + onChange: (message) => { + applyChanges(message.changes) + }, + onCommand: handleCommand, + onStatusChange: (next) => { + status.value = next + }, + onVersionChange: (next) => { + version.value = next + }, + }) + client.connect() +} + +async function bootstrap(): Promise { + if (booted.value) return + booted.value = true + + const parsed = readUrlOptions() + options.value = parsed + if (parsed.page !== null) page.value = parsed.page + locked.value = parsed.locked + + hydrateFromCache() + connect() + + const restOk = await loadRest() + if (!restOk && version.value === null) activateMock() + + if (!locked.value) startPageRotation() +} + +function dispose(): void { + stopPageRotation() + clearDetailTimer() + rankTimer = clearTimer(rankTimer) + announceTimer = clearTimer(announceTimer) + client?.close() + client = null + booted.value = false +} + +function rankDeltaFor(teamId: number): number | null { + return rankDelta.value[teamId] ?? null +} + +/** + * 大屏只有这一份状态:多块屏各自是独立窗口/标签页,不需要跨实例共享, + * 所以用模块级单例而不是 Pinia,少一层依赖。 + */ +export function useScoreboard() { + return { + source, + status, + version, + settings, + teams, + events, + standings, + athletes, + latestResults, + records, + rankDelta, + page, + locked, + options, + detail, + announcement, + booted, + rotationPages, + bootstrap, + dispose, + setPage, + setLocked, + startPageRotation, + stopPageRotation, + closeDetail, + rankDeltaFor, + } +} diff --git a/web/src/display/useStageScale.ts b/web/src/display/useStageScale.ts new file mode 100644 index 0000000..886d5ec --- /dev/null +++ b/web/src/display/useStageScale.ts @@ -0,0 +1,37 @@ +import { onUnmounted, ref } from 'vue' + +export const DESIGN_WIDTH = 1920 +export const DESIGN_HEIGHT = 1080 + +/** + * 1920×1080 设计基准 + `transform: scale()` 等比适配(06 第 3 节)。 + * + * 只按「较小的一边」缩放并居中,非 16:9 窗口留黑边而不拉伸; + * 不写死设备像素,1080p / 4K / 系统缩放都能正确铺满。 + */ +export function useStageScale() { + const scale = ref(1) + const offsetX = ref(0) + const offsetY = ref(0) + + function fit(): void { + const width = window.innerWidth + const height = window.innerHeight + const next = Math.min(width / DESIGN_WIDTH, height / DESIGN_HEIGHT) + scale.value = next + offsetX.value = (width - DESIGN_WIDTH * next) / 2 + offsetY.value = (height - DESIGN_HEIGHT * next) / 2 + } + + fit() + // display-metrics-changed(Electron 改分辨率/缩放)在页面里同样表现为 resize。 + window.addEventListener('resize', fit) + window.addEventListener('orientationchange', fit) + + onUnmounted(() => { + window.removeEventListener('resize', fit) + window.removeEventListener('orientationchange', fit) + }) + + return { scale, offsetX, offsetY, fit } +} diff --git a/web/src/shared/styles/base.css b/web/src/shared/styles/base.css index 05929b4..6ebc72d 100644 --- a/web/src/shared/styles/base.css +++ b/web/src/shared/styles/base.css @@ -1,3 +1,5 @@ +@import './fonts.css'; + /* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */ :root { --bg: #0b1e3a; @@ -10,6 +12,9 @@ --design-w: 1920px; --design-h: 1080px; + + /* 全局默认字体;各端可通过覆盖 --font-ui 切换(字体清单见 vendor/fonts/manifest.json) */ + --font-ui: var(--font-noto-sans); } * { @@ -28,8 +33,7 @@ body, body { background: var(--bg); color: var(--text); - font-family: - 'Noto Sans SC', 'Source Han Sans SC', 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif; + font-family: var(--font-ui, 'Noto Sans SC', 'Source Han Sans SC', system-ui, sans-serif); -webkit-font-smoothing: antialiased; } diff --git a/web/src/shared/styles/fonts.css b/web/src/shared/styles/fonts.css new file mode 100644 index 0000000..017af83 --- /dev/null +++ b/web/src/shared/styles/fonts.css @@ -0,0 +1,86 @@ +/* 字体声明 —— 离线部署,字体已 vendor 进仓库(vendor/fonts/),禁止任何 CDN。 + * + * 字符集:ASCII + GB2312 一级/二级汉字 + 中文标点 + 数字符号(7553 字符), + * 子集化与体积明细见 vendor/fonts/manifest.json 与 vendor/fonts/README.md。 + * + * 引用方式:在全局样式入口 `@import './fonts.css';`(本文件不自动生效)。 + * 需要新增字体:改 vendor/fonts/tools/fonts.conf 后重跑 subset-fonts.sh, + * 再在本文件补对应的 @font-face 与 --font- 变量。 + */ + +/* ---------- Noto Sans SC(思源黑体同源,无衬线,大屏正文默认) ---------- */ +@font-face { + font-family: 'Noto Sans SC'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../../../../vendor/fonts/noto-sans-regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'Noto Sans SC'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../../vendor/fonts/noto-sans-bold.woff2') format('woff2'); +} + +/* ---------- Noto Serif SC(衬线,用于标题/仪式感文案) ---------- */ +@font-face { + font-family: 'Noto Serif SC'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../../../../vendor/fonts/noto-serif-regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'Noto Serif SC'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../../vendor/fonts/noto-serif-bold.woff2') format('woff2'); +} + +/* ---------- MiSans(小米,无衬线;授权见 vendor/fonts/README.md) ---------- */ +@font-face { + font-family: 'MiSans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../../../../vendor/fonts/misans-regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'MiSans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../../vendor/fonts/misans-bold.woff2') format('woff2'); +} + +/* ---------- Source Han Sans CN(思源黑体,无衬线) ---------- */ +@font-face { + font-family: 'Source Han Sans CN'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../../../../vendor/fonts/source-han-sans-regular.woff2') format('woff2'); +} + +@font-face { + font-family: 'Source Han Sans CN'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('../../../../vendor/fonts/source-han-sans-bold.woff2') format('woff2'); +} + +/* ---------- 字体令牌:大屏 / 管理端按需切换 ---------- */ +:root { + --font-noto-sans: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --font-noto-serif: 'Noto Serif SC', 'Songti SC', 'SimSun', serif; + --font-misans: 'MiSans', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; + --font-source-han-sans: + 'Source Han Sans CN', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; +}