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 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

@@ -14,9 +14,12 @@ const toast = useToastStore()
interface AthleteForm {
name: string
gender: '' | Gender
// type="number" 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),这里如实标注
age: string | number
teamId: string
employeeNo: string
idCard: string
bib: string
active: boolean
}
@@ -38,7 +41,21 @@ const confirmTarget = ref<Athlete | null>(null)
const confirmPending = ref(false)
function emptyForm(): AthleteForm {
return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true }
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
}
/**
* 年龄取值。
*
* `type="number"` 的 v-model 会被 Vue 自动转成 number(空值仍是 ''),所以不能直接 `.trim()`:
* 填了数字后字段是 number,`.trim()` 抛 TypeError,点击处理器中断,表现为「点了没反应」。
* 非法输入返回 NaN,交给 validate 标红。
*/
function toAge(value: string | number): number | null {
const text = String(value).trim()
if (text === '') return null
const parsed = Number(text)
return Number.isFinite(parsed) ? parsed : Number.NaN
}
const filtered = computed(() => {
@@ -53,6 +70,7 @@ const filtered = computed(() => {
athlete.name.toLowerCase().includes(keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
(athlete.idCard ?? '').toLowerCase().includes(keyword) ||
(athlete.bib ?? '').toLowerCase().includes(keyword) ||
(team?.name ?? '').toLowerCase().includes(keyword)
)
})
@@ -74,9 +92,11 @@ function openEdit(athlete: Athlete): void {
form.value = {
name: athlete.name,
gender: athlete.gender ?? '',
age: athlete.age === null ? '' : String(athlete.age),
teamId: String(athlete.teamId),
employeeNo: athlete.employeeNo ?? '',
idCard: athlete.idCard ?? '',
bib: athlete.bib ?? '',
active: athlete.active,
}
errors.value = {}
@@ -90,6 +110,8 @@ function validate(): boolean {
if (form.value.teamId === '' || data.teamById(Number(form.value.teamId)) === undefined) {
next.teamId = '必须选择一个参赛单位'
}
const age = toAge(form.value.age)
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) next.age = '年龄为 0–120 的整数'
errors.value = next
return Object.keys(next).length === 0
}
@@ -101,9 +123,11 @@ async function submit(): Promise<void> {
const input: AthleteInput = {
name: form.value.name.trim(),
gender: form.value.gender === '' ? null : form.value.gender,
age: toAge(form.value.age),
teamId: Number(form.value.teamId),
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
idCard: form.value.idCard.trim() === '' ? null : form.value.idCard.trim(),
bib: form.value.bib.trim() === '' ? null : form.value.bib.trim(),
active: form.value.active,
}
try {
@@ -133,9 +157,11 @@ async function toggleActive(athlete: Athlete): Promise<void> {
{
name: athlete.name,
gender: athlete.gender,
age: athlete.age,
teamId: athlete.teamId,
employeeNo: athlete.employeeNo,
idCard: athlete.idCard,
bib: athlete.bib,
active: !athlete.active,
},
athlete.id,
@@ -162,7 +188,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
<div class="bd-toolbar-left">
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号" />
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号 / 号码布" />
</div>
<select v-model="teamFilter" class="mx-cell-input bd-select">
<option value="all">全部单位</option>
@@ -187,16 +213,18 @@ async function toggleActive(athlete: Athlete): Promise<void> {
<tr>
<th>姓名</th>
<th class="bd-cell-mini">性别</th>
<th class="bd-cell-mini">年龄</th>
<th>所属单位</th>
<th>工号 / 学号</th>
<th>证件号</th>
<th>号码布</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="data.loading.value && data.athletes.value.length === 0">
<td colspan="7">
<td colspan="9">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
@@ -207,9 +235,11 @@ async function toggleActive(athlete: Athlete): Promise<void> {
<tr v-for="athlete in filtered" :key="athlete.id">
<td>{{ athlete.name }}</td>
<td>{{ athlete.gender ?? '—' }}</td>
<td>{{ athlete.age ?? '—' }}</td>
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
<td class="bd-mono">{{ athlete.idCard ?? '—' }}</td>
<td class="bd-mono">{{ athlete.bib ?? '—' }}</td>
<td>
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ athlete.active ? '启用' : '已停用' }}
@@ -233,7 +263,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7">
<td colspan="9">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
@@ -267,6 +297,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
<option value="女">女</option>
</select>
</FormField>
<FormField label="年龄" hint="可留空,填了必须是 0–120 的整数" :error="errors.age">
<input v-model="form.age" class="mx-cell-input" type="number" min="0" max="120" placeholder="例如 26" />
</FormField>
<FormField label="所属单位" required :error="errors.teamId">
<select v-model="form.teamId" class="mx-cell-input">
<option value="" disabled>请选择单位</option>
@@ -278,9 +311,12 @@ async function toggleActive(athlete: Athlete): Promise<void> {
<FormField label="工号 / 学号" hint="可留空,用于对账">
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
</FormField>
<FormField label="证件号" hint="人员唯一标识,建议必填">
<FormField label="证件号" hint="证件号与号码布至少填一个,二者都是人员唯一标识">
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
</FormField>
<FormField label="号码布" hint="没有身份证信息时可只填号码布">
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
</FormField>
<FormField label="状态" wide>
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
</FormField>
@@ -83,9 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
}
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
function missingIdCard(athlete: Athlete): boolean {
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
/** 个人项目的行必须带证件号或号码布;团体行运动员为空,不受影响。 */
function missingIdentifier(athlete: Athlete): boolean {
const noIdCard = athlete.idCard === null || athlete.idCard === ''
const noBib = athlete.bib === null || athlete.bib === ''
return mode.value === 'individual' && noIdCard && noBib
}
async function loadEntries(id: number): Promise<void> {
@@ -138,7 +140,7 @@ async function submit(): Promise<void> {
categoryName: category.name,
teamCode: team.code,
mode: mode.value,
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard, bib: athlete.bib })),
})
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
const result = await data.importEntries(file)
@@ -227,12 +229,12 @@ async function submit(): Promise<void> {
v-model="checked"
type="checkbox"
:value="athlete.id"
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)"
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdentifier(athlete)"
/>
<span class="picker-name">{{ athlete.name }}</span>
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}{{ athlete.bib ? ` · 号码布 ${athlete.bib}` : '' }}</span>
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
<span v-else-if="missingIdentifier(athlete)" class="mx-badge mx-badge-warning">缺证件号/号码布</span>
</label>
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
@@ -243,8 +245,8 @@ async function submit(): Promise<void> {
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
<p class="bd-note">报名会自动带上该运动员的证件号(优先)或号码布;种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
</template>
@@ -8,6 +8,8 @@ import { useToastStore } from '../../stores/toast'
import {
REQUIRED_COLUMNS,
conflictDiffs,
conflictLabel,
decisionKeyOf,
decisionsPayload,
pruneDecisions,
recordDecisions,
@@ -20,8 +22,9 @@ const toast = useToastStore()
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,110101199001011234,男,101,,registered,',
'BB,混合组,F2,,,,,种子1,registered,第一分公司代表队',
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,registered,',
'W100,女子组,F2,王芳,,女,,202,,registered,',
'BB,混合组,F2,,,,,,种子1,registered,第一分公司代表队',
].join('\n')
const MAX_SIZE = 16 * 1024 * 1024
@@ -33,7 +36,7 @@ const importing = ref(false)
const result = ref<EntryImportResult | null>(null)
const error = ref<string | null>(null)
/** 冲突决策面板的暂存状态:证件号 → 保留哪一侧,点「应用决策」才提交。 */
/** 冲突决策面板的暂存状态:决策键(证件号优先,否则号码布)→ 保留哪一侧,点「应用决策」才提交。 */
const decisions = ref<DecisionMap>({})
const selected = ref<string[]>([])
const resolving = ref(false)
@@ -88,7 +91,7 @@ function clearFile(): void {
}
function selectAll(): void {
selected.value = conflicts.value.map((conflict) => conflict.idCard)
selected.value = conflicts.value.map(decisionKeyOf)
}
function clearSelection(): void {
@@ -101,7 +104,7 @@ function decideSelected(action: ConflictAction): void {
}
function decideOne(conflict: ImportConflict, action: ConflictAction): void {
decisions.value = recordDecisions(decisions.value, conflicts.value, [conflict.idCard], action)
decisions.value = recordDecisions(decisions.value, conflicts.value, [decisionKeyOf(conflict)], action)
}
async function applyDecisions(): Promise<void> {
@@ -116,8 +119,8 @@ async function applyDecisions(): Promise<void> {
result.value = next
// 已决策的人不再出现在剩余冲突里,同步清掉暂存与勾选,避免串到下一轮。
decisions.value = pruneDecisions(decisions.value, next.conflicts)
const remaining = new Set(next.conflicts.map((conflict) => conflict.idCard))
selected.value = selected.value.filter((idCard) => remaining.has(idCard))
const remaining = new Set(next.conflicts.map(decisionKeyOf))
selected.value = selected.value.filter((key) => remaining.has(key))
toast.success('冲突决策已应用', `新增 ${next.created} · 更新 ${next.updated} · 跳过 ${next.skipped}`)
} catch (caught) {
resolveError.value = caught instanceof Error ? caught.message : '应用决策失败'
@@ -186,9 +189,10 @@ function downloadTemplate(): void {
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender 可缺省,
但有姓名的行必须填证件号,否则该行会报错并跳过。证件号是人员唯一标识:先按证件号匹配,
未命中再按「姓名 + 单位」匹配库里证件号为空的人并回填。
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省,
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
证件号与号码布都是人员唯一标识:
先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。
</span>
</div>
@@ -258,7 +262,7 @@ function downloadTemplate(): void {
<div class="conflicts-head">
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
<span class="bd-note">
同一证件号在库里与报名表里的姓名 / 性别 / 单位不一致,本轮未落库,也不建报名。
同一证件号 / 号码布在库里与报名表里的姓名 / 性别 / 年龄 / 单位 / 号码布不一致,本轮未落库,也不建报名。
</span>
</div>
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
@@ -294,23 +298,23 @@ function downloadTemplate(): void {
<div class="conflict-list">
<div
v-for="conflict in conflicts"
:key="conflict.idCard"
:key="decisionKeyOf(conflict)"
class="conflict-item"
:class="{ decided: decisions[conflict.idCard] !== undefined }"
:class="{ decided: decisions[decisionKeyOf(conflict)] !== undefined }"
>
<div class="conflict-head">
<label class="bd-check conflict-check">
<input v-model="selected" type="checkbox" :value="conflict.idCard" />
<span class="conflict-id bd-mono">{{ conflict.idCard }}</span>
<input v-model="selected" type="checkbox" :value="decisionKeyOf(conflict)" />
<span class="conflict-id bd-mono">{{ conflictLabel(conflict) }}</span>
<span class="conflict-name">{{ conflict.existing.name }}</span>
<span class="bd-note">第 {{ conflict.row }} 行</span>
</label>
<span
v-if="decisions[conflict.idCard] !== undefined"
v-if="decisions[decisionKeyOf(conflict)] !== undefined"
class="mx-badge"
:class="decisions[conflict.idCard] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
:class="decisions[decisionKeyOf(conflict)] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
>
{{ decisions[conflict.idCard] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
{{ decisions[decisionKeyOf(conflict)] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
</span>
<div class="bd-actions conflict-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-existing')">
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import type { Team, TeamInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import ImageUploadField from '../system/ImageUploadField.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
@@ -281,8 +282,8 @@ async function toggleActive(team: Team): Promise<void> {
<span v-if="form.color" class="bd-color" :style="{ background: form.color }" />
</div>
</FormField>
<FormField label="Logo 路径" hint="相对 uploads/,可留空">
<input v-model="form.logoPath" class="mx-cell-input" placeholder="logos/team1.png" />
<FormField label="Logo 路径" hint="留空则不显示单位图标;上传的图片随赛事包一起导出">
<ImageUploadField v-model="form.logoPath" :disabled="!data.canWrite.value" />
</FormField>
<FormField label="排序" :error="errors.sortOrder" hint="数字越小越靠前">
<input v-model="form.sortOrder" class="mx-cell-input" type="number" min="0" max="9999" />
@@ -21,14 +21,15 @@ export const REQUIRED_COLUMNS = [
'athlete_name',
'id_card',
'gender',
'age',
'bib',
'seed',
'status',
'member_names',
] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender'] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
@@ -44,8 +45,8 @@ export interface SelectionInput {
teamCode: string
status?: string
mode: SelectionMode
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
athletes: { name: string; idCard?: string | null }[]
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。 */
athletes: { name: string; idCard?: string | null; bib?: string | null }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
memberNames?: string[]
}
@@ -69,6 +70,7 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
athlete.idCard ?? '',
'',
'',
athlete.bib ?? '',
'',
status,
'',
@@ -77,7 +79,19 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', '', '', status, names.join('/')])
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
'',
'',
'',
'',
'',
'',
status,
names.join('/'),
])
return rows
}
@@ -96,8 +110,10 @@ function escapeField(field: string): string {
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
name: '姓名',
gender: '性别',
age: '年龄',
teamCode: '单位',
employeeNo: '工号 / 学号',
bib: '号码布',
}
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
@@ -120,32 +136,48 @@ export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
}))
}
function formatFieldValue(value: string | null): string {
return value === null || value === '' ? '—' : value
function formatFieldValue(value: string | number | null | undefined): string {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
/** 证件号 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
/**
* 冲突的决策键:证件号优先,没有证件号时用号码布。
*
* 与服务端 repo_import.go 的 decisionKey() 是同一个约定,两边必须保持一致;
* 只有号码布的行证件号为空串,直接拿证件号当键会让多条冲突互相覆盖。
*/
export function decisionKeyOf(conflict: ImportConflict): string {
return conflict.idCard !== '' ? conflict.idCard : conflict.bib
}
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
export function conflictLabel(conflict: ImportConflict): string {
const key = decisionKeyOf(conflict)
return key === '' ? '(无证件号 / 号码布)' : key
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
export type DecisionMap = Record<string, ConflictAction>
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */
export function recordDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
idCards: readonly string[],
keys: readonly string[],
action: ConflictAction,
): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
const known = new Set(conflicts.map(decisionKeyOf))
const next: DecisionMap = { ...current }
for (const idCard of idCards) {
if (known.has(idCard)) next[idCard] = action
for (const key of keys) {
if (known.has(key)) next[key] = action
}
return next
}
/** 丢弃已不存在的证件号,换文件或 resolve 之后调用。 */
/** 丢弃已不存在的决策键,换文件或 resolve 之后调用。 */
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
return Object.fromEntries(Object.entries(current).filter(([idCard]) => known.has(idCard)))
const known = new Set(conflicts.map(decisionKeyOf))
return Object.fromEntries(Object.entries(current).filter(([key]) => known.has(key)))
}
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
@@ -154,8 +186,12 @@ export function decisionsPayload(
conflicts: readonly ImportConflict[],
): ImportConflictDecision[] {
return conflicts
.filter((conflict) => current[conflict.idCard] !== undefined)
.map((conflict) => ({ idCard: conflict.idCard, action: current[conflict.idCard] as ConflictAction }))
.filter((conflict) => current[decisionKeyOf(conflict)] !== undefined)
.map((conflict) => ({
idCard: conflict.idCard,
bib: conflict.bib,
action: current[decisionKeyOf(conflict)] as ConflictAction,
}))
}
export interface DecisionSummary {
@@ -171,7 +207,7 @@ export function summarizeDecisions(
): DecisionSummary {
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
for (const conflict of conflicts) {
const action = current[conflict.idCard]
const action = current[decisionKeyOf(conflict)]
if (action === 'keep-existing') summary.keepExisting += 1
else if (action === 'keep-new') summary.keepNew += 1
else summary.undecided += 1
+101 -15
View File
@@ -237,6 +237,8 @@ export class MockBaseData {
teamId: team.id,
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
idCard: null,
bib: `${team.id}${String(nameIndex + 1).padStart(2, '0')}`,
age: 22 + nameIndex * 2,
active: true,
})
})
@@ -409,6 +411,8 @@ export class MockBaseData {
}
createAthlete(input: AthleteInput): Athlete {
this.assertAthleteIdentityFree(input, null)
this.assertAthleteAge(input)
const athlete: Athlete = { ...input, id: (this.nextAthleteId += 1) }
this.athletes = [...this.athletes, athlete]
return { ...athlete }
@@ -417,11 +421,32 @@ export class MockBaseData {
updateAthlete(id: number, input: AthleteInput): Athlete {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
this.assertAthleteIdentityFree(input, id)
this.assertAthleteAge(input)
const athlete: Athlete = { ...input, id }
this.athletes[index] = athlete
return { ...athlete }
}
/** 证件号 / 号码布都是人员唯一标识,重复时报出具体是哪个字段。 */
private assertAthleteIdentityFree(input: AthleteInput, selfId: number | null): void {
const idCard = input.idCard ?? ''
if (idCard !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.idCard === idCard)) {
throw new Error(`证件号 ${idCard} 已被其他人员使用`)
}
const bib = input.bib ?? ''
if (bib !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.bib === bib)) {
throw new Error(`号码布 ${bib} 已被其他人员使用`)
}
}
/** 年龄可空;填了就必须是 0–120 的整数,与服务端校验一致。 */
private assertAthleteAge(input: AthleteInput): void {
if (input.age !== null && (!Number.isInteger(input.age) || input.age < 0 || input.age > 120)) {
throw new Error('年龄必须是 0–120 的整数')
}
}
removeAthlete(id: number): void {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
@@ -434,9 +459,12 @@ export class MockBaseData {
return this.runImport(file, new Map())
}
/** 冲突决策重跑:decisions 为空的证件号仍视为未决策,继续留在返回的 conflicts 里。 */
/** 冲突决策重跑:没给决策的键仍视为未决策,继续留在返回的 conflicts 里。 */
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
return this.runImport(file, new Map(decisions.map((decision) => [decision.idCard, decision.action])))
// 与服务端一致:证件号优先,没有证件号时用号码布当决策键。
const keyOf = (decision: ImportConflictDecision): string =>
decision.idCard !== '' ? decision.idCard : decision.bib
return this.runImport(file, new Map(decisions.map((decision) => [keyOf(decision), decision.action])))
}
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
@@ -453,6 +481,7 @@ export class MockBaseData {
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
const seenIdCards = new Set<string>()
const seenBibs = new Set<string>()
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
if (row.every((cell) => cell.trim() === '')) return
@@ -498,35 +527,58 @@ export class MockBaseData {
return
}
const idCard = cell('id_card')
if (idCard === '') {
const bib = cell('bib')
if (idCard === '' && bib === '') {
result.skipped += 1
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号`)
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号或号码布`)
return
}
if (seenIdCards.has(idCard)) {
// 年龄可空;填了就必须是 0–120 的整数,非法行直接跳过,不占证件号 / 号码布名额。
const age = parseAge(cell('age'))
if (age !== null && (!Number.isInteger(age) || age < 0 || age > 120)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
return
}
if (idCard !== '' && seenIdCards.has(idCard)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
return
}
seenIdCards.add(idCard)
if (bib !== '' && seenBibs.has(bib)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
return
}
if (idCard !== '') seenIdCards.add(idCard)
if (bib !== '') seenBibs.add(bib)
const rawGender = cell('gender')
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
const matched = this.athletes.find((athlete) => athlete.idCard === idCard)
// 契约匹配顺序:证件号 → 号码布 → 姓名 + 单位(库里证件号为空)。
const matched =
(idCard === '' ? undefined : this.athletes.find((athlete) => athlete.idCard === idCard)) ??
(bib === '' ? undefined : this.athletes.find((athlete) => athlete.bib === bib))
if (matched !== undefined) {
const conflict = this.detectConflict(matched, line, idCard, athleteName, gender, team)
if (conflict !== null && decisions.get(idCard) === undefined) {
const conflict = this.detectConflict(matched, line, idCard, bib, athleteName, gender, age, team)
const key = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(key) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(idCard) === 'keep-new') {
if (conflict !== null && decisions.get(key) === 'keep-new') {
matched.name = athleteName
if (gender !== null) matched.gender = gender
if (age !== null) matched.age = age
matched.teamId = team.id
if (idCard !== '') matched.idCard = idCard
if (bib !== '') matched.bib = bib
}
// 库里没有年龄时按表里的值回填,已有年龄一律不动。
if (age !== null && matched.age === null) matched.age = age
result.created += 1
return
}
// 证件号未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
// 证件号 / 号码布未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
const legacy = this.athletes.find(
(athlete) =>
athlete.name === athleteName &&
@@ -534,7 +586,18 @@ export class MockBaseData {
(athlete.idCard === null || athlete.idCard === ''),
)
if (legacy !== undefined) {
legacy.idCard = idCard
const conflict = this.detectConflict(legacy, line, idCard, bib, athleteName, gender, age, team)
const legacyKey = idCard !== '' ? idCard : bib
if (conflict !== null && decisions.get(legacyKey) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && bib !== '') legacy.bib = bib
if (conflict !== null && decisions.get(legacyKey) === 'keep-new' && age !== null) legacy.age = age
if (idCard !== '') legacy.idCard = idCard
if (bib !== '' && (legacy.bib === null || legacy.bib === '')) legacy.bib = bib
// 其余情况只补库中为空的年龄,绝不用空值清掉已有值。
if (age !== null && legacy.age === null) legacy.age = age
result.created += 1
return
}
@@ -544,7 +607,9 @@ export class MockBaseData {
gender,
teamId: team.id,
employeeNo: null,
idCard,
idCard: idCard === '' ? null : idCard,
bib: bib === '' ? null : bib,
age,
active: true,
})
result.created += 1
@@ -552,36 +617,57 @@ export class MockBaseData {
return result
}
/** name / gender / teamCode 与库里不一致才算冲突;表里 gender 为空视为未指定。 */
/** name / gender / age / teamCode / bib 与库里不一致才算冲突;表里为空的字段视为未指定。 */
private detectConflict(
athlete: Athlete,
row: number,
idCard: string,
bib: string,
name: string,
gender: Athlete['gender'],
age: number | null,
team: Team,
): ImportConflict | null {
const changed: ImportConflictField[] = []
if (athlete.name !== name) changed.push('name')
if (gender !== null && athlete.gender !== gender) changed.push('gender')
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
if (age !== null && athlete.age !== null && athlete.age !== age) changed.push('age')
if (athlete.teamId !== team.id) changed.push('teamCode')
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
if (bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== bib) changed.push('bib')
if (changed.length === 0) return null
return {
row,
idCard,
bib,
athleteId: athlete.id,
existing: {
name: athlete.name,
gender: athlete.gender,
age: athlete.age,
teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '',
employeeNo: athlete.employeeNo,
bib: athlete.bib,
},
incoming: { name, gender, teamCode: team.code, employeeNo: null },
incoming: { name, gender, age, teamCode: team.code, employeeNo: null, bib: bib === '' ? null : bib },
changedFields: changed,
}
}
}
/**
* 报名表里的年龄:空值返回 null(未指定),非数字返回 NaN 交给调用方报错。
*
* 与「组别」表的写法一致:不能对 number 直接 `.trim()`,先 `String()` 再解析。
*/
function parseAge(raw: string): number | null {
const text = String(raw).trim()
if (text === '') return null
const parsed = Number(text)
return Number.isFinite(parsed) ? parsed : Number.NaN
}
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
function parseCsv(text: string): string[][] {
const rows: string[][] = []
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'
import type { EventSettings } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ImageUploadField from './ImageUploadField.vue'
import MockBadge from './MockBadge.vue'
import { describeError, settingsApi, type EventSettingsPatch } from './systemApi'
import { useAuthStore } from '../../stores/auth'
@@ -77,11 +78,15 @@ async function save(): Promise<void> {
name: next.event.name.trim(),
subtitle: optional(next.event.subtitle),
host: optional(next.event.host),
// 同理:会标清空必须传空串,传 null 服务端会当成「不改动」。
logoPath: optional(next.event.logoPath),
},
display: {
...next.display,
title: next.display.title.trim(),
subtitle: optional(next.display.subtitle),
logo: optional(next.display.logo),
background: optional(next.display.background),
rotateSeconds,
},
standings: { ...next.standings },
@@ -151,10 +156,34 @@ async function save(): Promise<void> {
<input id="event-host" v-model="form.event.host" class="mx-input" placeholder="可选" />
</div>
</div>
<div class="field wide">
<span class="mx-label">赛事会标</span>
<ImageUploadField
v-model="form.event.logoPath"
:disabled="!isAdmin"
hint="积分榜左上角的会标。上传的图片存放在服务端 uploads/ 下,会随赛事包一起导出。"
/>
</div>
</div>
<h3 class="group-title">大屏配置</h3>
<div class="form-grid">
<div class="field wide">
<span class="mx-label">大屏会标</span>
<ImageUploadField
v-model="form.display.logo"
:disabled="!isAdmin"
hint="大屏顶栏的会标;留空则回落到赛事会标。"
/>
</div>
<div class="field wide">
<span class="mx-label">大屏背景图</span>
<ImageUploadField
v-model="form.display.background"
:disabled="!isAdmin"
hint="大屏整屏背景图,建议 1920×1080;留空则不显示背景图。"
/>
</div>
<div class="field">
<label class="mx-label" for="display-title">大屏标题</label>
<div class="mx-input-wrap">
@@ -243,6 +272,10 @@ async function save(): Promise<void> {
display: flex;
flex-direction: column;
}
/* 图片字段要比纯文本框宽:缩略图与两个按钮并排。 */
.field.wide {
grid-column: 1 / -1;
}
.switch-field {
justify-content: flex-end;
}
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import MxIcon from '../MxIcon.vue'
import { adminApi } from '../../api/client'
import { useToastStore } from '../../stores/toast'
import { describeError } from './systemApi'
import { resolveAssetUrl } from './imagePath'
/**
* 图片字段:文本框(可手填 / 粘贴路径)+ 上传按钮 + 缩略图预览。
*
* 上传走 POST /api/uploads,把服务端返回的 `/uploads/<随机名>` 写回 v-model;
* 服务端的 uploads 目录随赛事包一起导出,所以跨设备转移只需导出整包。
* 清空一律写空串而不是 null:服务端对 *string 的 nil 是「保持原值」,
* 传 null 会让旧图永远清不掉。
*/
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
const props = withDefaults(
defineProps<{
modelValue: string | null
hint?: string
disabled?: boolean
accept?: string
}>(),
{
hint: '',
disabled: false,
accept: 'image/png,image/jpeg,image/webp,image/gif',
},
)
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
const toast = useToastStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const broken = ref(false)
/** 上传中的本地即时预览(Blob URL),上传成功后以服务端返回路径为准。 */
const localPreview = ref<string | null>(null)
const hasValue = computed(() => (props.modelValue ?? '').trim() !== '')
const previewUrl = computed(() => localPreview.value ?? resolveAssetUrl(props.modelValue))
watch(
() => props.modelValue,
() => {
broken.value = false
},
)
onBeforeUnmount(revokeLocalPreview)
function revokeLocalPreview(): void {
if (localPreview.value === null) return
URL.revokeObjectURL(localPreview.value)
localPreview.value = null
}
function choose(): void {
if (props.disabled || uploading.value) return
fileInput.value?.click()
}
function onInput(event: Event): void {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
function clear(): void {
revokeLocalPreview()
emit('update:modelValue', '')
}
async function onPick(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
// 不清空 value 时连续选同一个文件不会再触发 change。
input.value = ''
if (file === null) return
if (file.size > MAX_UPLOAD_BYTES) {
toast.error('图片过大', `单张图片不能超过 ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`)
return
}
revokeLocalPreview()
localPreview.value = URL.createObjectURL(file)
uploading.value = true
try {
const result = await adminApi.client.uploads.upload(file)
revokeLocalPreview()
emit('update:modelValue', result.path)
toast.success('图片已上传', result.path)
} catch (caught) {
revokeLocalPreview()
toast.error('图片上传失败', describeError(caught))
} finally {
uploading.value = false
}
}
</script>
<template>
<div class="img-field">
<div class="img-row">
<input
class="mx-cell-input"
type="text"
:value="modelValue ?? ''"
:disabled="disabled"
placeholder="可手填相对路径,如 uploads/logo.png"
@input="onInput"
/>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="disabled || uploading"
@click="choose"
>
<MxIcon name="push" :size="15" /> {{ uploading ? '上传中…' : '上传图片' }}
</button>
<button
v-if="hasValue"
class="mx-btn mx-btn-ghost"
type="button"
:disabled="disabled || uploading"
@click="clear"
>
清除
</button>
</div>
<input ref="fileInput" class="file-input" type="file" :accept="accept" @change="onPick" />
<div v-if="previewUrl !== ''" class="img-preview">
<img :src="previewUrl" alt="" @load="broken = false" @error="broken = true" />
<span v-if="broken" class="img-note error">图片无法加载,请检查路径</span>
<span v-else-if="localPreview !== null" class="img-note">上传中,预览为本地文件</span>
</div>
<span v-else class="img-note">未设置:留空则不显示</span>
<span v-if="hint !== ''" class="img-note">{{ hint }}</span>
</div>
</template>
<style scoped>
.img-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.img-row {
display: flex;
align-items: center;
gap: 8px;
}
.img-row .mx-cell-input {
flex: 1;
min-width: 0;
}
.file-input {
display: none;
}
.img-preview {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.img-preview img {
max-width: 180px;
max-height: 64px;
border-radius: var(--mx-r-xs);
border: 1px solid var(--mx-border);
background: var(--mx-s3);
}
.img-note {
display: block;
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
word-break: break-all;
}
.img-note.error {
color: var(--mx-error);
}
</style>
@@ -0,0 +1,13 @@
/**
* 图片路径解析(供 <img src> 使用)。
*
* 上传接口返回的路径本就带 `/uploads/` 前缀,这里主要兼容手填的历史值:
* 绝对地址(http/https)、内联数据(data:)与已带前导斜杠的路径原样使用,
* 其余相对路径补一个 `/`,否则浏览器会相对当前路由解析(如 `/admin/logos/x.png`)。
*/
export function resolveAssetUrl(value: string | null | undefined): string {
const path = (value ?? '').trim()
if (path === '') return ''
if (path.startsWith('/') || path.startsWith('data:') || /^https?:\/\//i.test(path)) return path
return `/${path}`
}
+13 -7
View File
@@ -3,14 +3,15 @@ import { computed, onMounted, ref } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useEventStore } from '../stores/event'
import type { SystemAbout } from '@/shared/api'
const data = useEventStore()
const SYSTEM_NAME = '运动会积分榜系统'
const SYSTEM_EN = 'Scoreboard'
// /api/health 只回 version,/api/system/about 也不带构建时间;前端构建期未注入该常量,
// 因此这里如实显示「未注入」,不再用假的 'dev' 冒充构建时间。
const BUILD_TIME = '未注入'
// 构建时间由服务端构建期注入,随 /api/system/about 一起取回;没注入(空串)或请求失败时
// 如实显示「未注入」,不用假的 'dev' 冒充。
const BUILD_TIME_UNKNOWN = '未注入'
const version = ref<string | null>(null)
const versionChecked = ref(false)
@@ -73,8 +74,12 @@ const port = computed(() => {
const origin = computed(() => window.location.origin)
// 内部署名由构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串
// 内部署名与构建时间都由服务端构建期注入,只有登录后(recorder+)才拿得到;未注入时为空串
const internalSignature = ref('')
const buildTime = ref('')
// 空串(未注入)/ 请求失败一律显示「未注入」,不留空白
const buildTimeText = computed(() => buildTime.value.trim() || BUILD_TIME_UNKNOWN)
onMounted(async () => {
// 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本
@@ -90,11 +95,12 @@ onMounted(async () => {
try {
const res = await fetch('/api/system/about', { credentials: 'include' })
if (res.ok) {
const body = (await res.json()) as { ok: boolean; data?: { internalSignature?: string } }
const body = (await res.json()) as { ok: boolean; data?: SystemAbout }
internalSignature.value = body.data?.internalSignature ?? ''
buildTime.value = body.data?.buildTime ?? ''
}
} catch {
// 未登录 / 接口不可达都按「没有署名」处理,不打扰用户
// 未登录 / 接口不可达都按「没有署名、没有构建时间」处理,不打扰用户
}
if (!data.loaded) void data.load()
@@ -132,7 +138,7 @@ onMounted(async () => {
</div>
<div>
<dt>构建时间</dt>
<dd class="mono">{{ BUILD_TIME }}</dd>
<dd class="mono">{{ buildTimeText }}</dd>
</div>
<div>
<dt>运行模式</dt>
+244 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -37,6 +37,115 @@ const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 显示器菜单:只有「打开服务端管理面板」那个 Electron 窗口里的 shell-preload 会注入
* window.desktopShell.displays,浏览器直接打开面板时没有这一组能力。
*/
interface DesktopDisplayInfo {
id: number
label: string
primary: boolean
width: number
height: number
scaleFactor: number
open: boolean
page: string | null
mode: 'kiosk' | 'windowed' | null
}
interface DesktopDisplayOpenResult {
ok: boolean
created: boolean
mode: 'kiosk' | 'windowed' | null
/** 请求全屏但被 Console 按规则降级时的原因。 */
message?: string
}
interface DesktopDisplaysApi {
list: () => Promise<{ single: boolean; displays: DesktopDisplayInfo[] }>
open: (displayId: number, options: { page: string; fullscreen: boolean }) => Promise<DesktopDisplayOpenResult>
close: (displayId: number) => Promise<{ ok: boolean; closed: boolean; message?: string }>
relayout: () => Promise<unknown>
}
// window.desktopShell 的全局声明里没有 displays 这一组,这里只在组件内部放宽,不动共享类型
const displaysApi =
typeof window === 'undefined'
? null
: ((window as unknown as { desktopShell?: { displays?: DesktopDisplaysApi } }).desktopShell?.displays ?? null)
const hasDisplayMenu = displaysApi !== null
/** 给新开的窗口锁定的页面,默认总积分榜。 */
const windowPage = ref<DisplayPage>('standings')
const displayList = ref<DesktopDisplayInfo[]>([])
const singleDisplay = ref(true)
const displayBusy = ref(false)
/** 全屏只放行「多屏 + 非主屏」,与 Console 主进程的判定保持一致。 */
function canFullscreen(item: DesktopDisplayInfo): boolean {
return !singleDisplay.value && !item.primary
}
async function loadDisplays(): Promise<void> {
if (!displaysApi) return
try {
const payload = await displaysApi.list()
displayList.value = payload.displays
// 单屏与否以 Console 的枚举结果为准,不看浏览器自己的 window.screen
singleDisplay.value = payload.single === true
} catch (caught) {
toast.error('读取显示器列表失败', caught instanceof Error ? caught.message : null)
}
}
async function runDisplayAction(label: string, action: () => Promise<void>): Promise<void> {
displayBusy.value = true
try {
await action()
} catch (caught) {
toast.error(`${label}失败`, caught instanceof Error ? caught.message : null)
} finally {
displayBusy.value = false
await loadDisplays()
}
}
async function openDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
if (!displaysApi) return
await runDisplayAction('打开大屏窗口', async () => {
const result = await displaysApi.open(item.id, { page: windowPage.value, fullscreen: canFullscreen(item) })
if (!result.ok) {
toast.error('打开大屏窗口失败', result.message ?? null)
return
}
// 一律以 Console 返回的实际模式为准:请求全屏也可能被降级成窗口化
const mode = result.mode === 'kiosk' ? '全屏窗口' : '窗口化窗口'
toast.success(`已${result.created ? '创建' : '聚焦'}「${item.label}」的${mode}`, result.message ?? null)
})
}
async function closeDisplayWindow(item: DesktopDisplayInfo): Promise<void> {
if (!displaysApi) return
await runDisplayAction('关闭大屏窗口', async () => {
const result = await displaysApi.close(item.id)
if (!result.ok) {
toast.error('关闭大屏窗口失败', result.message ?? null)
return
}
toast.success(result.closed ? `已关闭「${item.label}」上的大屏窗口` : `「${item.label}」上没有大屏窗口`)
})
}
async function relayoutDisplays(): Promise<void> {
if (!displaysApi) return
await runDisplayAction('重新排布大屏窗口', async () => {
await displaysApi.relayout()
toast.success('已请求按设置重建全部大屏窗口')
})
}
onMounted(loadDisplays)
/**
* 在线看板(占位)。
*
@@ -203,6 +312,89 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">显示器</span>
<span v-if="hasDisplayMenu" class="mx-badge mx-badge-neutral">{{ displayList.length }} 块屏</span>
</div>
<div class="mx-card-body">
<p v-if="!hasDisplayMenu" class="display-note">
此功能需要在服务管理窗口里点「打开服务端管理面板」打开的面板中使用。
</p>
<template v-else>
<label class="display-page-pick">
<span class="display-pick-label">窗口页面</span>
<span class="mx-input-wrap display-pick-select">
<select v-model="windowPage" class="mx-input">
<option v-for="page in PAGES" :key="page.key" :value="page.key">{{ page.label }}</option>
</select>
</span>
</label>
<p v-if="displayList.length === 0" class="display-note">没有读到显示器,请确认 Console(服务管理程序)正在本机运行。</p>
<div v-for="item in displayList" :key="item.id" class="display-row">
<div class="display-meta">
<div class="display-name">
{{ item.label }}
<span v-if="item.primary" class="mx-badge mx-badge-neutral">主显示器</span>
</div>
<div class="display-sub">
{{ item.width }} × {{ item.height }} · {{ item.scaleFactor }}x ·
{{ item.open ? `已开窗口(${item.mode === 'kiosk' ? '全屏' : '窗口化'})` : '未开窗口' }}
</div>
<div v-if="!item.open && !canFullscreen(item)" class="display-sub">单显示器 / 主显示器只能窗口化</div>
</div>
<div class="display-actions">
<button
v-if="item.open"
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="push" :size="15" /> 聚焦窗口
</button>
<button
v-else-if="canFullscreen(item)"
class="mx-btn mx-btn-primary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="display" :size="15" /> 创建全屏窗口
</button>
<button
v-else
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="openDisplayWindow(item)"
>
<MxIcon name="display" :size="15" /> 创建窗口(窗口化)
</button>
<button
v-if="item.open"
class="mx-btn mx-btn-secondary"
type="button"
:disabled="displayBusy"
@click="closeDisplayWindow(item)"
>
<MxIcon name="close" :size="15" /> 关闭窗口
</button>
</div>
</div>
<button class="mx-btn mx-btn-secondary full" type="button" :disabled="displayBusy" @click="relayoutDisplays">
<MxIcon name="refresh" :size="15" /> 重新排布(按设置重建全部窗口)
</button>
<p class="display-note">
全屏窗口只在「多块显示器 + 非主显示器」上开放:单显示器或主显示器上开全屏会把桌面整个盖住又没法退出,所以只能开窗口化窗口。副屏全屏窗口被误关后,点该屏的「创建全屏窗口」即可恢复;「重新排布」按 Console 配置的选屏策略重建全部窗口(不在策略内的窗口会被关掉)。
</p>
</template>
</div>
</section>
</div>
<section class="mx-card">
@@ -363,6 +555,57 @@ async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'):
.board-hint .mock {
color: var(--mx-amber);
}
.display-page-pick {
display: flex;
align-items: center;
gap: 10px;
}
.display-pick-label {
font-size: 12px;
color: var(--mx-t3);
white-space: nowrap;
}
.display-pick-select {
flex: 1;
height: 34px;
padding: 0 12px;
}
.display-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.display-meta {
min-width: 0;
flex: 1;
}
.display-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.display-sub {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.display-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.display-note {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
@media (max-width: 1023px) {
.display-grid {
grid-template-columns: 1fr;
+34 -14
View File
@@ -17,33 +17,42 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('表头含证件号 / 性别可选列,必填列不含这两列', () => {
test('表头含证件号 / 性别 / 年龄可选列,必填列不含这三列', () => {
assert.deepEqual(
[...REQUIRED_COLUMNS],
['event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'bib', 'seed', 'status', 'member_names'],
[
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
'bib', 'seed', 'status', 'member_names',
],
)
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender'])
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
})
test('buildSelectionRows 个人项目:一人一行,共 10 列并带上证件号', () => {
test('buildSelectionRows 个人项目:一人一行,共 11 列并带上证件号 / 号码布', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [
{ name: '张伟', idCard: '110101199001011234' },
{ name: '李强', idCard: null },
{ name: '张伟', idCard: '110101199001011234', bib: '101' },
{ name: '李强', idCard: null, bib: '102' },
{ name: '王芳', idCard: '110101199001011235', bib: null },
],
})
assert.equal(rows.length, 3, '表头 + 2 名运动员')
assert.equal(rows.length, 4, '表头 + 3 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
assert.equal(rows[1]?.length, 11)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', 'registered', ''])
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', 'registered', ''])
// 只有证件号时 bib 列留空。
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
@@ -55,8 +64,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
assert.equal(rows[1]?.length, 11)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
@@ -69,5 +78,16 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
})
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '无标识' }],
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
})
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { resolveAssetUrl } from '../components/system/imagePath'
// 服务端上传接口返回的路径已带 /uploads/ 前缀,这里主要保证手填的历史值也能加载。
test('resolveAssetUrl 原样保留已带前缀的路径与绝对地址', () => {
assert.equal(resolveAssetUrl('/uploads/a.png'), '/uploads/a.png')
assert.equal(resolveAssetUrl('https://cdn.example.com/a.png'), 'https://cdn.example.com/a.png')
assert.equal(resolveAssetUrl('HTTP://cdn.example.com/a.png'), 'HTTP://cdn.example.com/a.png')
assert.equal(resolveAssetUrl('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA')
})
test('resolveAssetUrl 给相对路径补上前导斜杠', () => {
assert.equal(resolveAssetUrl('uploads/a.png'), '/uploads/a.png')
assert.equal(resolveAssetUrl('logos/team1.png'), '/logos/team1.png')
assert.equal(resolveAssetUrl(' logos/team1.png '), '/logos/team1.png')
})
// 空值必须返回空串:调用方据此判断「不渲染缩略图」,而不是给出一个坏掉的 <img src>。
test('resolveAssetUrl 对空值返回空串', () => {
assert.equal(resolveAssetUrl(null), '')
assert.equal(resolveAssetUrl(undefined), '')
assert.equal(resolveAssetUrl(' '), '')
})
+268 -24
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ImportConflict } from '@/shared'
import type { AthleteInput, ImportConflict } from '@/shared'
import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
@@ -17,10 +17,11 @@ import { MockBaseData } from '../components/baseData/mockBaseData'
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
return {
row: 12,
bib: '',
athleteId: 42,
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: '1001' },
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
changedFields: ['teamCode'],
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
changedFields: ['age', 'teamCode', 'bib'],
...overrides,
}
}
@@ -29,32 +30,49 @@ function csv(rows: string[][]): File {
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
}
/** event_code / category / team_code / athlete_name / id_card / gender / bib / seed / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, '', '', 'registered', '']
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', 'registered', '']
}
test('conflictDiffs 展开两侧字段并标出差异字段', () => {
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
assert.deepEqual(
diffs.map((diff) => diff.field),
['name', 'gender', 'teamCode', 'employeeNo'],
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
)
assert.deepEqual(
diffs.map((diff) => diff.label),
['姓名', '性别', '单位', '工号 / 学号'],
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
)
assert.deepEqual(
diffs.map((diff) => diff.changed),
[false, false, true, false],
[false, false, true, true, false, true],
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
)
assert.equal(diffs[3]?.incoming, '—', 'null 展示为占位符')
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
assert.equal(diffs[2]?.incoming, '27')
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
assert.equal(diffs[5]?.existing, '101')
assert.equal(diffs[5]?.incoming, '102')
})
test('recordDecisions 只接受仍存在的证件号,且不改原 map', () => {
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
const diffs = conflictDiffs(
conflict({
idCard: '110101199001011234',
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
changedFields: [],
}),
)
const age = diffs.find((diff) => diff.field === 'age')
assert.deepEqual([age?.existing, age?.incoming, age?.changed], ['—', '—', false])
})
test('recordDecisions 只接受仍存在的决策键,且不改原 map', () => {
const conflicts = [conflict({ idCard: 'A' }), conflict({ idCard: 'B' })]
const current = recordDecisions({}, conflicts, ['A', '不存在的证件号'], 'keep-existing')
const current = recordDecisions({}, conflicts, ['A', '不存在的键'], 'keep-existing')
assert.deepEqual(current, { A: 'keep-existing' })
const next = recordDecisions(current, conflicts, ['B'], 'keep-new')
assert.deepEqual(next, { A: 'keep-existing', B: 'keep-new' })
@@ -63,20 +81,44 @@ test('recordDecisions 只接受仍存在的证件号,且不改原 map', () =>
test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收敛', () => {
const conflicts = [conflict({ idCard: 'B' }), conflict({ idCard: 'A' })]
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的证件号: 'keep-new' }
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的键: 'keep-new' }
assert.deepEqual(pruneDecisions(decisions, conflicts), { A: 'keep-new', B: 'keep-existing' })
assert.deepEqual(
decisionsPayload(decisions, conflicts),
[
{ idCard: 'B', action: 'keep-existing' },
{ idCard: 'A', action: 'keep-new' },
{ idCard: 'B', bib: '', action: 'keep-existing' },
{ idCard: 'A', bib: '', action: 'keep-new' },
],
'载荷顺序与冲突列表一致',
'载荷顺序与冲突列表一致,证件号与号码布都带上',
)
assert.deepEqual(summarizeDecisions(decisions, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
assert.deepEqual(summarizeDecisions({}, conflicts), { keepExisting: 0, keepNew: 0, undecided: 2 })
})
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
assert.deepEqual(first, { 101: 'keep-existing' })
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
const both = recordDecisions(first, conflicts, ['102'], 'keep-new')
assert.deepEqual(both, { 101: 'keep-existing', 102: 'keep-new' }, '两条号码布各留各的决策')
assert.deepEqual(
decisionsPayload(both, conflicts),
[
{ idCard: '', bib: '101', action: 'keep-existing' },
{ idCard: '', bib: '102', action: 'keep-new' },
],
'载荷两条都在,且分别带自己的号码布供服务端算决策键',
)
assert.deepEqual(summarizeDecisions(both, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
assert.deepEqual(pruneDecisions({ 101: 'keep-existing', 102: 'keep-new' }, [conflicts[1] as ImportConflict]), {
102: 'keep-new',
})
})
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
@@ -99,7 +141,7 @@ test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', as
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号/)
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
const duplicated = await mock.importEntries(
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
@@ -121,9 +163,10 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
assert.deepEqual(conflicted.conflicts[0], {
row: 2,
idCard,
bib: '',
athleteId,
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: null },
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
changedFields: ['teamCode'],
})
@@ -132,7 +175,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
assert.equal(again.conflicts.length, 1)
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, action: 'keep-existing' },
{ idCard, bib: '', action: 'keep-existing' },
])
assert.equal(keptExisting.conflicts.length, 0)
assert.equal(keptExisting.created, 1)
@@ -140,7 +183,7 @@ test('导入:同一证件号但单位不同记为冲突且不落库,决策
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, action: 'keep-new' },
{ idCard, bib: '', action: 'keep-new' },
])
assert.equal(keptNew.conflicts.length, 0)
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
@@ -161,7 +204,7 @@ test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async
},
})
const file = new File(['a'], 'entries.csv', { type: 'text/csv' })
const decisions = [{ idCard: '110101199001010001', action: 'keep-new' as const }]
const decisions = [{ idCard: '110101199001010001', bib: '101', action: 'keep-new' as const }]
const result = await client.entries.resolveImport(file, decisions)
assert.equal(captured.url, '/api/entries/import/resolve')
@@ -177,3 +220,204 @@ test('导入:gender 只认男 / 女,其它值忽略', async () => {
const athlete = mock.listAthletes().find((item) => item.idCard === '110101199001010005')
assert.equal(athlete?.gender, null)
})
test('导入:有姓名的行只填号码布也能建人员', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '8001')]))
assert.equal(result.created, 1)
assert.deepEqual(result.conflicts, [])
assert.equal(mock.listAthletes().length, before + 1)
const athlete = mock.listAthletes().find((item) => item.bib === '8001')
assert.equal(athlete?.name, '号布甲')
assert.equal(athlete?.idCard, null)
})
test('导入:号码布命中已有人员,不再新建', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
// 种子数据里 JG 的「张伟」号码布为 101。
const result = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '101')]))
assert.equal(result.created, 1)
assert.deepEqual(result.conflicts, [])
assert.equal(mock.listAthletes().length, before, '号码布命中已有人员,不新增')
})
test('导入:姓名 + 单位命中且证件号为空时回填证件号并保留已有号码布', async () => {
const mock = new MockBaseData()
// 种子数据里 F1 的「陈晨」证件号为空、号码布为 202。
const athlete = mock.listAthletes().find((item) => item.name === '陈晨')
assert.equal(athlete?.bib, '202')
const result = await mock.importEntries(csv([row('陈晨', '110101199001010006', 'F1', '女')]))
assert.equal(result.created, 1)
assert.equal(result.conflicts.length, 0)
const after = mock.listAthletes().find((item) => item.id === athlete?.id)
assert.equal(after?.idCard, '110101199001010006')
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
})
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
const idCard = '110101199001010007'
const mock = new MockBaseData()
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
assert.ok(athleteId !== undefined)
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.equal(conflict?.idCard, idCard)
assert.equal(conflict?.existing.bib, '101')
assert.equal(conflict?.incoming.bib, '999')
assert.deepEqual(conflict?.changedFields, ['bib'])
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-existing' },
])
assert.equal(keptExisting.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
{ idCard, bib: '999', action: 'keep-new' },
])
assert.equal(keptNew.created, 1)
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
})
test('导入:同一文件里号码布重复按行报错', async () => {
const mock = new MockBaseData()
const result = await mock.importEntries(
csv([row('号布甲', '', 'F1', '男', '8101'), row('号布乙', '', 'F2', '男', '8101')]),
)
assert.equal(result.created, 1, '第一行仍导入')
assert.equal(result.skipped, 1)
assert.match(result.errors[0] ?? '', /号码布 8101 在文件中重复出现/)
})
test('导入(mock):只有号码布的两条冲突各按自己的号码布决策', async () => {
const mock = new MockBaseData()
await mock.importEntries(csv([row('号布甲', '', 'F1', '男', '9101'), row('号布乙', '', 'F2', '男', '9102')]))
// 号码布命中同一人但单位对调 → 两条冲突,证件号都是空串。
const conflicted = await mock.importEntries(
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
)
assert.deepEqual(
conflicted.conflicts.map((item) => [item.idCard, item.bib]),
[
['', '9101'],
['', '9102'],
],
)
const resolved = await mock.resolveImportConflicts(
csv([row('号布甲', '', 'F2', '男', '9101'), row('号布乙', '', 'F1', '男', '9102')]),
[{ idCard: '', bib: '9101', action: 'keep-new' }],
)
assert.equal(resolved.created, 1, '只处理已决策的那条')
assert.deepEqual(
resolved.conflicts.map((item) => item.bib),
['9102'],
'没决策的号码布仍留在冲突里,没被另一条的决策带走',
)
const f2 = mock.listTeams().find((team) => team.code === 'F2')?.id
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '9101')?.teamId, f2)
})
test('导入:库里没年龄时表里的年龄只回填,不算冲突;库里已有年龄则不覆盖', async () => {
const idCard = '110101199001010009'
const mock = new MockBaseData()
await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, null)
// 旧数据没有年龄列:表里带年龄也不能把所有人标成冲突。
const filled = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男', '', '31')]))
assert.deepEqual(filled.conflicts, [], '库中年龄为空不算差异')
assert.equal(filled.created, 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '回填到库里')
// 库里已有年龄:表里留空不得清掉。
const kept = await mock.importEntries(csv([row('年龄新人', idCard, 'F1', '男')]))
assert.deepEqual(kept.conflicts, [])
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 31, '空值不清掉已有年龄')
})
test('导入:年龄两侧都有值且不同记为冲突,决策后按所选一侧处理', async () => {
const idCard = '110101199001010010'
const mock = new MockBaseData()
// 种子数据里 JG 的「张伟」号码布为 101、年龄为 22。
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22)
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '', '30')]))
assert.equal(conflicted.created, 0, '年龄冲突同样不落库')
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.deepEqual([conflict?.existing.age, conflict?.incoming.age], [22, 30])
assert.deepEqual(conflict?.changedFields, ['age'])
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
{ idCard, bib: '', action: 'keep-existing' },
])
assert.equal(keptExisting.created, 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 22, '保留已有年龄')
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '', '30')]), [
{ idCard, bib: '', action: 'keep-new' },
])
assert.equal(keptNew.created, 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === athleteId)?.age, 30, '使用新数据时年龄更新')
})
test('导入:年龄非整数 / 超出 0–120 的行报错并跳过', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
const bad = await mock.importEntries(
csv([
row('年龄非整数', '110101199001010011', 'F1', '男', '', '26.5'),
row('年龄超范围', '110101199001010012', 'F1', '男', '', '121'),
row('年龄非数字', '110101199001010013', 'F1', '男', '', '二十'),
]),
)
assert.equal(bad.skipped, 3)
assert.equal(bad.created, 0)
assert.equal(mock.listAthletes().length, before, '非法行不建人员')
for (const error of bad.errors) assert.match(error, /年龄必须是 0–120 的整数/)
// 团体 / 对抗行没有姓名,年龄不参与校验。
const team = await mock.importEntries(csv([row('', '', 'F1', '', '', '999')]))
assert.equal(team.created, 1)
assert.deepEqual(team.errors, [])
})
test('MockBaseData 新增 / 更新人员时校验年龄范围', () => {
const mock = new MockBaseData()
const base: AthleteInput = {
name: '年龄校验', gender: null, age: 26, teamId: 1, employeeNo: null, idCard: null, bib: null, active: true,
}
mock.createAthlete(base)
const id = mock.listAthletes().find((athlete) => athlete.name === '年龄校验')?.id
assert.ok(id !== undefined)
assert.throws(() => mock.createAthlete({ ...base, age: 121 }), /年龄必须是 0–120 的整数/)
assert.throws(() => mock.updateAthlete(id, { ...base, age: 26.5 }), /年龄必须是 0–120 的整数/)
assert.equal(mock.listAthletes().find((athlete) => athlete.id === id)?.age, 26, '校验失败不改动原数据')
assert.equal(mock.updateAthlete(id, { ...base, age: null }).age, null, '年龄可以清空')
})
test('MockBaseData 新增人员时证件号、号码布重复分别报错', () => {
const mock = new MockBaseData()
const base = { name: '唯一性测试', gender: null, age: null, teamId: 1, employeeNo: null, active: true } as const
mock.createAthlete({ ...base, idCard: '110101199001010008', bib: '9001' })
assert.throws(
() => mock.createAthlete({ ...base, idCard: '110101199001010008', bib: null }),
/证件号 .* 已被其他人员使用/,
)
assert.throws(
() => mock.createAthlete({ ...base, idCard: null, bib: '9001' }),
/号码布 .* 已被其他人员使用/,
)
})
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createApiClient } from '@/shared/api'
// 上传端点的契约由服务端定义(multipart 字段名 file,响应 {ok,data:{path,url}}),
// 这里钉住前端这一侧:字段名或路径写错时,图片会在真机上静默 400。
test('uploads.upload 以 multipart 字段 file POST /api/uploads 并返回路径', async () => {
const captured: { url?: string; method?: string; body?: FormData } = {}
const client = createApiClient({
baseUrl: '/api',
fetchImpl: async (input, init) => {
captured.url = String(input)
captured.method = init?.method
captured.body = init?.body as FormData
return new Response(JSON.stringify({ ok: true, data: { path: '/uploads/ab12.png', url: '/uploads/ab12.png' } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
},
})
const file = new File(['x'], 'team.png', { type: 'image/png' })
const result = await client.uploads.upload(file)
assert.equal(captured.url, '/api/uploads')
assert.equal(captured.method, 'POST')
assert.equal(captured.body?.get('file'), file)
assert.deepEqual(result, { path: '/uploads/ab12.png', url: '/uploads/ab12.png' })
})
test('uploads.upload 把服务端的错误文案原样抛出', async () => {
const client = createApiClient({
baseUrl: '/api',
fetchImpl: async () =>
new Response(
JSON.stringify({ ok: false, error: { code: 'VALIDATION_FAILED', message: '图片超过 5 MiB 上限' } }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
})
await assert.rejects(
() => client.uploads.upload(new File(['x'], 'big.png', { type: 'image/png' })),
(error: Error) => error.message === '图片超过 5 MiB 上限',
)
})
+2 -1
View File
@@ -7,9 +7,10 @@ import OfflineBadge from './OfflineBadge.vue'
const { settings, options } = useScoreboard()
const now = ref(new Date().toISOString())
// 右上角时钟精确到秒,所以每秒刷新一次;15 秒的旧间隔会让秒针跳格。
const timer = window.setInterval(() => {
now.value = new Date().toISOString()
}, 15_000)
}, 1_000)
onUnmounted(() => window.clearInterval(timer))
const display = computed(() => settings.value?.display ?? null)
+26
View File
@@ -144,6 +144,17 @@ export interface EntriesApi {
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
}
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
export interface UploadResult {
path: string
url: string
}
export interface UploadsApi {
/** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */
upload(file: File): Promise<UploadResult>
}
export interface ResultsApi {
list(query: ResultQuery): Promise<Page<Result>>
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
@@ -201,6 +212,12 @@ export interface SystemApi {
backup(): Promise<BackupResult>
}
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
export interface SystemAbout {
internalSignature?: string
buildTime?: string
}
interface Http {
request<T>(path: string, options?: RequestOptions): Promise<T>
requestVoid(path: string, options?: RequestOptions): Promise<void>
@@ -324,6 +341,7 @@ export class ApiClient {
readonly auditLogs: AuditApi
readonly rules: RulesApi
readonly system: SystemApi
readonly uploads: UploadsApi
private readonly baseUrl: string
private readonly credentials: RequestCredentials
@@ -429,6 +447,14 @@ export class ApiClient {
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
}
this.uploads = {
upload: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<UploadResult>('/uploads', { method: 'POST', body: form })
},
}
}
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
+2 -2
View File
@@ -102,8 +102,8 @@ export function formatDateTime(iso: string | null): string {
/** 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())}`
if (date === null) return '--:--:--'
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}:${DATE_PAD(date.getSeconds())}`
}
/** 带时区后缀(Z 或 ±HH:MM)的时间串按原样解析。 */
+1 -1
View File
@@ -24,5 +24,5 @@ test('formatSeconds 在 fractionDigits=0 时秒数仍补足两位', () => {
test('formatDateTime 对空值返回占位', () => {
assert.equal(formatDateTime(null), '—')
assert.equal(formatClock(null), '--:--')
assert.equal(formatClock(null), '--:--:--')
})
+14 -2
View File
@@ -27,8 +27,12 @@ export interface Athlete {
gender: Gender | null
teamId: number
employeeNo: string | null
/** 人员唯一标识(证件号);历史数据可能为空。 */
/** 人员唯一标识之一(证件号);与 bib 二者取其一即可,历史数据可能都为空。 */
idCard: string | null
/** 人员唯一标识之一(号码布);与 idCard 二者取其一即可。 */
bib: string | null
/** 年龄;可空,历史数据与团体报名都可能没有。 */
age: number | null
active: boolean
}
@@ -156,17 +160,23 @@ export interface EntryWithResult extends Entry {
export interface ImportConflictFields {
name: string
gender: Gender | null
/** 年龄;两侧都有值且不同才算差异,空值视为未指定。 */
age: number | null
teamCode: string
employeeNo: string | null
/** 人员级号码布;参与冲突差异展示。 */
bib: string | null
}
export type ImportConflictField = keyof ImportConflictFields
/** 同一证件号在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
/** 同一证件号 / 号码布在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
export interface ImportConflict {
/** 报名表原始行号(含表头,从 1 开始)。 */
row: number
idCard: string
/** 报名表该行的号码布;证件号为空时它就是这条冲突的决策键。 */
bib: string
athleteId: number
existing: ImportConflictFields
incoming: ImportConflictFields
@@ -176,8 +186,10 @@ export interface ImportConflict {
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
export type ConflictAction = 'keep-existing' | 'keep-new'
/** 决策键由服务端判定:证件号优先,为空时取号码布,因此两个字段都要带。 */
export interface ImportConflictDecision {
idCard: string
bib: string
action: ConflictAction
}