feat(admin): 报名导入新增「从已有名单勾选报名」

原来只能上传 CSV/XLSX。现在同一个 tab 上方多一个选择器:选小项 → 组别 → 单位,
从该单位已有运动员里勾人,一键加入报名。

- 个人项目:勾 N 人 → N 条报名(一人一行)
- 团体/对抗项目:勾 N 人 → 1 条报名,姓名拼进 member_names(再次提交即更新队员名单)
- 已报名的人/单位会标「已报名」并禁止重复勾选
- 复用服务端唯一报名入口 POST /entries/import:勾选结果转成同格式 CSV 提交,
  字段校验、按 (组别,单位,运动员) upsert、行级报错回显与表格导入完全一致
- 纯函数 entrySelection.ts(表头唯一来源 + RFC 4180 转义)+ 4 个单测;
  表头定义从 ImportSection/mockBaseData 收敛到一处
- 号码布/种子仍走表格导入,界面与文档都写明

自检:vue-tsc + pnpm build 通过,前端 node --test 30/30。
This commit is contained in:
AskaEth committed 2026-09-10 02:03:16 +08:00
1 parent 7531ea29e1
commit c9d5b8ea89
6 files changed
+499 -77

No files matched your search

+14
View File
@@ -269,11 +269,25 @@
### 10.7 报名导入
报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。
**从已有名单勾选**
1. 依次选择小项、组别、单位;
2. 在运动员列表里按姓名 / 工号搜索并勾选:
- 个人项目:勾选的人各生成一条报名;已报名的运动员显示「已报名」且不可重复勾选;
- 团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;该单位已报名时再次提交会更新队员名单;
3. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量。
**表格导入**
1. 点「下载 CSV 模板」获取表头样例;
2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感);
3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。
---
## 11. 导出
@@ -0,0 +1,291 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Athlete, EntryWithResult } from '@/shared'
import MxIcon from '../MxIcon.vue'
import { adminApi } from '../../api/client'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import { buildSelectionRows, toCsv, type SelectionMode } from './entrySelection'
/**
* 从已有运动员名单里勾选报名。
*
* 勾选结果不走新接口:转成与导入表头一致的 CSV 后复用 /entries/import,
* 服务端的匹配、upsert 与行级校验只有一份实现。
*/
const data = useBaseData()
const toast = useToastStore()
const eventId = ref('')
const categoryId = ref('')
const teamId = ref('')
const search = ref('')
const checked = ref<number[]>([])
const entries = ref<EntryWithResult[]>([])
const entriesLoading = ref(false)
const submitting = ref(false)
const error = ref<string | null>(null)
const eventIdNum = computed(() => (eventId.value === '' ? null : Number(eventId.value)))
const currentEvent = computed(() => data.eventById(eventIdNum.value))
const mode = computed<SelectionMode>(() => (currentEvent.value?.eventType === 'individual' ? 'individual' : 'team'))
const categories = computed(() => data.categoriesOf(eventIdNum.value))
const currentCategory = computed(() => categories.value.find((category) => String(category.id) === categoryId.value))
const currentTeam = computed(() => data.teams.value.find((team) => String(team.id) === teamId.value))
/** 只列启用中的运动员:停用为软删除,新报名不再可选。 */
const teamAthletes = computed(() =>
data.athletes.value.filter((athlete) => athlete.active && String(athlete.teamId) === teamId.value),
)
const filteredAthletes = computed(() => {
const keyword = search.value.trim().toLowerCase()
if (keyword === '') return teamAthletes.value
return teamAthletes.value.filter(
(athlete) =>
athlete.name.toLowerCase().includes(keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword),
)
})
/** 勾选顺序可能与列表顺序不同,提交前统一按列表顺序取,团体队员名单才稳定。 */
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
/** 个人项目按 athleteId 判断已报名;团体项目按单位整体判断。 */
const registeredAthleteIds = computed(() => {
const ids = new Set<number>()
for (const entry of entries.value) {
if (entry.categoryId !== currentCategory.value?.id) continue
if (entry.teamId !== currentTeam.value?.id) continue
if (entry.athleteId !== null) ids.add(entry.athleteId)
}
return ids
})
const teamRegistered = computed(() =>
entries.value.some(
(entry) => entry.categoryId === currentCategory.value?.id && entry.teamId === currentTeam.value?.id,
),
)
const canSubmit = computed(
() =>
data.canWrite.value &&
currentEvent.value !== undefined &&
currentCategory.value !== undefined &&
currentTeam.value !== undefined &&
checkedAthletes.value.length > 0 &&
!submitting.value,
)
function isRegistered(athlete: Athlete): boolean {
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
}
async function loadEntries(id: number): Promise<void> {
entriesLoading.value = true
try {
entries.value = await adminApi.entries(id)
} catch (caught) {
entries.value = []
error.value = caught instanceof Error ? caught.message : '已报名列表加载失败'
} finally {
entriesLoading.value = false
}
}
async function syncEvent(id: string): Promise<void> {
checked.value = []
search.value = ''
categoryId.value = ''
entries.value = []
error.value = null
if (id === '') return
// loadEventDetail 自带缓存,重复选同一个小项不会二次请求。
await data.loadEventDetail(Number(id))
const first = data.categoriesOf(Number(id))[0]
categoryId.value = first === undefined ? '' : String(first.id)
await loadEntries(Number(id))
}
watch(eventId, (id) => {
void syncEvent(id)
})
// 换组别 / 换单位后勾选不再适用,清空避免把上一组的名单带过来。
watch([categoryId, teamId], () => {
checked.value = []
})
async function submit(): Promise<void> {
const event = currentEvent.value
const category = currentCategory.value
const team = currentTeam.value
const chosen = checkedAthletes.value
if (event === undefined || category === undefined || team === undefined || chosen.length === 0) return
submitting.value = true
error.value = null
try {
const rows = buildSelectionRows({
eventCode: event.code,
categoryName: category.name,
teamCode: team.code,
mode: mode.value,
athletes: chosen.map((athlete) => ({ name: athlete.name })),
})
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
const result = await data.importEntries(file)
toast.success('报名已加入', `新增 ${result.created} · 更新 ${result.updated} · 跳过 ${result.skipped}`)
if (result.errors.length > 0) error.value = result.errors.join(';')
checked.value = []
await loadEntries(event.id)
} catch (caught) {
const message = caught instanceof Error ? caught.message : '加入报名失败'
error.value = message
toast.error('加入报名失败', message)
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">从已有名单勾选报名</span>
<span v-if="mode === 'team' && teamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
<span v-else-if="entriesLoading" class="mx-badge mx-badge-neutral">读取已报名…</span>
</div>
<div class="mx-card-body">
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<select v-model="eventId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value">
<option value="">选择小项</option>
<option v-for="event in data.events.value" :key="event.id" :value="String(event.id)">
{{ event.name }}
</option>
</select>
<select
v-model="categoryId"
class="mx-cell-input bd-select"
:disabled="!data.canWrite.value || eventId === '' || categories.length === 0"
>
<option value="" disabled>选择组别</option>
<option v-for="category in categories" :key="category.id" :value="String(category.id)">
{{ category.name }}
</option>
</select>
<select v-model="teamId" class="mx-cell-input bd-select" :disabled="!data.canWrite.value || eventId === ''">
<option value="" disabled>选择单位</option>
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
</option>
</select>
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSubmit" @click="submit">
<MxIcon name="entry" :size="15" /> {{ submitting ? '加入中…' : '加入报名' }}
</button>
</div>
</div>
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
<MxIcon name="lock" :size="14" />
当前不可写入(未登录或服务端未连接),勾选与提交已停用。
</div>
<p v-else-if="eventId === ''" class="bd-note">先选择小项、组别和单位,再勾选运动员。</p>
<p v-else-if="categories.length === 0" class="bd-note">该小项还没有组别,请先在「组别」分区配置后再报名。</p>
<p v-else-if="teamId === ''" class="bd-note">再选择一个单位,即可看到该单位的运动员。</p>
<template v-else>
<div class="bd-toolbar">
<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="搜索姓名 / 工号" />
</div>
<span class="bd-note">已勾选 {{ checkedAthletes.length }} 人</span>
</div>
</div>
<div class="picker-list">
<label
v-for="athlete in filteredAthletes"
:key="athlete.id"
class="bd-check picker-row"
:class="{ 'is-registered': isRegistered(athlete) }"
>
<input
v-model="checked"
type="checkbox"
:value="athlete.id"
:disabled="!data.canWrite.value || isRegistered(athlete)"
/>
<span class="picker-name">{{ athlete.name }}</span>
<span class="bd-note">{{ athlete.employeeNo ?? '—' }}</span>
<span v-if="isRegistered(athlete)" class="mx-badge mx-badge-neutral">已报名</span>
</label>
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
</div>
</div>
<p class="bd-note">
<template v-if="mode === 'individual'">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</template>
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
</template>
</div>
</section>
</template>
<style scoped>
.picker-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
max-height: 320px;
overflow-y: auto;
margin-bottom: 12px;
}
.picker-row {
justify-content: flex-start;
gap: 8px;
padding: 8px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.picker-row.is-registered {
opacity: 0.6;
cursor: not-allowed;
}
.picker-name {
font-size: 13px;
color: var(--mx-text);
}
.picker-row .bd-note {
margin-left: auto;
}
.picker-empty {
padding: 12px 0;
}
.readonly-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
color: var(--mx-amber);
}
.picker-error {
display: block;
margin-top: 12px;
padding: 10px 12px;
line-height: 1.5;
}
</style>
@@ -2,15 +2,14 @@
import { computed, ref } from 'vue'
import type { EntryImportResult } from '@/shared'
import MxIcon from '../MxIcon.vue'
import EntryPicker from './EntryPicker.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import { REQUIRED_COLUMNS } from './entrySelection'
const data = useBaseData()
const toast = useToastStore()
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
const REQUIRED_COLUMNS = ['event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names']
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,101,,registered,',
@@ -97,87 +96,91 @@ function downloadTemplate(): void {
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">报名导入</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
<MxIcon name="export" :size="14" /> 下载 CSV 模板
</button>
</div>
<div class="bd-stack">
<EntryPicker />
<div class="mx-card-body">
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
</span>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">报名导入</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
<MxIcon name="export" :size="14" /> 下载 CSV 模板
</button>
</div>
<div
class="drop-zone"
:class="{ dragging }"
@click="chooseFile"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<MxIcon name="export" :size="26" />
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
<div class="bd-note">单个文件不超过 16MB;xlsx 由服务端解析第一个工作表</div>
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
</div>
<div v-if="picked !== null" class="picked">
<span class="bd-mono">{{ fileLabel }}</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || importing"
@click="runImport"
>
{{ importing ? '导入中…' : '开始导入' }}
</button>
<div class="mx-card-body">
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
</span>
</div>
</div>
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
<MxIcon name="lock" :size="14" />
当前不可写入(未登录或服务端未连接),导入按钮已停用。
</div>
<div
class="drop-zone"
:class="{ dragging }"
@click="chooseFile"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<MxIcon name="export" :size="26" />
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
<div class="bd-note">单个文件不超过 16MB;xlsx 由服务端解析第一个工作表</div>
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
</div>
<div v-if="error !== null" class="mx-badge mx-badge-error import-error">{{ error }}</div>
<div v-if="result !== null" class="result">
<div class="result-cards">
<div class="result-card">
<span class="result-label">新增</span>
<strong class="bd-mono">{{ result.created }}</strong>
</div>
<div class="result-card">
<span class="result-label">更新</span>
<strong class="bd-mono">{{ result.updated }}</strong>
</div>
<div class="result-card">
<span class="result-label">跳过</span>
<strong class="bd-mono">{{ result.skipped }}</strong>
<div v-if="picked !== null" class="picked">
<span class="bd-mono">{{ fileLabel }}</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || importing"
@click="runImport"
>
{{ importing ? '导入中…' : '开始导入' }}
</button>
</div>
</div>
<div v-if="result.errors.length > 0" class="errors">
<div class="errors-head">
<span class="mx-badge mx-badge-warning">{{ result.errors.length }} 行有问题</span>
<span class="bd-note">这些行未导入,修正后可重新上传</span>
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
<MxIcon name="lock" :size="14" />
当前不可写入(未登录或服务端未连接),导入按钮已停用。
</div>
<div v-if="error !== null" class="mx-badge mx-badge-error import-error">{{ error }}</div>
<div v-if="result !== null" class="result">
<div class="result-cards">
<div class="result-card">
<span class="result-label">新增</span>
<strong class="bd-mono">{{ result.created }}</strong>
</div>
<div class="result-card">
<span class="result-label">更新</span>
<strong class="bd-mono">{{ result.updated }}</strong>
</div>
<div class="result-card">
<span class="result-label">跳过</span>
<strong class="bd-mono">{{ result.skipped }}</strong>
</div>
</div>
<ul class="error-list">
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
</ul>
<div v-if="result.errors.length > 0" class="errors">
<div class="errors-head">
<span class="mx-badge mx-badge-warning">{{ result.errors.length }} 行有问题</span>
<span class="bd-note">这些行未导入,修正后可重新上传</span>
</div>
<ul class="error-list">
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
</ul>
</div>
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
</div>
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
</div>
</div>
</section>
</section>
</div>
</template>
<style scoped>
@@ -0,0 +1,62 @@
/**
* 「从已有名单勾选报名」的纯函数部分。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
*/
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
export const REQUIRED_COLUMNS = [
'event_code',
'category',
'team_code',
'athlete_name',
'bib',
'seed',
'status',
'member_names',
] as const
export type SelectionMode = 'individual' | 'team'
export interface SelectionInput {
eventCode: string
categoryName: string
teamCode: string
status?: string
mode: SelectionMode
athletes: { name: string }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
memberNames?: string[]
}
/**
* 生成与导入表头一致的二维数组。
*
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names。
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。
*/
export function buildSelectionRows(input: SelectionInput): string[][] {
const status = input.status ?? 'registered'
const rows: string[][] = [[...REQUIRED_COLUMNS]]
if (input.mode === 'individual') {
for (const athlete of input.athletes) {
rows.push([input.eventCode, input.categoryName, input.teamCode, athlete.name, '', '', status, ''])
}
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', status, names.join('/')])
return rows
}
/** 转成 CSV 文本;服务端用标准 csv 解析器读取,转义必须符合 RFC 4180。 */
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(escapeField).join(',')).join('\n')
}
/** 字段含逗号 / 引号 / 换行时用双引号包裹,内部引号翻倍。 */
function escapeField(field: string): string {
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
}
@@ -15,6 +15,7 @@ import type {
Team,
TeamInput,
} from '@/shared'
import { REQUIRED_COLUMNS } from './entrySelection'
/**
* 基础数据页的离线示例数据源。
@@ -170,10 +171,8 @@ const ATHLETE_NAMES = [
'杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓',
]
/** 报名表模板列,与服务端 importColumns 保持一致(大小写不敏感)。 */
const IMPORT_COLUMNS = [
'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names',
]
/** 报名表模板列,与导入通道共用同一份定义(大小写不敏感)。 */
const IMPORT_COLUMNS = REQUIRED_COLUMNS
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
@@ -0,0 +1,53 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { REQUIRED_COLUMNS, buildSelectionRows, toCsv } from '../components/baseData/entrySelection'
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a', 'b']]), 'a,b')
assert.equal(toCsv([['张,伟', 'x']]), '"张,伟",x')
assert.equal(toCsv([['张"伟', 'x']]), '"张""伟",x')
assert.equal(toCsv([['第一行\n第二行', 'x']]), '"第一行\n第二行",x')
// 同一格同时含逗号与引号:包裹一次,内部引号翻倍。
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('buildSelectionRows 个人项目:一人一行,共 8 列', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '张伟' }, { name: '李强' }],
})
assert.equal(rows.length, 3, '表头 + 2 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '', '', 'registered', ''])
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
status: 'scratched',
mode: 'team',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'scratched', '刘洋/王芳'])
})