Files
Scoreboard/web/src/shared/format.ts
T
AskaEth 6483acf9ad feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(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 文件通过。
2026-09-10 09:05:06 +08:00

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
}