fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# Windows 批处理必须是 CRLF:cmd.exe 在纯 LF 下可能解析不了标签(:label)与多行 if 块
*.bat text eol=crlf
*.cmd text eol=crlf
# 其余文本统一 LF,避免跨平台 diff 噪声
*.go text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.css text eol=lf
*.html text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.nsh text eol=crlf
# 二进制资源不做行尾转换
*.png binary
*.ico binary
*.woff2 binary
*.zip binary
*.exe binary
+2 -2
View File
@@ -25,8 +25,8 @@
3. 浏览器打开终端提示的地址 3. 浏览器打开终端提示的地址
``` ```
大屏 http://<主机IP>:8080/display 大屏 http://<主机IP>:19540/display
管理面板 http://<主机IP>:8080/admin 管理面板 http://<主机IP>:19540/admin
``` ```
> 完全离线运行,不依赖外网,不需要安装任何运行时。 > 完全离线运行,不依赖外网,不需要安装任何运行时。
+6 -1
View File
@@ -15,7 +15,12 @@ const DEFAULTS = {
healthIntervalMs: 5000, healthIntervalMs: 5000,
restartDelayMs: 3000, restartDelayMs: 3000,
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来 // 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
maxRestarts: 0 maxRestarts: 0,
// 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号
shutdownPath: '/api/shutdown',
shutdownTimeoutMs: 12000,
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
healthyResetMs: 60000
}, },
display: { display: {
// all-secondary | all | specified // all-secondary | all | specified
+10 -1
View File
@@ -52,7 +52,7 @@ class DisplayManager {
} else if (selection === SELECTION.SPECIFIED) { } else if (selection === SELECTION.SPECIFIED) {
picked = this.pickSpecified(all) picked = this.pickSpecified(all)
} else { } else {
picked = all.filter((item) => item.id !== primary.id) picked = this.pickSecondary(all, primary)
} }
// 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景, // 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景,
@@ -61,6 +61,15 @@ class DisplayManager {
return { single: false, displays: picked } return { single: false, displays: picked }
} }
// 默认策略是「除内置屏外的每块屏」(doc 06 §6.2)。用 display.internal 判断,
// 而不是拿主屏当替身:现场常把主屏设成外接 HDMI,用主屏排除会把 kiosk 开到笔记本屏。
// 全部屏都报 internal=false 时(纯台式机)退回「排除主屏」的旧口径。
pickSecondary(all, primary) {
const external = all.filter((item) => item.internal === false)
if (external.length && external.length < all.length) return external
return all.filter((item) => item.id !== primary.id)
}
// displays 配置里可以写 display.id,也可以写 0 起的序号 // displays 配置里可以写 display.id,也可以写 0 起的序号
pickSpecified(all) { pickSpecified(all) {
const wanted = Array.isArray(this.display.displays) ? this.display.displays : [] const wanted = Array.isArray(this.display.displays) ? this.display.displays : []
+20 -12
View File
@@ -7,8 +7,7 @@ const {
Menu, Menu,
Tray, Tray,
ipcMain, ipcMain,
powerSaveBlocker, powerSaveBlocker
shell
} = require('electron') } = require('electron')
const { loadConfig, resolveExecutable } = require('./config') const { loadConfig, resolveExecutable } = require('./config')
@@ -242,6 +241,18 @@ function main() {
tray.on('double-click', () => showAdmin()) tray.on('double-click', () => showAdmin())
} }
// 开机自启:只对打包后的 Windows/macOS 程序有意义,开发态注册的是 electron 可执行文件
function applyOpenAtLogin() {
if (!app.isPackaged || (process.platform !== 'win32' && process.platform !== 'darwin')) return
const wanted = !cfg.values.startup || cfg.values.startup.openAtLogin !== false
try {
app.setLoginItemSettings({ openAtLogin: wanted })
log.push('console', wanted ? '开机自启已启用' : '开机自启已关闭')
} catch (err) {
log.push('console', `设置开机自启失败:${err.message}`)
}
}
function registerIpc() { function registerIpc() {
ipcMain.handle('service:get-status', () => statusSnapshot()) ipcMain.handle('service:get-status', () => statusSnapshot())
ipcMain.handle('service:start', () => { ipcMain.handle('service:start', () => {
@@ -265,14 +276,6 @@ function main() {
if (displays && displaysEnabled) displays.relayout() if (displays && displaysEnabled) displays.relayout()
return statusSnapshot() return statusSnapshot()
}) })
ipcMain.handle('app:open-external', (_event, url) => {
// 只放行 http/https,避免管理窗口被注入的链接拉去执行本地协议
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
shell.openExternal(url)
return true
}
return false
})
ipcMain.handle('app:open-admin-window', () => { ipcMain.handle('app:open-admin-window', () => {
showAdmin() showAdmin()
return true return true
@@ -308,8 +311,6 @@ function main() {
}) })
} }
app.on('second-instance', () => showAdmin())
app.whenReady().then(() => { app.whenReady().then(() => {
cfg = loadConfig(resolveConfigPath()) cfg = loadConfig(resolveConfigPath())
log = new LogBus({ log = new LogBus({
@@ -367,6 +368,13 @@ function main() {
createTray() createTray()
registerIpc() registerIpc()
// 单实例锁在 whenReady 之前就生效,但回调要用 cfg;注册放在这里,
// 避免第二个实例在配置加载完成前触发 showAdmin() 读到 null
app.on('second-instance', () => showAdmin())
// 现场无人值守,Console 需要随系统启动(doc 10 §4.3)
applyOpenAtLogin()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠 // 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep') powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`) log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
+8 -1
View File
@@ -22,7 +22,14 @@ class LogBus {
try { try {
fs.mkdirSync(path.dirname(file), { recursive: true }) fs.mkdirSync(path.dirname(file), { recursive: true })
if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) { if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) {
fs.renameSync(file, `${file}.1`) // Windows 上 rename 到已存在的目标会失败,先删掉上一份轮转文件
const rotated = `${file}.1`
try {
fs.rmSync(rotated, { force: true })
} catch {
// 删不掉就让 rename 失败,落到下面的「仅内存日志」兜底
}
fs.renameSync(file, rotated)
} }
this.stream = fs.createWriteStream(file, { flags: 'a' }) this.stream = fs.createWriteStream(file, { flags: 'a' })
} catch (err) { } catch (err) {
+107 -18
View File
@@ -14,6 +14,17 @@ const STATE_LABEL = {
crashed: '异常退出' crashed: '异常退出'
} }
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
// 握手不可用时信号已能结束进程,不必再等满超时
const FALLBACK_FORCE_KILL_MS = 1500
// 连续健康这么久才认为崩溃计数可以清零
const HEALTHY_RESET_MS = 60000
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。 // 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。 // 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter { class ServiceSupervisor extends EventEmitter {
@@ -33,6 +44,7 @@ class ServiceSupervisor extends EventEmitter {
this.lastError = null this.lastError = null
this.stopRequested = false this.stopRequested = false
this.healthySince = null
this.healthTimer = null this.healthTimer = null
this.restartTimer = null this.restartTimer = null
this.forceKillTimer = null this.forceKillTimer = null
@@ -90,6 +102,7 @@ class ServiceSupervisor extends EventEmitter {
const cwd = path.dirname(this.exePath) const cwd = path.dirname(this.exePath)
this.lastError = null this.lastError = null
this.healthySince = null
this.startedAt = new Date().toISOString() this.startedAt = new Date().toISOString()
this.setState('starting') this.setState('starting')
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`) this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
@@ -140,6 +153,7 @@ class ServiceSupervisor extends EventEmitter {
this.child = null this.child = null
this.pid = null this.pid = null
this.healthy = false this.healthy = false
this.healthySince = null
const reason = signal ? `信号 ${signal}` : `退出码 ${code}` const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
if (this.stopRequested) { if (this.stopRequested) {
@@ -187,25 +201,80 @@ class ServiceSupervisor extends EventEmitter {
this.log.push('console', '正在停止服务端…') this.log.push('console', '正在停止服务端…')
child.once('exit', () => resolve()) child.once('exit', () => resolve())
try {
child.kill() const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
} catch (err) { // 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
this.log.push('console', `发送停止信号失败:${err.message}`) this.armForceKill(timeoutMs)
}
// Go 服务端有 10s 优雅关闭窗口,超过 5s 仍未退出就强杀,避免 Console 退不干净 this.requestShutdown().then((accepted) => {
this.forceKillTimer = setTimeout(() => { if (this.child !== child) return
if (this.child && this.pid) { if (accepted) {
this.log.push('console', '服务端未在 5 秒内退出,强制结束') this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
try { return
this.child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
} }
}, 5000) // 握手不可用:服务端还没实现该接口,或进程已无响应。
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
this.log.push('console', '关闭接口不可用,退回进程信号')
this.armForceKill(FALLBACK_FORCE_KILL_MS)
try {
child.kill()
} catch (err) {
this.log.push('console', `发送停止信号失败:${err.message}`)
}
})
}) })
} }
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
requestShutdown() {
return new Promise((resolve) => {
const body = Buffer.from('{}')
const request = http.request(
{
host: this.healthHost(),
port: this.server.port,
path: this.server.shutdownPath || DEFAULT_SHUTDOWN_PATH,
method: 'POST',
timeout: SHUTDOWN_REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
Connection: 'close'
}
},
(response) => {
response.resume()
const accepted = response.statusCode >= 200 && response.statusCode < 300
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
resolve(accepted)
}
)
request.on('timeout', () => request.destroy(new Error('请求超时')))
request.on('error', (err) => {
this.log.push('console', `关闭接口请求失败:${err.message}`)
resolve(false)
})
request.end(body)
})
}
armForceKill(ms) {
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
this.forceKillTimer = setTimeout(() => {
this.forceKillTimer = null
if (!this.child || !this.pid) return
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
try {
this.child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
}, ms)
}
async restart() { async restart() {
await this.stop() await this.stop()
this.restarts = 0 this.restarts = 0
@@ -247,10 +316,30 @@ class ServiceSupervisor extends EventEmitter {
} }
setHealthy(ok) { setHealthy(ok) {
if (this.healthy === ok) return const changed = this.healthy !== ok
if (ok) {
if (!this.healthySince) this.healthySince = Date.now()
} else {
this.healthySince = null
}
this.healthy = ok this.healthy = ok
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)') if (changed) {
this.emitStatus() this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
}
// 连续健康一段时间后清零崩溃计数:否则长时间赛事里历史崩溃会耗尽
// maxRestarts 配额,之后真崩了反而不自动拉起
let reset = false
if (ok && this.restarts > 0) {
const resetAfter = Number(this.server.healthyResetMs) || HEALTHY_RESET_MS
if (Date.now() - this.healthySince >= resetAfter) {
this.restarts = 0
reset = true
this.log.push('console', '服务端持续健康,崩溃计数已清零')
}
}
if (changed || reset) this.emitStatus()
} }
dispose() { dispose() {
+2413
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -17,7 +17,6 @@ contextBridge.exposeInMainWorld('consoleApi', {
getLog: () => ipcRenderer.invoke('log:tail'), getLog: () => ipcRenderer.invoke('log:tail'),
clearLog: () => ipcRenderer.invoke('log:clear'), clearLog: () => ipcRenderer.invoke('log:clear'),
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'), relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
openExternal: (url) => ipcRenderer.invoke('app:open-external', url),
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'), openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
minimize: () => ipcRenderer.invoke('window:minimize'), minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'), maximize: () => ipcRenderer.invoke('window:maximize'),
+2 -2
View File
@@ -3,6 +3,8 @@ module git.yeij.top/AskaEth/Scoreboard/server
go 1.26.0 go 1.26.0
require ( require (
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.57.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.58.0 modernc.org/sqlite v1.58.0
) )
@@ -18,9 +20,7 @@ require (
github.com/richardlehane/msoleps v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.11.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
+18 -3
View File
@@ -162,12 +162,13 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
return out return out
} }
// RecomputeEventLedger 重算某个小项的流水:旧流水整项作废,再追加新流水。 // RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。 // 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) { func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
active := make([]LedgerEntry, 0, len(existing)) active := make([]LedgerEntry, 0, len(existing))
for _, e := range existing { for _, e := range existing {
if e.EventID == eventID && !e.Voided { if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
active = append(active, e) active = append(active, e)
} }
} }
@@ -175,10 +176,24 @@ func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerE
return append([]LedgerEntry(nil), existing...), nil return append([]LedgerEntry(nil), existing...), nil
} }
out, _ := VoidByEvent(existing, eventID, by, reason) out := append([]LedgerEntry(nil), existing...)
for i := range out {
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
}
return AppendLedger(out, fresh...) return AppendLedger(out, fresh...)
} }
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
// 人工调整由人负责,重算不得触碰。
func recomputableSource(t SourceType) bool {
return t == SourceResult || t == SourceRecordBonus
}
// ApplyLedgerOps 按给定顺序应用增量操作。 // ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。 // 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) { func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
+4
View File
@@ -84,6 +84,7 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
// recordBonusLedger 把破纪录加分写成流水。 // recordBonusLedger 把破纪录加分写成流水。
// 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。 // 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。
// 只处理本组别的报名项:上层按组别逐个调用 ScoreEvent,不加组别过滤会让同一笔加分每组各记一次。
func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) { func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 { if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil return nil, nil
@@ -103,6 +104,9 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
byID := make(map[int64]Entry, len(in.Entries)) byID := make(map[int64]Entry, len(in.Entries))
for _, e := range in.Entries { for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
continue
}
byID[e.ID] = e byID[e.ID] = e
} }
+45 -55
View File
@@ -12,6 +12,7 @@ const rankEpsilon = 1e-9
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。 // ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
type ScoreOptions struct { type ScoreOptions struct {
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。 // UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
// 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。
UnionAttribution UnionAttribution UnionAttribution UnionAttribution
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。 // IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
IncludeDrafts bool IncludeDrafts bool
@@ -57,26 +58,44 @@ type RankedResult struct {
} }
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 // ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
// 项目 scored=false 或没有通道时不产生任何流水(只排名不计分)。 // 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
func ScoreEvent(in EventScoreInput) (EventScore, error) { func ScoreEvent(in EventScoreInput) (EventScore, error) {
var out EventScore var out EventScore
if !in.Event.Scored { rankOnly := len(in.Channels) == 0
return out, nil channels := sortedChannels(in.Channels)
if rankOnly {
channels = []ScoreChannel{rankOnlyChannel(in)}
} }
for _, ch := range sortedChannels(in.Channels) { for _, ch := range channels {
profile, ok := in.Profiles[ch.ProfileID] // 不计分时不需要积分表:既不查模板,也不可能算出非零分。
if !ok { profile := ScoringProfile{}
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID) if in.Event.Scored && !rankOnly {
p, ok := in.Profiles[ch.ProfileID]
if !ok {
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
}
profile = p
} }
ranked, err := scoreChannel(in, ch, profile) ranked, err := scoreChannel(in, ch, profile)
if err != nil { if err != nil {
return EventScore{}, err return EventScore{}, err
} }
if !in.Event.Scored {
for i := range ranked {
ranked[i].Points = 0
}
}
out.Ranked = append(out.Ranked, ranked...) out.Ranked = append(out.Ranked, ranked...)
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...) if in.Event.Scored {
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
}
} }
if !in.Event.Scored {
return out, nil
}
bonus, err := recordBonusLedger(in) bonus, err := recordBonusLedger(in)
if err != nil { if err != nil {
return EventScore{}, err return EventScore{}, err
@@ -85,6 +104,20 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
return out, nil return out, nil
} }
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
basis := BasisResult
if in.Event.ResultMode == ResultModeManual {
basis = BasisManual
}
scope := ScopeAthlete
if in.Event.EventType == EventTypeTeam || in.Event.EventType == EventTypeMatch {
scope = ScopeTeam
}
return ScoreChannel{EventID: in.Event.ID, Scope: scope, Basis: basis}
}
// sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。 // sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。
func sortedChannels(channels []ScoreChannel) []ScoreChannel { func sortedChannels(channels []ScoreChannel) []ScoreChannel {
out := append([]ScoreChannel(nil), channels...) out := append([]ScoreChannel(nil), channels...)
@@ -325,22 +358,17 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
return rr return rr
} }
// scoreByAggregate 先把同一主体的成绩汇总成一个值,再按值排名(预留能力)。 // scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
cands, err := collectCandidates(in, ch) cands, err := collectCandidates(in, ch)
if err != nil { if err != nil {
return nil, err return nil, err
} }
method := ch.Aggregate
if method == "" {
method = AggregateSum
}
type bucket struct { type bucket struct {
subject int64 subject int64
entry Entry entry Entry
values []float64 total float64
} }
order := make([]int64, 0) order := make([]int64, 0)
buckets := make(map[int64]*bucket) buckets := make(map[int64]*bucket)
@@ -358,13 +386,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
buckets[subject] = b buckets[subject] = b
order = append(order, subject) order = append(order, subject)
} }
b.values = append(b.values, c.value) b.total += c.value
} }
agg := make([]candidate, 0, len(order)) agg := make([]candidate, 0, len(order))
for _, id := range order { for _, id := range order {
b := buckets[id] b := buckets[id]
c := candidate{entry: b.entry, subject: b.subject, value: aggregateValue(method, b.values)} c := candidate{entry: b.entry, subject: b.subject, value: b.total}
c.entry.ID = 0 // 聚合结果不对应单条报名项 c.entry.ID = 0 // 聚合结果不对应单条报名项
agg = append(agg, c) agg = append(agg, c)
} }
@@ -408,44 +436,6 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
return out, nil return out, nil
} }
func aggregateValue(method AggregateMethod, values []float64) float64 {
if len(values) == 0 {
return 0
}
switch method {
case AggregateAvg:
var sum float64
for _, v := range values {
sum += v
}
return sum / float64(len(values))
case AggregateMax:
best := values[0]
for _, v := range values[1:] {
if v > best {
best = v
}
}
return best
case AggregateMin:
best := values[0]
for _, v := range values[1:] {
if v < best {
best = v
}
}
return best
case AggregateCount:
return float64(len(values))
default:
var sum float64
for _, v := range values {
sum += v
}
return sum
}
}
func sortCandidatesByValue(cands []candidate, compare CompareMode) { func sortCandidatesByValue(cands []candidate, compare CompareMode) {
sort.SliceStable(cands, func(i, j int) bool { sort.SliceStable(cands, func(i, j int) bool {
if nearlyEqual(cands[i].value, cands[j].value) { if nearlyEqual(cands[i].value, cands[j].value) {
+102 -34
View File
@@ -2,12 +2,32 @@ package domain
import "sort" import "sort"
// 榜单排序键,取值与规则包 standings.tieBreak 一致。
// 定义在 domain 是为了让 rules 包复用同一套键名,避免两边各写一份常量。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
)
// TieBreak 未配置时的口径,与 devdocs 02 §5.2 一致。
var (
defaultTeamTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName}
)
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。 // StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
type StandingsInput struct { type StandingsInput struct {
Teams []Team Teams []Team
Athletes []Athlete Athletes []Athlete
Ledger []LedgerEntry Ledger []LedgerEntry
Ranked []RankedResult Ranked []RankedResult
// TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name);
// 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。
TieBreak []string
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。 // IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
IndividualToTeamWeight float64 IndividualToTeamWeight float64
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。 // ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
@@ -33,7 +53,7 @@ func rankMedal(rank int) int {
} }
// BuildTeamStandings 生成总积分榜。 // BuildTeamStandings 生成总积分榜。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序:总分 → 金 → 银 → 铜 → 单位编码。 // 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
func BuildTeamStandings(in StandingsInput) []StandingsRow { func BuildTeamStandings(in StandingsInput) []StandingsRow {
weight := effectiveWeight(in.IndividualToTeamWeight) weight := effectiveWeight(in.IndividualToTeamWeight)
sums := SumByTeam(in.Ledger, weight) sums := SumByTeam(in.Ledger, weight)
@@ -62,7 +82,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
} }
} }
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j]) }) sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
for i := range rows { for i := range rows {
rows[i].Rank = i + 1 rows[i].Rank = i + 1
} }
@@ -92,7 +112,7 @@ func BuildMedalStandings(in StandingsInput) []MedalRow {
} }
// BuildAthleteStandings 生成个人积分奖牌榜。 // BuildAthleteStandings 生成个人积分奖牌榜。
// 排序:总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。 // 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow { func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
points := SumByAthlete(in.Ledger) points := SumByAthlete(in.Ledger)
medals := athleteMedals(in) medals := athleteMedals(in)
@@ -154,7 +174,7 @@ func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
}) })
} }
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j]) }) sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j], in.TieBreak) })
for i := range rows { for i := range rows {
rows[i].Rank = i + 1 rows[i].Rank = i + 1
} }
@@ -237,43 +257,91 @@ func athleteMedals(in StandingsInput) map[int64]medalTally {
return out return out
} }
func lessTeamRow(a, b StandingsRow) bool { // tieBreakKeys 把配置的排序键展开成实际比较顺序:配置优先,缺失的默认键补在后面,
if !nearlyEqual(a.Points, b.Points) { // 保证两行在所有键上都相等时仍能由调用方的 ID 兜底分出先后。
return a.Points > b.Points func tieBreakKeys(cfg, def []string) []string {
if len(cfg) == 0 {
return def
} }
if a.Gold != b.Gold { out := make([]string, 0, len(cfg)+len(def))
return a.Gold > b.Gold seen := make(map[string]bool, len(cfg)+len(def))
for _, key := range cfg {
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
} }
if a.Silver != b.Silver { for _, key := range def {
return a.Silver > b.Silver if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
} }
if a.Bronze != b.Bronze { return out
return a.Bronze > b.Bronze }
}
if a.Code != b.Code { func lessTeamRow(a, b StandingsRow, tieBreak []string) bool {
return a.Code < b.Code for _, key := range tieBreakKeys(tieBreak, defaultTeamTieBreak) {
switch key {
case TieBreakPoints:
if !nearlyEqual(a.Points, b.Points) {
return a.Points > b.Points
}
case TieBreakGold:
if a.Gold != b.Gold {
return a.Gold > b.Gold
}
case TieBreakSilver:
if a.Silver != b.Silver {
return a.Silver > b.Silver
}
case TieBreakBronze:
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
case TieBreakTeamCode:
if a.Code != b.Code {
return a.Code < b.Code
}
case TieBreakName:
if a.Name != b.Name {
return a.Name < b.Name
}
}
} }
return a.TeamID < b.TeamID return a.TeamID < b.TeamID
} }
func lessAthleteRow(a, b AthleteStandingsRow) bool { func lessAthleteRow(a, b AthleteStandingsRow, tieBreak []string) bool {
if !nearlyEqual(a.Points, b.Points) { for _, key := range tieBreakKeys(tieBreak, defaultAthleteTieBreak) {
return a.Points > b.Points switch key {
} case TieBreakPoints:
if a.Gold != b.Gold { if !nearlyEqual(a.Points, b.Points) {
return a.Gold > b.Gold return a.Points > b.Points
} }
if a.Silver != b.Silver { case TieBreakGold:
return a.Silver > b.Silver if a.Gold != b.Gold {
} return a.Gold > b.Gold
if a.Bronze != b.Bronze { }
return a.Bronze > b.Bronze case TieBreakSilver:
} if a.Silver != b.Silver {
if a.Name != b.Name { return a.Silver > b.Silver
return a.Name < b.Name }
} case TieBreakBronze:
if a.TeamCode != b.TeamCode { if a.Bronze != b.Bronze {
return a.TeamCode < b.TeamCode return a.Bronze > b.Bronze
}
case TieBreakTeamCode:
if a.TeamCode != b.TeamCode {
return a.TeamCode < b.TeamCode
}
case TieBreakName:
if a.Name != b.Name {
return a.Name < b.Name
}
}
} }
return a.AthleteID < b.AthleteID return a.AthleteID < b.AthleteID
} }
+14 -20
View File
@@ -98,17 +98,6 @@ const (
BasisAggregate Basis = "aggregate" // 按团体汇总(预留) BasisAggregate Basis = "aggregate" // 按团体汇总(预留)
) )
// AggregateMethod 是 aggregate 通道的汇总口径,默认 sum。
type AggregateMethod string
const (
AggregateSum AggregateMethod = "sum"
AggregateAvg AggregateMethod = "avg"
AggregateMax AggregateMethod = "max"
AggregateMin AggregateMethod = "min"
AggregateCount AggregateMethod = "count"
)
// TieMode 并列名次口径。 // TieMode 并列名次口径。
type TieMode string type TieMode string
@@ -136,11 +125,14 @@ const (
) )
// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。 // UnionAttribution 联队(一条成绩归属多个单位)的归属口径。
// 未实现:库中没有保存联队份额的表,加载器也不会填充 Entry.TeamShares,
// ScoreOptions.UnionAttribution 在生产链路里没有任何赋值入口;
// 规则包校验也不接受相关配置。这里只保留数据结构,本期不启用(devdocs 01 §115)。
type UnionAttribution string type UnionAttribution string
const ( const (
UnionWhole UnionAttribution = "whole" // 整体记给主单位 UnionWhole UnionAttribution = "whole" // 整体记给主单位
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分 UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分(未实现)
) )
// AdjustmentCategory 人工调整类别。 // AdjustmentCategory 人工调整类别。
@@ -230,14 +222,15 @@ type EventCategory struct {
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。 // ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。 // 一个项目可配 0..N 个通道;0 个表示只排名不计分。
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
type ScoreChannel struct { type ScoreChannel struct {
ID int64 `json:"id"` ID int64 `json:"id"`
EventID int64 `json:"eventId"` EventID int64 `json:"eventId"`
Scope Scope `json:"scope"` Scope Scope `json:"scope"`
Basis Basis `json:"basis"` Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"` ProfileID int64 `json:"profileId"`
Aggregate AggregateMethod `json:"aggregate,omitempty"` // 仅 basis=aggregate 时使用 SortOrder int `json:"sortOrder,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
} }
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。 // ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
@@ -259,6 +252,7 @@ type ScoringProfile struct {
} }
// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。 // TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。
// 未实现:没有建表、没有 loader 填充、也没有写入入口(见 UnionAttribution 注释)。
type TeamShare struct { type TeamShare struct {
TeamID int64 `json:"teamId"` TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"` Weight float64 `json:"weight"`
@@ -273,7 +267,7 @@ type Entry struct {
AthleteID int64 `json:"athleteId,omitempty"` AthleteID int64 `json:"athleteId,omitempty"`
AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜 AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜
MemberNames []string `json:"memberNames,omitempty"` MemberNames []string `json:"memberNames,omitempty"`
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID(联队未实现,见 UnionAttribution)
Bib string `json:"bib,omitempty"` Bib string `json:"bib,omitempty"`
Seed string `json:"seed,omitempty"` Seed string `json:"seed,omitempty"`
Status EntryStatus `json:"status"` Status EntryStatus `json:"status"`
+31
View File
@@ -0,0 +1,31 @@
package dto
// EventResultGroup 是 snapshot.latestResults 的一组「小项 + 组别」成绩,
// 与 web/src/shared/types.ts 的 EventResultGroup 对齐。
type EventResultGroup struct {
EventID int64 `json:"eventId"`
EventCode string `json:"eventCode"`
EventName string `json:"eventName"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
PublishedAt *string `json:"publishedAt"`
Rows []EventResultRow `json:"rows"`
}
// EventResultRow 是组内的一条成绩行;字段与 types.ts 的 EventResultRow 一一对应。
type EventResultRow struct {
EntryID int64 `json:"entryId"`
Rank *int `json:"rank"`
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode"`
TeamName string `json:"teamName"`
ShortName *string `json:"shortName"`
AthleteID *int64 `json:"athleteId"`
AthleteName *string `json:"athleteName"`
MemberNames []string `json:"memberNames"`
ResultText string `json:"resultText"`
ResultValue *float64 `json:"resultValue"`
Unit string `json:"unit"`
IsTie bool `json:"isTie"`
Status string `json:"status"`
}
+132
View File
@@ -0,0 +1,132 @@
// Package dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
//
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
// 本包只依赖 domain 与标准库,不做任何 IO。
package dto
import (
"strconv"
"strings"
)
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
// 与 web/src/shared/types.ts 的 EventSettings 一一对应。
type EventSettings struct {
Event EventSettingsEvent `json:"event"`
Display EventSettingsDisplay `json:"display"`
Standings EventSettingsStandings `json:"standings"`
DefaultProfileID *int64 `json:"defaultProfileId"`
RequireApproval bool `json:"requireApproval"`
}
// EventSettingsEvent 是赛事基本信息。
type EventSettingsEvent struct {
Name string `json:"name"`
Subtitle *string `json:"subtitle"`
Host *string `json:"host"`
LogoPath *string `json:"logoPath"`
PrimaryColor *string `json:"primaryColor"`
}
// EventSettingsDisplay 是大屏展示配置。
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"`
}
// EventSettingsStandings 是榜单口径配置。
type EventSettingsStandings struct {
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
RankingMode string `json:"rankingMode"`
ShowIndividualBoard bool `json:"showIndividualBoard"`
}
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
//
// 默认值必须与 REST 的 getSettings 逐条一致:大屏首包走 snapshot、管理端走 /api/settings,
// 两边口径不同会让同一块屏刷新前后显示不一样的标题。
// defaultProfileId 需要查 scoring_profiles 反查 code,属于 IO,由调用方补填。
func ParseEventSettings(values map[string]string) EventSettings {
var out EventSettings
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)
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func orDefault(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
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
}
}
+65
View File
@@ -0,0 +1,65 @@
package dto
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
// StandingRow 是 GET /api/standings 与 snapshot.standings 的线上结构。
//
// 字段名与 web/src/shared/types.ts 的 StandingRow 对齐:大屏与管理端共用一份解析逻辑,
// 不能再把 domain.StandingsRow 的 points/code/medals 直接下发。
type StandingRow 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"`
}
// StandingRows 把 domain 榜单转换成线上结构。
// teams 用于补齐颜色/logo 等 domain 榜单没带的展示字段;rankChange 快照没有对比基准,与 REST 一致下发 null。
func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow {
byID := make(map[int64]domain.Team, len(teams))
for _, t := range teams {
byID[t.ID] = t
}
out := make([]StandingRow, 0, len(rows))
for _, r := range rows {
t := byID[r.TeamID]
row := StandingRow{
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 {
row.Color = nilIfEmpty(t.Color)
row.LogoPath = nilIfEmpty(t.LogoPath)
if row.TeamCode == "" {
row.TeamCode = t.Code
}
if row.TeamName == "" {
row.TeamName = t.Name
}
if row.ShortName == nil {
row.ShortName = nilIfEmpty(t.ShortName)
}
}
out = append(out, row)
}
return out
}
+17 -11
View File
@@ -24,24 +24,30 @@ type client struct {
closeCode websocket.StatusCode closeCode websocket.StatusCode
closeReason string closeReason string
mu sync.Mutex mu sync.Mutex
greeted bool greeted bool
role string // role/userID 是 hello 之后实际生效的身份;sessionRole/sessionUserID 是握手时从会话取到的上限,
userID int64 // 客户端自报的 role/userId 不能越过它。
instanceID string role string
userID int64
sessionRole string
sessionUserID int64
instanceID string
ackVersion atomic.Int64 // 客户端已确认应用到的版本 ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本 lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool slow atomic.Bool
} }
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client { func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
return &client{ return &client{
hub: h, hub: h,
conn: conn, conn: conn,
remoteAddr: remoteAddr, remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer), send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}), done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
} }
} }
+44 -9
View File
@@ -9,6 +9,9 @@ import (
"time" "time"
"github.com/coder/websocket" "github.com/coder/websocket"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
) )
// 默认参数;现场可通过 Set* 调整。 // 默认参数;现场可通过 Set* 调整。
@@ -210,14 +213,26 @@ func (h *Hub) Handler() http.Handler {
h.mu.Unlock() h.mu.Unlock()
defer h.connWG.Done() defer h.connWG.Done()
h.serve(conn, r.RemoteAddr) // 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
role, userID := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID)
}) })
} }
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string) { // wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
func wsIdentity(ctx context.Context) (string, int64) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
return RoleDisplay, 0
}
return RoleAdmin, u.ID
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
conn.SetReadLimit(1 << 20) conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr) c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
h.addClient(c) h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx) ctx, cancel := context.WithCancel(h.ctx)
@@ -293,22 +308,42 @@ func (h *Hub) roomClients(room string) []*client {
return out return out
} }
// handleHello 只信任会话身份:客户端自报的 role/userId 会被忽略,
// 管理端房间必须由登录会话授予,未登录连接请求管理端直接断开。
func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) { func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
if p.Role != RoleDisplay && p.Role != RoleAdmin { c.mu.Lock()
sessionRole, sessionUserID := c.sessionRole, c.sessionUserID
c.mu.Unlock()
if p.Role != "" && p.Role != RoleDisplay && p.Role != RoleAdmin {
h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr) h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "role 非法") c.stop(websocket.StatusPolicyViolation, "role 非法")
return return
} }
if p.Role == RoleAdmin && sessionRole != RoleAdmin {
h.log.Warn("未登录连接请求管理端房间,已断开", "remote", c.remoteAddr)
c.stop(websocket.StatusPolicyViolation, "无权订阅管理端房间")
return
}
// 不写 role 时按会话上限走;显式 role=display 允许管理端用户降级成大屏。
role := sessionRole
if p.Role == RoleDisplay {
role = RoleDisplay
}
c.mu.Lock() c.mu.Lock()
c.role = p.Role c.role = role
c.userID = p.UserID c.userID = 0
if role == RoleAdmin {
c.userID = sessionUserID
}
c.mu.Unlock() c.mu.Unlock()
if p.Role == RoleAdmin { if role == RoleAdmin {
rooms := []string{RoomAdmin} rooms := []string{RoomAdmin}
if p.UserID > 0 { if c.userID > 0 {
rooms = append(rooms, AdminRoom(p.UserID)) rooms = append(rooms, AdminRoom(c.userID))
} }
h.join(c, rooms...) h.join(c, rooms...)
} else { } else {
+3 -1
View File
@@ -84,6 +84,9 @@ func encode(event string, data any) ([]byte, error) {
} }
// helloPayload 是 C→S 的 hello 载荷。 // helloPayload 是 C→S 的 hello 载荷。
//
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」, // instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」,
// 老客户端不带这两个字段也能正常工作。 // 老客户端不带这两个字段也能正常工作。
type helloPayload struct { type helloPayload struct {
@@ -91,7 +94,6 @@ type helloPayload struct {
LastVersion *int64 `json:"lastVersion,omitempty"` LastVersion *int64 `json:"lastVersion,omitempty"`
DisplayKey string `json:"displayKey,omitempty"` DisplayKey string `json:"displayKey,omitempty"`
InstanceID string `json:"instanceId,omitempty"` InstanceID string `json:"instanceId,omitempty"`
UserID int64 `json:"userId,omitempty"`
Page string `json:"page,omitempty"` Page string `json:"page,omitempty"`
} }
+214 -84
View File
@@ -4,24 +4,31 @@ import (
"context" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
) )
// latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。 // latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。
const latestResultsLimit = 20 const latestResultsLimit = 20
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 // Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致。 // 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
// 否则大屏收到首包就会按错误的键名渲染。
type Snapshot struct { type Snapshot struct {
Version int64 `json:"version"` Version int64 `json:"version"`
Settings map[string]json.RawMessage `json:"settings"` Settings dto.EventSettings `json:"settings"`
Teams []domain.Team `json:"teams"` Teams []domain.Team `json:"teams"`
Events []domain.Event `json:"events"` Events []domain.Event `json:"events"`
Standings []domain.StandingsRow `json:"standings"` Standings []dto.StandingRow `json:"standings"`
Records []Record `json:"records"` Records []Record `json:"records"`
LatestResults []LatestResult `json:"latestResults"` LatestResults []dto.EventResultGroup `json:"latestResults"`
} }
// Record 是纪录榜的一行。 // Record 是纪录榜的一行。
@@ -38,23 +45,6 @@ type Record struct {
Source string `json:"source"` 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 读取一份一致的完整状态。 // Snapshot 读取一份一致的完整状态。
// //
// 全部查询放在同一个只读事务里,且版本号取自同一事务, // 全部查询放在同一个只读事务里,且版本号取自同一事务,
@@ -71,18 +61,21 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
snap := &Snapshot{ snap := &Snapshot{
Settings: map[string]json.RawMessage{},
Teams: []domain.Team{}, Teams: []domain.Team{},
Events: []domain.Event{}, Events: []domain.Event{},
Standings: []domain.StandingsRow{}, Standings: []dto.StandingRow{},
Records: []Record{}, Records: []Record{},
LatestResults: []LatestResult{}, LatestResults: []dto.EventResultGroup{},
} }
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil { if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil {
return nil, fmt.Errorf("读取快照版本号: %w", err) return nil, fmt.Errorf("读取快照版本号: %w", err)
} }
if err := loadSettings(ctx, tx, snap); err != nil { values, err := loadSettings(ctx, tx)
if err != nil {
return nil, err
}
if err := fillSettings(ctx, tx, values, snap); err != nil {
return nil, err return nil, err
} }
if err := loadTeams(ctx, tx, snap); err != nil { if err := loadTeams(ctx, tx, snap); err != nil {
@@ -91,7 +84,7 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if err := loadEvents(ctx, tx, snap); err != nil { if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err return nil, err
} }
if err := loadStandings(ctx, tx, snap); err != nil { if err := loadStandings(ctx, tx, snap, values); err != nil {
return nil, err return nil, err
} }
if err := loadRecords(ctx, tx, snap); err != nil { if err := loadRecords(ctx, tx, snap); err != nil {
@@ -107,21 +100,46 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
return snap, nil return snap, nil
} }
func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { // loadSettings 原样读出 settings 表的键值:榜单口径要用原始字符串,
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`) // 而线上结构需要聚合后的嵌套对象,两者由调用方分别消费。
func loadSettings(ctx context.Context, tx *sql.Tx) (map[string]string, error) {
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil { if err != nil {
return fmt.Errorf("读取赛事配置: %w", err) return nil, fmt.Errorf("读取赛事配置: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() { for rows.Next() {
var key, value string var key, value string
if err := rows.Scan(&key, &value); err != nil { if err := rows.Scan(&key, &value); err != nil {
return fmt.Errorf("解析赛事配置: %w", err) return nil, fmt.Errorf("解析赛事配置: %w", err)
} }
snap.Settings[key] = rawJSONOrString(value) out[key] = value
} }
return rows.Err() return out, rows.Err()
}
// fillSettings 聚合成与 REST /api/settings 相同的嵌套结构。
// defaultProfileId 在 settings 表里存的是模板 code,需要反查一次主键。
func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, snap *Snapshot) error {
snap.Settings = dto.ParseEventSettings(values)
code := values["scoring.default_profile"]
if code == "" {
return nil
}
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:保持 null,与 REST 一致,由管理端提示重选。
case err != nil:
return fmt.Errorf("解析默认积分模板: %w", err)
default:
snap.Settings.DefaultProfileID = &id
}
return nil
} }
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
@@ -191,9 +209,9 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
return rows.Err() return rows.Err()
} }
// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次, // loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 保证大屏看到的榜单与 REST /api/standings 一致。 // 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
ledger, err := loadLedger(ctx, tx) ledger, err := loadLedger(ctx, tx)
if err != nil { if err != nil {
return err return err
@@ -202,11 +220,14 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
if err != nil { if err != nil {
return err return err
} }
snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{ rows := domain.BuildTeamStandings(domain.StandingsInput{
Teams: snap.Teams, Teams: snap.Teams,
Ledger: ledger, Ledger: ledger,
Ranked: ranked, Ranked: ranked,
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
}) })
snap.Standings = dto.StandingRows(rows, snap.Teams)
return nil return nil
} }
@@ -235,13 +256,19 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
return out, rows.Err() return out, rows.Err()
} }
// loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。 // loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) { func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
FROM results r JOIN entries e ON e.id = r.entry_id e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3 FROM results r
ORDER BY r.id`) 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)
ORDER BY r.id, c.sort_order, c.id`)
if err != nil { if err != nil {
return nil, fmt.Errorf("读取奖牌名次: %w", err) return nil, fmt.Errorf("读取奖牌名次: %w", err)
} }
@@ -250,19 +277,23 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
var out []domain.RankedResult var out []domain.RankedResult
for rows.Next() { for rows.Next() {
var ( var (
teamID, athleteID int64 rr domain.RankedResult
rank int value sql.NullFloat64
athleteID sql.NullInt64
rank int
) )
if err := rows.Scan(&teamID, &athleteID, &rank); err != nil { 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, fmt.Errorf("解析奖牌名次: %w", err) return nil, fmt.Errorf("解析奖牌名次: %w", err)
} }
out = append(out, domain.RankedResult{ rr.Rank = rank
TeamID: teamID, if value.Valid {
AthleteID: athleteID, v := value.Float64
Rank: rank, rr.ResultValue = &v
Scope: domain.ScopeTeam, }
Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}}, rr.AthleteID = athleteID.Int64
}) rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
} }
return out, rows.Err() return out, rows.Err()
} }
@@ -299,50 +330,149 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
return rows.Err() return rows.Err()
} }
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0), SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value, e.category_id, COALESCE(cat.name, ''),
COALESCE(r.unit, ''), r.is_tie, r.status, r.state, COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') r.entry_id, r.rank, e.team_id,
FROM results r JOIN entries e ON e.id = r.entry_id COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
WHERE r.state = 'published' e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
LIMIT ?`, latestResultsLimit) FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
LIMIT ?`, latestResultsLimit)
if err != nil { if err != nil {
return fmt.Errorf("读取最新成绩: %w", err) return fmt.Errorf("读取最新成绩: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
type groupKey struct {
eventID int64
categoryID int64
}
index := make(map[groupKey]int)
for rows.Next() { for rows.Next() {
var ( var (
lr LatestResult eventID, categoryID int64
value sql.NullFloat64 eventCode, eventName string
isTie int categoryName string
publishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
memberNames string
isTie int
) )
if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID, if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
&lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil { &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName,
&athleteID, &athleteName, &memberNames,
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
return fmt.Errorf("解析最新成绩: %w", err) return fmt.Errorf("解析最新成绩: %w", err)
} }
if rank.Valid {
v := int(rank.Int64)
row.Rank = &v
}
if value.Valid { if value.Valid {
v := value.Float64 v := value.Float64
lr.ResultValue = &v row.ResultValue = &v
} }
lr.IsTie = isTie != 0 if athleteID.Valid && athleteID.Int64 != 0 {
snap.LatestResults = append(snap.LatestResults, lr) v := athleteID.Int64
row.AthleteID = &v
}
row.AthleteName = nilIfEmpty(athleteName)
row.ShortName = nilIfEmpty(shortName)
row.MemberNames = parseMemberNames(memberNames)
row.IsTie = isTie != 0
key := groupKey{eventID: eventID, categoryID: categoryID}
i, ok := index[key]
if !ok {
i = len(snap.LatestResults)
index[key] = i
snap.LatestResults = append(snap.LatestResults, dto.EventResultGroup{
EventID: eventID,
EventCode: eventCode,
EventName: eventName,
CategoryID: categoryID,
CategoryName: categoryName,
PublishedAt: nilIfEmpty(publishedAt),
Rows: []dto.EventResultRow{},
})
}
snap.LatestResults[i].Rows = append(snap.LatestResults[i].Rows, row)
} }
return rows.Err() if err := rows.Err(); err != nil {
return err
}
// 行按发布时间倒序取回,组内需改回名次顺序;无名次的排最后。
for i := range snap.LatestResults {
sort.SliceStable(snap.LatestResults[i].Rows, func(a, b int) bool {
ra, rb := snap.LatestResults[i].Rows[a], snap.LatestResults[i].Rows[b]
switch {
case ra.Rank == nil && rb.Rank == nil:
return ra.EntryID < rb.EntryID
case ra.Rank == nil:
return false
case rb.Rank == nil:
return true
default:
return *ra.Rank < *rb.Rank
}
})
}
return nil
} }
// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传, // parseMemberNames 解析 entries.member_names;空值或坏 JSON 一律当作「无成员名单」。
// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。 func parseMemberNames(raw string) []string {
func rawJSONOrString(value string) json.RawMessage { if raw == "" {
if json.Valid([]byte(value)) { return nil
return json.RawMessage(value)
} }
quoted, err := json.Marshal(value) var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil { if err != nil {
return json.RawMessage(`null`) 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
} }
return quoted
} }
+20 -3
View File
@@ -69,7 +69,8 @@ async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Prom
} }
export interface Bootstrap { export interface Bootstrap {
settings: EventSettings /** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */
settings: EventSettings | null
teams: Team[] teams: Team[]
sports: Sport[] sports: Sport[]
events: Event[] events: Event[]
@@ -78,11 +79,17 @@ export interface Bootstrap {
athleteStandings: AthleteStanding[] athleteStandings: AthleteStanding[]
} }
/** 除 settings 外都视为必需:失败时交给 call 决定降级到示例数据还是抛出业务错误。 */
function required<T>(result: PromiseSettledResult<T>): T {
if (result.status === 'fulfilled') return result.value
throw result.reason
}
async function bootstrap(): Promise<Bootstrap> { async function bootstrap(): Promise<Bootstrap> {
return call( return call(
async () => { async () => {
const [settings, teams, sports, events, profiles, standings, athleteStandings] = const [settings, teams, sports, events, profiles, standings, athleteStandings] =
await Promise.all([ await Promise.allSettled([
client.settings.get(), client.settings.get(),
client.teams.list(), client.teams.list(),
client.sports.list(), client.sports.list(),
@@ -91,7 +98,17 @@ async function bootstrap(): Promise<Bootstrap> {
client.standings.teams(), client.standings.teams(),
client.standings.athletes(), client.standings.athletes(),
]) ])
return { settings, teams, sports, events, profiles, standings, athleteStandings } // GET /api/settings 是 admin-only,guest/recorder 必然 403。
// 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。
return {
settings: settings.status === 'fulfilled' ? settings.value : null,
teams: required(teams),
sports: required(sports),
events: required(events),
profiles: required(profiles),
standings: required(standings),
athleteStandings: required(athleteStandings),
}
}, },
() => mockDb.bootstrap(), () => mockDb.bootstrap(),
) )
+36 -23
View File
@@ -8,48 +8,51 @@ import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event' import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
/** 大屏页面表里的规范值;写错会被大屏静默丢弃,所以用字面量联合锁死。 */
type DisplayPage = 'standings' | 'team-medals' | 'athlete-standings' | 'event-results'
interface ScreenNode { interface ScreenNode {
key: string key: string
name: string name: string
/** null = 未知;后端没有实例上报接口,占位节点恒为未知。 */
online: boolean | null online: boolean | null
lastSeen: string | null /** 本机最后一次下发指令的时间,不是大屏心跳。 */
lastCommandAt: string | null
} }
const PAGES: Array<{ key: string; label: string }> = [ const PAGES: Array<{ key: DisplayPage; label: string }> = [
{ key: 'standings', label: '总积分榜' }, { key: 'standings', label: '总积分榜' },
{ key: 'medals', label: '团体奖牌榜' }, { key: 'team-medals', label: '团体奖牌榜' },
{ key: 'athletes', label: '个人积分榜' }, { key: 'athlete-standings', label: '个人积分榜' },
{ key: 'latest', label: '最新成绩' }, { key: 'event-results', label: '最新成绩' },
] ]
const data = useEventStore() const data = useEventStore()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToastStore() const toast = useToastStore()
const currentPage = ref('standings') const currentPage = ref<DisplayPage>('standings')
const locked = ref(false) const locked = ref(false)
const announceText = ref('') const announceText = ref('')
const announceSeconds = ref(15) const announceSeconds = ref(15)
const pending = ref<string | null>(null) const pending = ref<string | null>(null)
/** /**
* 在线看板。 * 在线看板(占位)。
* *
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳, * devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
* 因此这里只登记本地已知的屏,并用「最近一次命令是否送达」推断在线状态。 * 所以这里只登记本地已知的屏,在线状态恒为「未知」——指令送达不等于大屏在线。
* 后端补上接口后,把 screens 换成接口数据即可。 * 后端补上实例上报接口后,把 screens 换成接口数据、hasInstanceData 才可能为真。
*/ */
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastSeen: null }]) const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastCommandAt: null }])
const canWrite = computed(() => !auth.readOnly && data.canWrite) const canWrite = computed(() => !auth.readOnly && data.canWrite)
/** 只有真实实例上报(online 非 null)才算有在线数据,占位节点一律不谎报在线。 */
const hasInstanceData = computed(() => screens.value.some((screen) => screen.online !== null))
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length) const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
function markSeen(): void { function recordDispatch(): void {
screens.value = screens.value.map((screen) => ({ screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() }))
...screen,
online: true,
lastSeen: new Date().toISOString(),
}))
} }
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> { async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
@@ -57,9 +60,19 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接') toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
return return
} }
if (data.isMock) {
// 示例数据模式下指令不会真正下发,不能提示「已切换」。
toast.warning('示例数据模式未真正下发', '后端未就绪,指令不会到达大屏')
return
}
pending.value = action pending.value = action
try { try {
if (action === 'goto') { if (action === 'goto') {
// 页面值不在大屏别名表内会被静默丢弃,这里先拦下并明确报错,避免谎报成功。
if (!PAGES.some((page) => page.key === currentPage.value)) {
toast.error('大屏不认识该页面', `页面值「${currentPage.value}」不在大屏页面表内`)
return
}
await adminApi.displayCommand({ action, payload: { page: currentPage.value } }) await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`) toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
} else if (action === 'lock' || action === 'unlock') { } else if (action === 'lock' || action === 'unlock') {
@@ -79,7 +92,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
announceText.value = '' announceText.value = ''
toast.success('公告已推送') toast.success('公告已推送')
} }
markSeen() recordDispatch()
} catch (caught) { } catch (caught) {
toast.error('指令下发失败', caught instanceof Error ? caught.message : null) toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
} finally { } finally {
@@ -195,14 +208,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
<section class="mx-card"> <section class="mx-card">
<div class="mx-card-head"> <div class="mx-card-head">
<span class="mx-card-title">在线看板</span> <span class="mx-card-title">在线看板</span>
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'"> <span class="mx-badge" :class="hasInstanceData ? (onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-error') : 'mx-badge-neutral'">
{{ onlineCount }} / {{ screens.length }} 在线 {{ hasInstanceData ? `${onlineCount} / ${screens.length} 在线` : '在线状态未知(占位)' }}
</span> </span>
</div> </div>
<div class="mx-card-body"> <div class="mx-card-body">
<div class="board-note"> <div class="board-note">
<MxIcon name="warning" :size="14" /> <MxIcon name="warning" :size="14" />
<span>后端未提供大屏在线接口(devdocs/05 §7 未定义),下表为本地登记节点,在线状态按最近一次指令送达推断。</span> <span>后端未提供大屏实例上报接口(devdocs/05 §7 未定义),下表是本地占位节点:「在线」无法判定,也不按指令送达推断;「最近下发」只是本机下发时间,不代表大屏在线。</span>
</div> </div>
<div class="mx-table-wrap"> <div class="mx-table-wrap">
<table class="mx-table mx-table-compact"> <table class="mx-table mx-table-compact">
@@ -210,7 +223,7 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
<tr> <tr>
<th>大屏</th> <th>大屏</th>
<th>状态</th> <th>状态</th>
<th>最后心跳</th> <th>最近下发</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -224,14 +237,14 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }} {{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
</span> </span>
</td> </td>
<td class="mono muted">{{ screen.lastSeen === null ? '—' : formatDateTime(screen.lastSeen) }}</td> <td class="mono muted">{{ screen.lastCommandAt === null ? '—' : formatDateTime(screen.lastCommandAt) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="board-hint"> <div class="board-hint">
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ currentPage }}</p> <p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ PAGES.find((page) => page.key === currentPage)?.label ?? currentPage }}</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p> <p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div> </div>
</div> </div>
+30 -4
View File
@@ -35,7 +35,6 @@ interface EditableRow {
isTie: boolean isTie: boolean
note: string note: string
resultState: ResultState | null resultState: ResultState | null
serverPoints: number | null
original: string original: string
} }
@@ -134,11 +133,18 @@ const score = computed(() => {
}) })
}) })
/**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
*
* 不能用 result.points:服务端从不返回该字段(恒为 0),否则已发布分会被重复计算。
* 这里改用与预览同一套算法算出的 rowPoints,口径与服务端计分一致。
*/
const publishedContribution = computed(() => { const publishedContribution = computed(() => {
const map = new Map<number, number>() const map = new Map<number, number>()
const rowPoints = score.value?.rowPoints
for (const row of visibleRows.value) { for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue if (row.resultState !== 'published') continue
const points = row.serverPoints ?? 0 const points = rowPoints?.get(row.entryId) ?? 0
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (isIndividual.value ? points * weight.value : points)) map.set(row.teamId, (map.get(row.teamId) ?? 0) + (isIndividual.value ? points * weight.value : points))
} }
return map return map
@@ -250,7 +256,6 @@ function toRow(entry: EntryWithResult): EditableRow {
isTie: result?.isTie ?? false, isTie: result?.isTie ?? false,
note: result?.note ?? '', note: result?.note ?? '',
resultState: result?.state ?? null, resultState: result?.state ?? null,
serverPoints: result?.points ?? null,
original: '', original: '',
} }
row.original = snapshotOf(entry) row.original = snapshotOf(entry)
@@ -347,6 +352,11 @@ async function saveDraft(silent = false): Promise<boolean> {
function requestPublish(): void { function requestPublish(): void {
if (selectedEventId.value === null) return if (selectedEventId.value === null) return
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
return
}
if (!canWrite.value) { if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接') toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return return
@@ -515,7 +525,7 @@ watch(
<button <button
class="mx-btn mx-btn-primary" class="mx-btn mx-btn-primary"
type="button" type="button"
:disabled="!canWrite || publishing" :disabled="!canWrite || publishing || data.isMock"
@click="requestPublish" @click="requestPublish"
> >
<MxIcon name="push" :size="15" /> 发布 <MxIcon name="push" :size="15" /> 发布
@@ -523,6 +533,11 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div v-if="data.isMock" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel }},示例数据模式下「发布」已停用,成绩不会写入真实赛事。</span>
</div>
<div class="entry-grid"> <div class="entry-grid">
<aside class="mx-card project-panel"> <aside class="mx-card project-panel">
<div class="mx-card-head"><span class="mx-card-title">项目</span></div> <div class="mx-card-head"><span class="mx-card-title">项目</span></div>
@@ -778,6 +793,17 @@ watch(
</template> </template>
<style scoped> <style scoped>
.mock-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
border-radius: var(--mx-r-sm);
font-size: 13px;
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.entry-grid { .entry-grid {
display: grid; display: grid;
grid-template-columns: 264px minmax(0, 1fr); grid-template-columns: 264px minmax(0, 1fr);
+14 -3
View File
@@ -31,7 +31,8 @@ interface SuspectedBreak {
resultValue: number resultValue: number
level: RecordLevel level: RecordLevel
recordText: string recordText: string
bonusPoints: number /** null = 规则包读不到,分值未知,以服务端为准。 */
bonusPoints: number | null
} }
interface RecordGroup { interface RecordGroup {
@@ -75,8 +76,18 @@ const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([]) const approvedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite) const canWrite = computed(() => !auth.readOnly && data.canWrite)
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false) /**
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档')) * 破纪录加分开关:true / false / null(未知)。
*
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),
* 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。
* 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
*/
const recordBonusEnabled = computed<boolean | null>(() => rulePack.value?.recordBonus.enabled ?? null)
const bonusMode = computed(() =>
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
)
const filteredRecords = computed(() => { const filteredRecords = computed(() => {
const keyword = search.value.trim() const keyword = search.value.trim()
+24 -2
View File
@@ -163,10 +163,11 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
const individual = event.eventType === 'individual' const individual = event.eventType === 'individual'
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。 // 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
// result.points 服务端从不返回(恒为 0),改用与预览同一套算法算出的 rowPoints。
const published = new Map<number, number>() const published = new Map<number, number>()
for (const entry of scoped) { for (const entry of scoped) {
if (entry.result?.state !== 'published') continue if (entry.result?.state !== 'published') continue
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1) const points = round(score.rowPoints.get(entry.id) ?? 0) * (individual ? weight.value : 1)
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points) published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
} }
@@ -267,6 +268,11 @@ function isReturned(group: DraftGroup): boolean {
} }
function requestPublish(group: DraftGroup): void { function requestPublish(group: DraftGroup): void {
if (data.isMock) {
// 示例数据模式下 publish 只改内存,提示「发布成功」等于谎报落库,直接停用。
toast.error('示例数据模式不能发布', '后端未就绪,发布不会写入真实赛事')
return
}
if (!canWrite.value) { if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接') toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return return
@@ -337,6 +343,11 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div v-if="data.isMock" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel }},示例数据模式下「发布整组」已停用,草稿不会写入真实赛事。</span>
</div>
<div v-if="error !== null" class="error-banner"> <div v-if="error !== null" class="error-banner">
<MxIcon name="warning" :size="15" /> <MxIcon name="warning" :size="15" />
<span>{{ error }}</span> <span>{{ error }}</span>
@@ -427,7 +438,7 @@ watch(
<button <button
class="mx-btn mx-btn-primary" class="mx-btn mx-btn-primary"
type="button" type="button"
:disabled="!canWrite || publishing" :disabled="!canWrite || publishing || data.isMock"
@click="requestPublish(selectedGroup)" @click="requestPublish(selectedGroup)"
> >
<MxIcon name="push" :size="14" /> 发布整组 <MxIcon name="push" :size="14" /> 发布整组
@@ -717,6 +728,17 @@ tr.selected {
font-size: 13px; font-size: 13px;
margin-top: 14px; margin-top: 14px;
} }
.mock-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
font-size: 13px;
margin-top: 14px;
}
@media (max-width: 1279px) { @media (max-width: 1279px) {
.review-grid { .review-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+7 -2
View File
@@ -16,8 +16,13 @@ export const useAuthStore = defineStore('admin-auth', () => {
/** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */ /** 会话只在应用启动时探测一次;否则登录后布局重新挂载会把刚登录的用户清掉。 */
const initialized = ref(false) const initialized = ref(false)
const isGuest = computed(() => user.value === null) /**
const readOnly = computed(() => user.value === null) * 未登录时 /auth/me 返回 200 + guest DTO 而不是 401,
* 所以「访客」必须按角色判定:只看 user === null 会把访客当成已登录,
* 只读横幅与 useBaseData.canWrite 的写保护都会失效。
*/
const isGuest = computed(() => user.value === null || user.value.role === 'guest')
const readOnly = computed(() => isGuest.value)
const displayName = computed(() => user.value?.displayName ?? '访客') const displayName = computed(() => user.value?.displayName ?? '访客')
const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户'))) const roleLabel = computed(() => (isGuest.value ? '只读访客' : (user.value?.role ?? '用户')))
+4 -4
View File
@@ -35,8 +35,8 @@ const { scale, offsetX, offsetY } = useStageScale({
const currentPage = computed<Component>(() => PAGES[page.value]) const currentPage = computed<Component>(() => PAGES[page.value])
const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` } const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` }
const barTitle = computed(() => settings.value?.display.title.trim() || '赛事标题待配置') const barTitle = computed(() => settings.value?.display?.title?.trim() || '赛事标题待配置')
const barLogo = computed(() => settings.value?.display.logo?.trim() ?? '') const barLogo = computed(() => settings.value?.display?.logo?.trim() ?? '')
const stageStyle = computed(() => ({ const stageStyle = computed(() => ({
transform: `scale(${scale.value})`, transform: `scale(${scale.value})`,
@@ -45,14 +45,14 @@ const stageStyle = computed(() => ({
})) }))
const background = computed(() => { const background = computed(() => {
const value = settings.value?.display.background?.trim() const value = settings.value?.display?.background?.trim()
return value !== undefined && value !== '' ? value : null return value !== undefined && value !== '' ? value : null
}) })
/** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */ /** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */
const themeVars = computed<Record<string, string>>(() => { const themeVars = computed<Record<string, string>>(() => {
const vars: Record<string, string> = {} const vars: Record<string, string> = {}
const primary = parseHexColor(settings.value?.display.primaryColor ?? null) const primary = parseHexColor(settings.value?.display?.primaryColor ?? null)
if (primary === null) return vars if (primary === null) return vars
const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45) const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45)
+1 -1
View File
@@ -31,7 +31,7 @@ const items = computed<TickerItem[]>(() => {
const index = ref(0) const index = ref(0)
const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null) const current = computed<TickerItem | null>(() => items.value[index.value] ?? items.value[0] ?? null)
const paused = computed(() => detail.value !== null) const paused = computed(() => detail.value !== null)
const rotateMs = computed(() => Math.max(2, settings.value?.display.rotateSeconds ?? 8) * 1000) const rotateMs = computed(() => Math.max(2, settings.value?.display?.rotateSeconds ?? 8) * 1000)
let timer = 0 let timer = 0
@@ -8,7 +8,11 @@ import PageFrame from '../PageFrame.vue'
const { athletes, settings } = useScoreboard() const { athletes, settings } = useScoreboard()
const rows = computed(() => athletes.value) const rows = computed(() => athletes.value)
const showMedals = computed(() => settings.value?.standings.showIndividualBoard ?? true) // 奖牌列由独立开关控制;旧配置没有 showIndividualMedals 时回退到「个人榜上屏」开关,避免升级后突然多出一列。
const showMedals = computed(() => {
const standings = settings.value?.standings
return standings?.showIndividualMedals ?? standings?.showIndividualBoard ?? true
})
const rowFont = computed(() => fitRowFont(rows.value.length, 560)) const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script> </script>
@@ -6,12 +6,8 @@ import PageFrame from '../PageFrame.vue'
const { standings } = useScoreboard() const { standings } = useScoreboard()
const rows = computed(() => // 排序与名次都取服务端口径(总分优先),前端不再按金→银→铜重排,否则名次会与总积分榜打架。
[...standings.value].sort( const rows = computed(() => standings.value)
(a, b) =>
b.gold - a.gold || b.silver - a.silver || b.bronze - a.bronze || b.totalPoints - a.totalPoints,
),
)
const rowFont = computed(() => fitRowFont(rows.value.length, 560)) const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script> </script>
@@ -27,8 +23,8 @@ const rowFont = computed(() => fitRowFont(rows.value.length, 560))
<span class="medal bronze">铜</span> <span class="medal bronze">铜</span>
<span class="total">合计</span> <span class="total">合计</span>
</div> </div>
<div v-for="(row, index) in rows" :key="row.teamId" class="row"> <div v-for="row in rows" :key="row.teamId" class="row">
<span class="rank" :class="`top-${Math.min(index + 1, 4)}`">{{ index + 1 }}</span> <span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
<span class="name">{{ row.teamName }}</span> <span class="name">{{ row.teamName }}</span>
<span class="medal gold">{{ row.gold }}</span> <span class="medal gold">{{ row.gold }}</span>
<span class="medal silver">{{ row.silver }}</span> <span class="medal silver">{{ row.silver }}</span>
+129 -26
View File
@@ -57,6 +57,11 @@ export function bool(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback return typeof value === 'boolean' ? value : fallback
} }
/** 三态布尔:字段缺失时保持 undefined,调用方才能区分「未配置」与「显式关闭」。 */
function boolOrUndefined(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
export function strings(value: unknown): string[] | null { export function strings(value: unknown): string[] | null {
const items = list(value).filter((item): item is string => typeof item === 'string') const items = list(value).filter((item): item is string => typeof item === 'string')
return items.length === 0 ? null : items return items.length === 0 ? null : items
@@ -96,8 +101,9 @@ function parseTeamRef(source: unknown): TeamRefShape {
const rec = record(source) const rec = record(source)
return { return {
teamId: num(rec.teamId), teamId: num(rec.teamId),
teamCode: str(rec.teamCode), // 快照直接序列化领域行时用的是 code/name,REST 契约用 teamCode/teamName,两种都收。
teamName: str(rec.teamName), teamCode: str(rec.teamCode) || str(rec.code),
teamName: str(rec.teamName) || str(rec.name),
shortName: strOrNull(rec.shortName), shortName: strOrNull(rec.shortName),
color: strOrNull(rec.color), color: strOrNull(rec.color),
logoPath: strOrNull(rec.logoPath), logoPath: strOrNull(rec.logoPath),
@@ -151,8 +157,8 @@ export function parseStandingRow(source: unknown): StandingRow {
gold, gold,
silver, silver,
bronze, bronze,
medalTotal: num(rec.medalTotal, gold + silver + bronze), medalTotal: num(rec.medalTotal, num(rec.medals, gold + silver + bronze)),
totalPoints: num(rec.totalPoints), totalPoints: num(rec.totalPoints, num(rec.points)),
rankChange: numOrNull(rec.rankChange), rankChange: numOrNull(rec.rankChange),
} }
} }
@@ -216,8 +222,61 @@ export function parseEventResultGroup(source: unknown): EventResultGroup {
} }
} }
export function parseEventResultGroups(value: unknown): EventResultGroup[] { function parseFlatResultRow(
return list(value).map(parseEventResultGroup) rec: Record<string, unknown>,
byTeam: ReadonlyMap<number, Team>,
): EventResultRow {
const row = parseEventResultRow(rec)
const team = byTeam.get(row.teamId)
if (team === undefined) return row
// 平铺行只带 teamId,单位名要从快照的 teams 里补,否则底栏只能显示空白。
return {
...row,
teamCode: row.teamCode === '' ? team.code : row.teamCode,
teamName: row.teamName === '' ? team.name : row.teamName,
shortName: row.shortName ?? team.shortName,
}
}
/**
* 服务端快照的 latestResults 存在「已分组」与「平铺成绩行」两种形态。
*
* 平铺行没有 rows,按分组解析会得到一堆空组,底栏轮播与分项成绩页就只剩空白;
* 这里检测到平铺行时按 eventId+categoryId 就地聚合,两种契约都能渲染。
*/
export function parseEventResultGroups(
value: unknown,
teams: readonly Team[] = [],
): EventResultGroup[] {
const items = list(value)
if (items.length === 0) return []
if (items.some((item) => isRecord(item) && Array.isArray(item.rows))) {
return items.map(parseEventResultGroup)
}
const byTeam = new Map<number, Team>(teams.map((team) => [team.id, team]))
const groups = new Map<string, EventResultGroup>()
for (const item of items) {
const rec = record(item)
const eventId = num(rec.eventId)
const categoryId = num(rec.categoryId)
const key = `${eventId}-${categoryId}`
let group = groups.get(key)
if (group === undefined) {
group = {
eventId,
eventCode: str(rec.eventCode),
eventName: str(rec.eventName),
categoryId,
categoryName: str(rec.categoryName),
publishedAt: strOrNull(rec.publishedAt ?? rec.updatedAt),
rows: [],
}
groups.set(key, group)
}
group.rows.push(parseFlatResultRow(rec, byTeam))
}
return [...groups.values()]
} }
export function parseRecordEntry(source: unknown): RecordEntry { export function parseRecordEntry(source: unknown): RecordEntry {
@@ -236,31 +295,73 @@ export function parseRecordEntry(source: unknown): RecordEntry {
} }
} }
export function parseSettings(source: unknown): EventSettings { /**
* 大屏侧扩展的赛事配置。
*
* `standings.showIndividualMedals` 还没进入 shared 契约,但大屏需要它独立控制奖牌列;
* 声明成可选,旧配置(服务端只给 showIndividualBoard)仍可赋值,页面再回退。
*/
export type DisplaySettings = EventSettings & {
standings: EventSettings['standings'] & { showIndividualMedals?: boolean }
}
/**
* 嵌套路径取值。
*
* REST 的 `/api/settings` 是嵌套对象,WS 快照却是 `display.rotate_seconds`
* 这类点号扁平键;同一份配置两种形态都要认,故先走嵌套路径再退回扁平键。
*/
function settingValue(
source: Record<string, unknown>,
nestedPath: string,
flatKey: string,
): unknown {
let node: unknown = source
for (const segment of nestedPath.split('.')) {
if (!isRecord(node)) {
node = undefined
break
}
node = node[segment]
}
if (node !== undefined && node !== null) return node
return source[flatKey]
}
export function parseSettings(source: unknown): DisplaySettings {
const rec = record(source) const rec = record(source)
const event = record(rec.event)
const display = record(rec.display)
const standings = record(rec.standings)
return { return {
event: { event: {
name: str(event.name), name: str(settingValue(rec, 'event.name', 'event.name')),
subtitle: strOrNull(event.subtitle), subtitle: strOrNull(settingValue(rec, 'event.subtitle', 'event.subtitle')),
host: strOrNull(event.host), host: strOrNull(settingValue(rec, 'event.host', 'event.organizer')),
logoPath: strOrNull(event.logoPath), logoPath: strOrNull(settingValue(rec, 'event.logoPath', 'event.logo_path')),
primaryColor: strOrNull(event.primaryColor), primaryColor: strOrNull(settingValue(rec, 'event.primaryColor', 'display.theme_color')),
}, },
display: { display: {
background: strOrNull(display.background), background: strOrNull(settingValue(rec, 'display.background', 'display.background')),
logo: strOrNull(display.logo), logo: strOrNull(settingValue(rec, 'display.logo', 'display.logo')),
title: str(display.title), title: str(settingValue(rec, 'display.title', 'display.title')),
subtitle: strOrNull(display.subtitle), subtitle: strOrNull(settingValue(rec, 'display.subtitle', 'display.subtitle')),
primaryColor: strOrNull(display.primaryColor), primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')),
rotateSeconds: num(display.rotateSeconds, 8), rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
}, },
standings: { standings: {
individualToTeamWeight: num(standings.individualToTeamWeight, 1), individualToTeamWeight: num(
rankingMode: str(standings.rankingMode, 'points'), settingValue(rec, 'standings.individualToTeamWeight', 'standings.individual_to_team_weight'),
showIndividualBoard: bool(standings.showIndividualBoard, true), 1,
),
rankingMode: str(
settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'),
'points',
),
showIndividualBoard: bool(
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
),
showIndividualMedals: boolOrUndefined(
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
),
}, },
defaultProfileId: numOrNull(rec.defaultProfileId), defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval), requireApproval: bool(rec.requireApproval),
@@ -301,13 +402,15 @@ export function parseSnapshot(value: unknown): SnapshotMessage | null {
if (!isRecord(value)) return null if (!isRecord(value)) return null
const version = numOrNull(value.version) const version = numOrNull(value.version)
if (version === null) return null if (version === null) return null
const teams = list(value.teams).map(parseTeam)
return { return {
version, version,
settings: parseSettings(value.settings), settings: parseSettings(value.settings),
teams: list(value.teams).map(parseTeam), teams,
events: list(value.events).map(parseEvent), events: list(value.events).map(parseEvent),
standings: parseStandingRows(value.standings), standings: parseStandingRows(value.standings),
records: list(value.records).map(parseRecordEntry), records: list(value.records).map(parseRecordEntry),
latestResults: parseEventResultGroups(value.latestResults), // 平铺成绩行要靠 teams 补单位名,必须传入已解析的单位表。
latestResults: parseEventResultGroups(value.latestResults, teams),
} }
} }
+26 -9
View File
@@ -6,7 +6,6 @@ import type {
DisplayCommand, DisplayCommand,
Event, Event,
EventResultGroup, EventResultGroup,
EventSettings,
RealtimeStatus, RealtimeStatus,
RecordEntry, RecordEntry,
SnapshotMessage, SnapshotMessage,
@@ -21,11 +20,13 @@ import {
parseEvent, parseEvent,
parseEventResultGroups, parseEventResultGroups,
parseSettings, parseSettings,
parseSnapshot,
parseStandingRow, parseStandingRow,
parseStandingRows, parseStandingRows,
parseTeam, parseTeam,
parseTeamBreakdown, parseTeamBreakdown,
} from './guards' } from './guards'
import type { DisplaySettings } from './guards'
import { createMockAthletes, createMockSnapshot } from './mock' import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage' import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types' import { DISPLAY_PAGES, normalizePage } from './types'
@@ -49,7 +50,7 @@ const reducedMotion = prefersReducedMotion()
const source = ref<DisplaySource>('live') const source = ref<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle') const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null) const version = ref<number | null>(null)
const settings = ref<EventSettings | null>(null) const settings = ref<DisplaySettings | null>(null)
const teams = ref<Team[]>([]) const teams = ref<Team[]>([])
const events = ref<Event[]>([]) const events = ref<Event[]>([])
const standings = ref<StandingRow[]>([]) const standings = ref<StandingRow[]>([])
@@ -169,8 +170,17 @@ function applyChanges(changes: Change[]): void {
for (const change of changes) { for (const change of changes) {
switch (change.entity) { switch (change.entity) {
case 'standings': case 'standings':
if (change.action === 'replace') applyStandings(parseStandingRows(change.data)) if (change.action === 'replace') {
else if (change.action === 'upsert') mergeStanding(parseStandingRow(change.data)) // 服务端当前下发的是 {eventId:N} 这类定位信息而不是榜单数组;
// 直接当空数组用会把大屏清空,所以忽略并拉全量。
if (!Array.isArray(change.data)) {
resyncNeeded = true
break
}
applyStandings(parseStandingRows(change.data))
} else if (change.action === 'upsert') {
mergeStanding(parseStandingRow(change.data))
}
break break
case 'athleteStandings': case 'athleteStandings':
case 'athlete_standings': case 'athlete_standings':
@@ -178,7 +188,9 @@ function applyChanges(changes: Change[]): void {
break break
case 'latestResults': case 'latestResults':
case 'eventResults': case 'eventResults':
if (change.action === 'replace') latestResults.value = parseEventResultGroups(change.data) if (change.action === 'replace') {
latestResults.value = parseEventResultGroups(change.data, teams.value)
}
break break
case 'settings': case 'settings':
if (change.action !== 'void') settings.value = parseSettings(change.data) if (change.action !== 'void') settings.value = parseSettings(change.data)
@@ -221,7 +233,8 @@ async function loadRest(): Promise<boolean> {
let ok = false let ok = false
if (settingsResult.status === 'fulfilled' && !hasSnapshot) { if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
settings.value = settingsResult.value // REST 载荷同样过解析层,保证嵌套结构与默认值和大屏其余入口一致。
settings.value = parseSettings(settingsResult.value)
ok = true ok = true
} }
if (standingsResult.status === 'fulfilled' && !hasSnapshot) { if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
@@ -237,7 +250,7 @@ async function loadRest(): Promise<boolean> {
function activateMock(): void { function activateMock(): void {
const snapshot = createMockSnapshot() const snapshot = createMockSnapshot()
settings.value = snapshot.settings settings.value = parseSettings(snapshot.settings)
teams.value = snapshot.teams teams.value = snapshot.teams
events.value = snapshot.events events.value = snapshot.events
latestResults.value = snapshot.latestResults latestResults.value = snapshot.latestResults
@@ -419,8 +432,12 @@ function connect(): void {
displayKey: options.value.instance ?? options.value.screen ?? undefined, displayKey: options.value.instance ?? options.value.screen ?? undefined,
initialVersion: version.value, initialVersion: version.value,
onSnapshot: (message) => { onSnapshot: (message) => {
applySnapshot(message, 'live') // 服务端快照的 settings 是点号扁平 map,原始载荷直写 ref 会让 display 变 undefined;
writeCachedSnapshot(message) // 必须过解析层归一,解析失败则整包丢弃(心跳/重连会再给一次)。
const snapshot = parseSnapshot(message)
if (snapshot === null) return
applySnapshot(snapshot, 'live')
writeCachedSnapshot(snapshot)
}, },
onCatchup: (message) => { onCatchup: (message) => {
applyChanges(message.changes) applyChanges(message.changes)
+61 -1
View File
@@ -5,8 +5,19 @@
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。 * 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
*/ */
/**
* FormData / Blob(含 File)/ URLSearchParams 不是普通对象:
* 展开或 JSON 序列化都会丢掉内容,必须原样交给 fetch(报名表导入就是被展平才丢文件)。
*/
export function isRawBody(value: unknown): value is FormData | Blob | URLSearchParams {
if (typeof FormData !== 'undefined' && value instanceof FormData) return true
if (typeof Blob !== 'undefined' && value instanceof Blob) return true
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return true
return false
}
export function isRecord(value: unknown): value is Record<string, unknown> { export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value) && !isRawBody(value)
} }
export function readString(source: unknown, key: string): string | null { export function readString(source: unknown, key: string): string | null {
@@ -14,3 +25,52 @@ export function readString(source: unknown, key: string): string | null {
const value = source[key] const value = source[key]
return typeof value === 'string' ? value : null return typeof value === 'string' ? value : null
} }
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}
/** 依次取第一个可用值:容忍服务端把数字写成字符串(settings 表存的是文本)。 */
export function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === 'string') return value
}
return null
}
export function firstNumber(def: number, ...values: unknown[]): number {
const found = firstNumberOrNull(...values)
return found === null ? def : found
}
export function firstNumberOrNull(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
}
return null
}
export function firstBool(def: boolean, ...values: unknown[]): boolean {
for (const value of values) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
return true
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
return false
}
}
}
return def
}
export function emptyToNull(value: string | null): string | null {
return value === null || value.trim() === '' ? null : value
}
+125
View File
@@ -0,0 +1,125 @@
/**
* 服务端两类载荷的归一化。
*
* REST 与 WebSocket 快照对同一份数据用了两套字段名(快照直接序列化 domain 类型),
* 归一化放在 shared 层,display 与 admin 各自只认归一后的契约类型。
*/
import {
asRecord,
emptyToNull,
firstBool,
firstNumber,
firstNumberOrNull,
firstString,
isRecord,
} from './internal'
import type { EventSettings, StandingRow } from './types'
/**
* 赛事配置归一化。
*
* `GET /api/settings` 是嵌套对象,而 WebSocket 快照的 `settings` 是
* settings 表的点号扁平 map(`display.rotate_seconds`、`standings.mode`…)。
* 两种形状都接受,缺省值对齐服务端 `getSettings`,避免大屏首包读不到字段。
*/
export function normalizeSettings(raw: unknown): EventSettings {
const source = asRecord(raw)
const nestedEvent = isRecord(source.event) ? source.event : null
const nestedDisplay = isRecord(source.display) ? source.display : null
const nestedStandings = isRecord(source.standings) ? source.standings : null
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
const name = firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name) ?? '未命名赛事'
const subtitle = emptyToNull(
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
)
const host = emptyToNull(firstString(isFlat ? source['event.organizer'] : undefined, nestedEvent?.host))
const logoPath = emptyToNull(
firstString(isFlat ? source['event.logo_path'] : undefined, nestedEvent?.logoPath),
)
const primaryColor = emptyToNull(
firstString(isFlat ? source['display.theme_color'] : undefined, nestedEvent?.primaryColor),
)
const displayLogo = emptyToNull(
firstString(isFlat ? source['display.logo'] : undefined, nestedDisplay?.logo),
)
return {
event: { name, subtitle, host, logoPath, primaryColor },
display: {
background: emptyToNull(
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
),
logo: displayLogo ?? logoPath,
title: firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title) ?? name,
subtitle:
emptyToNull(
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
) ?? subtitle,
primaryColor,
rotateSeconds: firstNumber(
15,
isFlat ? source['display.rotate_seconds'] : undefined,
nestedDisplay?.rotateSeconds,
),
},
standings: {
individualToTeamWeight: firstNumber(
1,
isFlat ? source['standings.individual_to_team_weight'] : undefined,
nestedStandings?.individualToTeamWeight,
),
rankingMode:
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode) ??
'points',
showIndividualBoard: firstBool(
true,
isFlat ? source['standings.show_individual_board'] : undefined,
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualBoard,
),
},
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
requireApproval: firstBool(
false,
isFlat ? source['results.review_required'] : undefined,
source.requireApproval,
),
}
}
/**
* 总积分榜归一化。
*
* 快照用 domain.StandingsRow(`code/name/points/medals`),
* REST 用 standingRowDTO(`teamCode/teamName/totalPoints/medalTotal`),两种都接受。
*/
export function normalizeStandingRows(raw: unknown): StandingRow[] {
if (!Array.isArray(raw)) return []
return raw.map(normalizeStandingRow)
}
function normalizeStandingRow(raw: unknown): StandingRow {
const rec = asRecord(raw)
const gold = firstNumber(0, rec.gold)
const silver = firstNumber(0, rec.silver)
const bronze = firstNumber(0, rec.bronze)
const rankChange = rec.rankChange
return {
rank: firstNumber(0, rec.rank),
teamId: firstNumber(0, rec.teamId),
teamCode: firstString(rec.teamCode, rec.code) ?? '',
teamName: firstString(rec.teamName, rec.name) ?? '',
shortName: emptyToNull(firstString(rec.shortName)),
color: emptyToNull(firstString(rec.color)),
logoPath: emptyToNull(firstString(rec.logoPath)),
gold,
silver,
bronze,
medalTotal: firstNumber(gold + silver + bronze, rec.medalTotal, rec.medals),
totalPoints: firstNumber(0, rec.totalPoints, rec.points),
rankChange: typeof rankChange === 'number' && Number.isFinite(rankChange) ? rankChange : null,
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 登录态判定。
*
* 服务端 `/auth/me` 未登录时返回 200 + guest DTO(role = "guest"、username = "guest"),
* 而不是 401,所以「user 是否为 null」判不出访客;统一走这里的判定函数。
*/
import type { CurrentUser } from './types'
/** 服务端 auth.RoleGuest 的字符串值。 */
export const GUEST_ROLE = 'guest'
export function isGuestRole(role: unknown): boolean {
return typeof role === 'string' && role.trim().toLowerCase() === GUEST_ROLE
}
/** null(还没探测 / 探测失败)与 guest DTO 都算访客。 */
export function isGuestUser(user: CurrentUser | null | undefined): boolean {
return user === null || user === undefined || isGuestRole(user.role)
}
+15 -3
View File
@@ -383,6 +383,11 @@ export interface CurrentUser {
username: string username: string
displayName: string displayName: string
role: string role: string
/**
* 由客户端按 role 补出:true 表示这是未登录时 /auth/me 返回的 guest DTO。
* 服务端未登录也回 200,不能用「对象是否为 null」判访客,见 shared/session.ts。
*/
guest?: boolean
/** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */ /** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */
mustChangePassword: boolean mustChangePassword: boolean
lastLoginAt: string | null lastLoginAt: string | null
@@ -415,7 +420,7 @@ export interface HealthInfo {
// 规则包(见 02-积分与排名规则 第 7 节) // 规则包(见 02-积分与排名规则 第 7 节)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' | 'name'
export interface RulePackMeta { export interface RulePackMeta {
code: string code: string
@@ -431,7 +436,8 @@ export interface RulePackStandings {
tieBreak: TieBreakKey[] tieBreak: TieBreakKey[]
medalCountsTeamEvents: boolean medalCountsTeamEvents: boolean
showIndividualBoard: boolean showIndividualBoard: boolean
showIndividualMedals: boolean /** 服务端规则包会带出该开关;缺省视为 true。 */
showIndividualMedals?: boolean
} }
export interface RuleProfile { export interface RuleProfile {
@@ -440,6 +446,8 @@ export interface RuleProfile {
awardRanks: number awardRanks: number
lastPlaceZero: boolean lastPlaceZero: boolean
tieMode: TieMode tieMode: TieMode
/** 单个模板可覆盖破纪录加分口径;服务端 Canonical 后总会带出。 */
recordBonusMode?: RecordBonusMode
rules: ScoringProfileRule[] rules: ScoringProfileRule[]
} }
@@ -465,11 +473,13 @@ export interface RuleChannel {
scope: ChannelScope scope: ChannelScope
basis: ChannelBasis basis: ChannelBasis
profile: string profile: string
/** basis 为 aggregate 时的汇总口径(avg/max/min/count),服务端缺省 sum。 */
aggregate?: string
} }
export interface ChannelTemplateMatch { export interface ChannelTemplateMatch {
eventType?: EventType eventType?: EventType
eventCode?: string resultType?: ResultType
sportCode?: string sportCode?: string
} }
@@ -482,6 +492,8 @@ export interface EventOverride {
eventCode: string eventCode: string
name?: string name?: string
scored?: boolean scored?: boolean
/** 单个项目的计分权重覆盖;缺省表示不动。 */
multiplier?: number
channels?: RuleChannel[] channels?: RuleChannel[]
} }
+417
View File
@@ -0,0 +1,417 @@
/**
* 极简 YAML 子集解析器。
*
* `GET /api/rules/export` 返回的是服务端 `rules.Marshal` 生成的 YAML 文本,
* 而前端不引入 YAML 依赖,所以这里只实现规则包实际用到的语法:
* 块状映射 / 序列、流式 `[]` / `{}`、单双引号、块标量、数字 / 布尔 / null 标量。
* 解析失败一律抛 `YamlParseError`,由调用方转成 PARSE_FAILED 并带上原文。
*/
export class YamlParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'YamlParseError'
}
}
interface Line {
indent: number
text: string
}
interface Parsed {
value: unknown
next: number
}
export function parseYaml(text: string): unknown {
const lines = tokenize(text)
if (lines.length === 0) return null
const parsed = parseBlock(lines, 0, lines[0].indent)
if (parsed.next < lines.length) {
throw new YamlParseError(`第 ${parsed.next + 1} 行无法归入任何块:${lines[parsed.next].text}`)
}
return parsed.value
}
function tokenize(text: string): Line[] {
const out: Line[] = []
const raw = text.split(/\r?\n/)
for (const line of raw) {
const withoutComment = stripComment(line)
if (withoutComment.trim() === '') continue
// 规则包只允许一个 YAML 文档,遇到分隔符直接跳过而不是当成新文档。
if (/^\s*(---|\.\.\.)\s*$/.test(withoutComment)) continue
out.push({
indent: withoutComment.length - withoutComment.trimStart().length,
text: withoutComment.trim(),
})
}
return out
}
function stripComment(line: string): string {
let quote: '"' | "'" | null = null
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (quote === '"') {
if (ch === '\\') i++
else if (ch === '"') quote = null
continue
}
if (quote === "'") {
if (ch === "'") {
if (line[i + 1] === "'") i++
else quote = null
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
// YAML 注释必须前面是空白(`a#b` 里的 # 属于标量)。
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
}
return line
}
function parseBlock(lines: Line[], index: number, indent: number): Parsed {
const line = lines[index]
if (line === undefined || line.indent < indent) return { value: null, next: index }
if (isSequenceItem(line.text)) return parseSequence(lines, index, line.indent)
return parseMapping(lines, index, line.indent)
}
function parseMapping(lines: Line[], index: number, indent: number): Parsed {
const out: Record<string, unknown> = {}
let i = index
while (i < lines.length) {
const line = lines[i]
if (line.indent !== indent || isSequenceItem(line.text)) break
const entry = splitEntry(line.text)
if (entry === null) throw new YamlParseError(`第 ${i + 1} 行不是「键: 值」:${line.text}`)
if (entry.rest === '') {
const next = lines[i + 1]
if (next !== undefined && next.indent > indent) {
const child = parseBlock(lines, i + 1, indent + 1)
out[entry.key] = child.value
i = child.next
} else {
out[entry.key] = null
i++
}
continue
}
if (isBlockScalarHeader(entry.rest)) {
const scalar = readBlockScalar(lines, i + 1, indent, entry.rest)
out[entry.key] = scalar.value
i = scalar.next
continue
}
out[entry.key] = parseScalar(entry.rest)
i++
}
return { value: out, next: i }
}
function parseSequence(lines: Line[], index: number, indent: number): Parsed {
const out: unknown[] = []
let i = index
while (i < lines.length) {
const line = lines[i]
if (line.indent !== indent || !isSequenceItem(line.text)) break
const afterDash = line.text.slice(1)
const lead = afterDash.length - afterDash.trimStart().length
const rest = afterDash.trimStart()
if (rest === '') {
const next = lines[i + 1]
if (next !== undefined && next.indent > indent) {
const child = parseBlock(lines, i + 1, indent + 1)
out.push(child.value)
i = child.next
} else {
out.push(null)
i++
}
continue
}
if (isBlockScalarHeader(rest)) {
const scalar = readBlockScalar(lines, i + 1, indent, rest)
out.push(scalar.value)
i = scalar.next
continue
}
if (isSequenceItem(rest) || splitEntry(rest) !== null) {
// `- key: value` 把映射的起始写在同一行:造一条等价的虚拟行再按同一份行数组解析,
// 这样后续与虚拟行同缩进的键仍能正确归入本项。
const effectiveIndent = indent + 1 + lead
const virtual = lines.slice()
virtual[i] = { indent: effectiveIndent, text: rest }
const child = isSequenceItem(rest)
? parseSequence(virtual, i, effectiveIndent)
: parseMapping(virtual, i, effectiveIndent)
out.push(child.value)
i = child.next
continue
}
out.push(parseScalar(rest))
i++
}
return { value: out, next: i }
}
function isSequenceItem(text: string): boolean {
return text === '-' || text.startsWith('- ') || text.startsWith('-\t')
}
/** 找顶层的「键: 值」分隔冒号;引号内和流式括号内的冒号不算。 */
function splitEntry(text: string): { key: string; rest: string } | null {
let quote: '"' | "'" | null = null
let depth = 0
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (quote === '"') {
if (ch === '\\') i++
else if (ch === '"') quote = null
continue
}
if (quote === "'") {
if (ch === "'") {
if (text[i + 1] === "'") i++
else quote = null
}
continue
}
if (ch === '"' || ch === "'") {
quote = ch
continue
}
if (ch === '{' || ch === '[') {
depth++
continue
}
if (ch === '}' || ch === ']') {
depth--
continue
}
if (ch === ':' && depth === 0) {
const after = text[i + 1]
if (after === undefined || after === ' ' || after === '\t') {
return {
key: unquote(text.slice(0, i).trim()),
rest: text.slice(i + 1).trim(),
}
}
}
}
return null
}
function isBlockScalarHeader(text: string): boolean {
return /^[|>][+-]?\d*$/.test(text)
}
function readBlockScalar(
lines: Line[],
index: number,
parentIndent: number,
header: string,
): { value: string; next: number } {
const collected: string[] = []
let i = index
while (i < lines.length && lines[i].indent > parentIndent) {
collected.push(lines[i].text)
i++
}
if (collected.length === 0) return { value: '', next: i }
const folded = header.startsWith('>')
const keep = header.includes('+')
const joined = folded ? collected.join(' ') : collected.join('\n')
return { value: keep ? `${joined}\n` : joined, next: i }
}
function parseScalar(text: string): unknown {
const trimmed = text.trim()
if (trimmed === '') return null
if (trimmed.startsWith('"')) return parseDoubleQuoted(trimmed)
if (trimmed.startsWith("'")) return parseSingleQuoted(trimmed)
if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parseFlow(trimmed)
return parsePlain(trimmed)
}
function parseDoubleQuoted(text: string): string {
let out = ''
let i = 1
while (i < text.length) {
const ch = text[i]
if (ch === '\\') {
const esc = text[i + 1]
i += 2
if (esc === 'n') out += '\n'
else if (esc === 't') out += '\t'
else if (esc === 'r') out += '\r'
else if (esc === 'u') {
const hex = text.slice(i, i + 4)
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new YamlParseError(`非法 \\u 转义:${hex}`)
out += String.fromCharCode(parseInt(hex, 16))
i += 4
} else out += esc ?? ''
continue
}
if (ch === '"') {
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`双引号后有多余内容:${text}`)
return out
}
out += ch
i++
}
throw new YamlParseError(`双引号未闭合:${text}`)
}
function parseSingleQuoted(text: string): string {
let out = ''
let i = 1
while (i < text.length) {
const ch = text[i]
if (ch === "'") {
if (text[i + 1] === "'") {
out += "'"
i += 2
continue
}
if (text.slice(i + 1).trim() !== '') throw new YamlParseError(`单引号后有多余内容:${text}`)
return out
}
out += ch
i++
}
throw new YamlParseError(`单引号未闭合:${text}`)
}
function parseFlow(text: string): unknown {
let pos = 0
function skip(): void {
while (pos < text.length && /\s/.test(text[pos])) pos++
}
function value(): unknown {
skip()
const ch = text[pos]
if (ch === '[') return sequence()
if (ch === '{') return mapping()
if (ch === '"' || ch === "'") return quoted()
return plain()
}
function quoted(): string {
const start = pos
const quote = text[pos]
pos++
while (pos < text.length) {
const ch = text[pos]
if (quote === '"' && ch === '\\') {
pos += 2
continue
}
if (ch === quote) {
if (quote === "'" && text[pos + 1] === "'") {
pos += 2
continue
}
pos++
const parsed = parseScalar(text.slice(start, pos))
return typeof parsed === 'string' ? parsed : String(parsed ?? '')
}
pos++
}
throw new YamlParseError(`流式引号未闭合:${text}`)
}
function plain(): unknown {
const start = pos
while (pos < text.length && text[pos] !== ',' && text[pos] !== ']' && text[pos] !== '}') pos++
return parsePlain(text.slice(start, pos).trim())
}
function sequence(): unknown[] {
pos++
const out: unknown[] = []
skip()
if (text[pos] === ']') {
pos++
return out
}
while (pos < text.length) {
out.push(value())
skip()
if (text[pos] === ',') {
pos++
continue
}
if (text[pos] === ']') {
pos++
return out
}
throw new YamlParseError(`流式序列缺少逗号或右括号:${text}`)
}
throw new YamlParseError(`流式序列未闭合:${text}`)
}
function mapping(): Record<string, unknown> {
pos++
const out: Record<string, unknown> = {}
skip()
if (text[pos] === '}') {
pos++
return out
}
while (pos < text.length) {
skip()
const keyStart = pos
while (pos < text.length && text[pos] !== ':') pos++
if (pos >= text.length) throw new YamlParseError(`流式映射缺少冒号:${text}`)
const key = unquote(text.slice(keyStart, pos).trim())
pos++
out[key] = value()
skip()
if (text[pos] === ',') {
pos++
continue
}
if (text[pos] === '}') {
pos++
return out
}
throw new YamlParseError(`流式映射缺少逗号或右括号:${text}`)
}
throw new YamlParseError(`流式映射未闭合:${text}`)
}
const result = value()
skip()
if (pos !== text.length) throw new YamlParseError(`流式值后有多余内容:${text}`)
return result
}
function parsePlain(text: string): unknown {
if (text === '' || text === '~' || text === 'null' || text === 'Null' || text === 'NULL') return null
if (/^(true|false)$/i.test(text)) return text.toLowerCase() === 'true'
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return Number(text)
return text
}
function unquote(text: string): string {
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
try {
return parseDoubleQuoted(text)
} catch {
return text.slice(1, -1)
}
}
if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
return text.slice(1, -1).replace(/''/g, "'")
}
return text
}