feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)

根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。

- 服务端 -project 缺省改为空串;解析顺序:显式 flag(并写回记忆)> <dataDir>/active_project.txt
  > default;backup/passwd 口径一致;启动日志打印生效项目与来源
- GET /api/projects(admin)列出各赛事(是否当前/是否有 event.db/大小/更新时间)
- POST /api/projects/activate(admin):校验后原子写记忆文件 → 审计 → 返回
  {ok,name,restarting:true} → 触发优雅关闭;Console 对非用户发起的退出会自动拉起,
  重启后即打开新赛事(无需改 Console)。本来就是当前赛事则 restarting:false 不重启
- 管理端「导出」页新增赛事项目列表与「切换为当前赛事」;导入成功后提示「已导入为 X,
  但还不是当前赛事」并给「立即切换并重启服务端」;切换后轮询 /api/health 等它回来再刷新
- 文档:使用手册(导入≠切换、生效四步)、常见问题(导入后看不到数据)

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:47:06 +08:00
1 parent 005b51b2ec
commit 2d207fd654
14 files changed
+1363 -24

No files matched your search

+50 -6
View File
@@ -47,7 +47,9 @@ func main() {
}
configPath := flag.String("config", "config.yaml", "配置文件路径")
project := flag.String("project", "default", "赛事项目名(data/event_projs/<项目名>/event.db)")
// 留空表示「按记忆文件里记住的上一次赛事」:Console 拉起服务端时不传 -project,
// 管理端切换赛事后靠这里读回新项目,否则导入的赛事包永远不会被打开。
project := flag.String("project", "", "赛事项目名(data/event_projs/<项目名>/event.db);留空则用记住的上一次赛事,再没有就用 default")
dataDir := flag.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
flag.Parse()
@@ -67,6 +69,19 @@ func main() {
logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir)
}
projectName, projectSource, err := resolveProjectName(cfg.DataDir, *project)
if err != nil {
logger.Error("解析赛事项目失败", "err", err)
os.Exit(1)
}
// 显式指定过就写回记忆文件:下次不带 -project 启动(Console 就是这样拉起的)仍打开同一个赛事。
if projectSource == projectSourceFlag {
if err := httpapi.WriteActiveProject(cfg.DataDir, projectName); err != nil {
logger.Warn("写回赛事项目记忆失败,本次仍按 -project 打开", "err", err)
}
}
logger.Info("当前赛事项目", "project", projectName, "source", projectSource)
st, err := store.Open(cfg.DataDir)
if err != nil {
logger.Error("打开系统库失败", "err", err)
@@ -79,7 +94,7 @@ func main() {
os.Exit(1)
}
eventDB, err := openEventDB(cfg.DataDir, *project)
eventDB, err := openEventDB(cfg.DataDir, projectName)
if err != nil {
logger.Error("打开赛事库失败", "err", err)
os.Exit(1)
@@ -89,7 +104,7 @@ func main() {
logger.Error("赛事库迁移失败", "err", err)
os.Exit(1)
}
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", *project)
logger.Info("数据库就绪", "dataDir", cfg.DataDir, "project", projectName)
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
hub := realtime.NewHub(eventDB, logger)
@@ -130,7 +145,7 @@ func main() {
Backup: backupSvc,
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
Shutdown: stop,
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", projectName),
Display: displaySender{hub: hub},
Realtime: hub,
WS: hub.Handler(),
@@ -181,11 +196,35 @@ func (d displaySender) SendDisplayCommand(action string, payload any) error {
return nil
}
// 赛事项目名的来源,只用于启动日志:现场据此确认这次打开的是哪一个赛事。
const (
defaultProjectName = "default"
projectSourceFlag = "命令行 -project"
projectSourceFile = "记忆文件 " + httpapi.ActiveProjectFile
projectSourceDefault = "默认"
)
// resolveProjectName 按「显式 -project → 数据目录里的记忆文件 → default」确定当前赛事项目名。
// 只解析与校验、不落盘:写回记忆文件是服务端启动路径自己的事,backup / passwd 不该顺手改掉它。
func resolveProjectName(dataDir, flagName string) (name, source string, err error) {
if v := strings.TrimSpace(flagName); v != "" {
safe, err := export.SafeProjectName(v)
if err != nil {
return "", "", fmt.Errorf("赛事项目名 %q 非法: %w", v, err)
}
return safe, projectSourceFlag, nil
}
if remembered := httpapi.ReadActiveProject(dataDir); remembered != "" {
return remembered, projectSourceFile, nil
}
return defaultProjectName, projectSourceDefault, 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", "赛事项目名")
project := fs.String("project", "", "赛事项目名;留空则用记住的上一次赛事,再没有就用 default")
reason := fs.String("reason", "manual", "备份原因:manual / publish / auto")
dataDir := fs.String("data", "", "数据目录;非空时覆盖配置文件里的 dataDir")
if err := fs.Parse(args); err != nil {
@@ -198,6 +237,11 @@ func runBackup(args []string) error {
}
// 与服务端主命令同一语义:备份的必须是 Console 实际在用的那个数据目录。
cfg.ApplyDataDir(*dataDir)
// 项目名也按同一顺序解析:备份的必须是服务端当前打开的那个赛事库。
projectName, _, err := resolveProjectName(cfg.DataDir, *project)
if err != nil {
return err
}
dir := filepath.Join(cfg.DataDir, "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
@@ -212,7 +256,7 @@ func runBackup(args []string) error {
return err
}
evPath := filepath.Join(cfg.DataDir, "event_projs", *project, "event.db")
evPath := filepath.Join(cfg.DataDir, "event_projs", projectName, "event.db")
if _, err := os.Stat(evPath); err == nil {
evDB, err := openSQLite(evPath)
if err != nil {