人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
import type { ResultType } from './types'
|
|
|
|
/**
|
|
* 成绩 / 时间的展示与录入互转。
|
|
*
|
|
* 库里同时存 result_text(给人看)和 result_value(给排序用),
|
|
* 两者必须在同一处换算,否则录入页和导出会出现「看起来一样、排出来不一样」。
|
|
*/
|
|
|
|
const DATE_PAD = (value: number): string => String(value).padStart(2, '0')
|
|
|
|
/** 秒 → `1:06.33`;不足一分钟用 `6.33`。 */
|
|
export function formatSeconds(seconds: number, fractionDigits = 2): string {
|
|
if (!Number.isFinite(seconds)) return '—'
|
|
|
|
const sign = seconds < 0 ? '-' : ''
|
|
const abs = Math.abs(seconds)
|
|
let minutes = Math.floor(abs / 60)
|
|
let rest = (abs - minutes * 60).toFixed(fractionDigits)
|
|
|
|
// 59.999 会四舍五入成 "60.00",进位到分钟,避免出现 "1:60.00"。
|
|
if (Number(rest) >= 60) {
|
|
minutes += 1
|
|
rest = (0).toFixed(fractionDigits)
|
|
}
|
|
|
|
// 秒数要补足两位;fractionDigits=0 时没有小数点,补位宽度是 2 而不是 3。
|
|
const secondsWidth = fractionDigits > 0 ? 3 + fractionDigits : 2
|
|
const body = minutes > 0 ? `${minutes}:${rest.padStart(secondsWidth, '0')}` : rest
|
|
return `${sign}${body}`
|
|
}
|
|
|
|
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。 */
|
|
export function parseSeconds(text: string): number | null {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return null
|
|
|
|
const parts = trimmed.split(':')
|
|
if (parts.length > 2) return null
|
|
|
|
const numbers: number[] = []
|
|
for (const part of parts) {
|
|
const value = Number(part)
|
|
if (!Number.isFinite(value)) return null
|
|
numbers.push(value)
|
|
}
|
|
|
|
if (numbers.length === 1) return numbers[0] ?? null
|
|
|
|
const minutes = numbers[0] ?? 0
|
|
const seconds = numbers[1] ?? 0
|
|
if (seconds < 0 || seconds >= 60) return null
|
|
return minutes * 60 + seconds
|
|
}
|
|
|
|
/** 按单位把比较值渲染成展示文本,如 `66.33` + `s` → `1:06.33`。 */
|
|
export function formatResultText(
|
|
value: number | null,
|
|
unit: string,
|
|
type: ResultType = 'none',
|
|
): string {
|
|
if (value === null || !Number.isFinite(value)) return '—'
|
|
if (type === 'time' || unit === 's') return formatSeconds(value)
|
|
if (unit === 'm') return `${value.toFixed(2)}米`
|
|
if (unit === 'pts') return `${formatPoints(value)}分`
|
|
if (unit === '次') return `${Math.round(value)}次`
|
|
return `${formatPoints(value)}${unit}`
|
|
}
|
|
|
|
/** 展示文本 → 比较值,与 formatResultText 互逆。 */
|
|
export function parseResultValue(text: string, unit: string): number | null {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return null
|
|
if (unit === 's') return parseSeconds(trimmed)
|
|
|
|
const numeric = Number(trimmed.replace(/[^\d.+-]/g, ''))
|
|
return Number.isFinite(numeric) ? numeric : null
|
|
}
|
|
|
|
/** 积分去掉无意义的 `.0`,同时避免 `12.25` 被截成 `12.3`。 */
|
|
export function formatPoints(points: number): string {
|
|
if (!Number.isFinite(points)) return '—'
|
|
return Number.isInteger(points) ? String(points) : String(Number(points.toFixed(2)))
|
|
}
|
|
|
|
/** 名次升降:正数为上升。无变化返回空串,交给调用方决定是否占位。 */
|
|
export function formatRankChange(delta: number | null): string {
|
|
if (delta === null || delta === 0) return ''
|
|
return delta > 0 ? `↑${delta}` : `↓${Math.abs(delta)}`
|
|
}
|
|
|
|
/** ISO → `YYYY-MM-DD HH:mm`(本地时区)。 */
|
|
export function formatDateTime(iso: string | null): string {
|
|
const date = toDate(iso)
|
|
if (date === null) return '—'
|
|
return (
|
|
`${date.getFullYear()}-${DATE_PAD(date.getMonth() + 1)}-${DATE_PAD(date.getDate())}` +
|
|
` ${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}`
|
|
)
|
|
}
|
|
|
|
/** ISO → `HH:mm`,大屏顶栏时钟用。 */
|
|
export function formatClock(iso: string | null): string {
|
|
const date = toDate(iso)
|
|
if (date === null) return '--:--:--'
|
|
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}:${DATE_PAD(date.getSeconds())}`
|
|
}
|
|
|
|
/** 带时区后缀(Z 或 ±HH:MM)的时间串按原样解析。 */
|
|
const HAS_TIMEZONE = /(Z|[+-]\d{2}:?\d{2})$/i
|
|
|
|
function toDate(iso: string | null): Date | null {
|
|
if (iso === null || iso === '') return null
|
|
// SQLite 的 datetime('now') 是 UTC 但不带时区,new Date() 会按本地时区解析,
|
|
// 东八区会整体早 8 小时;先把空格分隔符换成 T,无时区的串再补 Z 按 UTC 解析。
|
|
const normalized = iso.replace(' ', 'T')
|
|
const text = HAS_TIMEZONE.test(normalized) ? normalized : `${normalized}Z`
|
|
const date = new Date(text)
|
|
return Number.isNaN(date.getTime()) ? null : date
|
|
}
|