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:
1 parent
005b51b2ec
commit
2d207fd654
14 files changed
+1363
-24
No files matched your search
+50
-6
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user