fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
@@ -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
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
3. 浏览器打开终端提示的地址
|
3. 浏览器打开终端提示的地址
|
||||||
|
|
||||||
```
|
```
|
||||||
大屏 http://<主机IP>:8080/display
|
大屏 http://<主机IP>:19540/display
|
||||||
管理面板 http://<主机IP>:8080/admin
|
管理面板 http://<主机IP>:19540/admin
|
||||||
```
|
```
|
||||||
|
|
||||||
> 完全离线运行,不依赖外网,不需要安装任何运行时。
|
> 完全离线运行,不依赖外网,不需要安装任何运行时。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)})`)
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Generated
+2413
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? '用户')))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user