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:
1 parent
7531ea29e1
commit
c9d5b8ea89
6 files changed
+499
-77
No files matched your search
@@ -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', '刘洋/王芳'])
|
||||
})
|
||||
Reference in new issue
Block a user