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 报名导入
|
### 10.7 报名导入
|
||||||
|
|
||||||
|
报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。
|
||||||
|
|
||||||
|
**从已有名单勾选**
|
||||||
|
|
||||||
|
1. 依次选择小项、组别、单位;
|
||||||
|
2. 在运动员列表里按姓名 / 工号搜索并勾选:
|
||||||
|
- 个人项目:勾选的人各生成一条报名;已报名的运动员显示「已报名」且不可重复勾选;
|
||||||
|
- 团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;该单位已报名时再次提交会更新队员名单;
|
||||||
|
3. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量。
|
||||||
|
|
||||||
|
**表格导入**
|
||||||
|
|
||||||
1. 点「下载 CSV 模板」获取表头样例;
|
1. 点「下载 CSV 模板」获取表头样例;
|
||||||
2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感);
|
2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感);
|
||||||
3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
||||||
4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||||
|
|
||||||
|
号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 导出
|
## 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 { computed, ref } from 'vue'
|
||||||
import type { EntryImportResult } from '@/shared'
|
import type { EntryImportResult } from '@/shared'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
|
import EntryPicker from './EntryPicker.vue'
|
||||||
import { useBaseData } from './useBaseData'
|
import { useBaseData } from './useBaseData'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
import { REQUIRED_COLUMNS } from './entrySelection'
|
||||||
|
|
||||||
const data = useBaseData()
|
const data = useBaseData()
|
||||||
const toast = useToastStore()
|
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 = [
|
const TEMPLATE_CSV = [
|
||||||
REQUIRED_COLUMNS.join(','),
|
REQUIRED_COLUMNS.join(','),
|
||||||
'M100,男子组,F1,张伟,101,,registered,',
|
'M100,男子组,F1,张伟,101,,registered,',
|
||||||
@@ -97,87 +96,91 @@ function downloadTemplate(): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mx-card">
|
<div class="bd-stack">
|
||||||
<div class="mx-card-head">
|
<EntryPicker />
|
||||||
<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="mx-card-body">
|
<section class="mx-card">
|
||||||
<div class="bd-warn">
|
<div class="mx-card-head">
|
||||||
<MxIcon name="warning" :size="14" />
|
<span class="mx-card-title">报名导入</span>
|
||||||
<span>
|
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
|
||||||
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
|
<MxIcon name="export" :size="14" /> 下载 CSV 模板
|
||||||
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
|
</button>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="mx-card-body">
|
||||||
class="drop-zone"
|
<div class="bd-warn">
|
||||||
:class="{ dragging }"
|
<MxIcon name="warning" :size="14" />
|
||||||
@click="chooseFile"
|
<span>
|
||||||
@dragover.prevent="dragging = true"
|
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
|
||||||
@dragleave.prevent="dragging = false"
|
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
|
||||||
@drop.prevent="onDrop"
|
</span>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
|
<div
|
||||||
<MxIcon name="lock" :size="14" />
|
class="drop-zone"
|
||||||
当前不可写入(未登录或服务端未连接),导入按钮已停用。
|
:class="{ dragging }"
|
||||||
</div>
|
@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="picked !== null" class="picked">
|
||||||
|
<span class="bd-mono">{{ fileLabel }}</span>
|
||||||
<div v-if="result !== null" class="result">
|
<div class="bd-toolbar-right">
|
||||||
<div class="result-cards">
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
|
||||||
<div class="result-card">
|
<button
|
||||||
<span class="result-label">新增</span>
|
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||||
<strong class="bd-mono">{{ result.created }}</strong>
|
type="button"
|
||||||
</div>
|
:disabled="!data.canWrite.value || importing"
|
||||||
<div class="result-card">
|
@click="runImport"
|
||||||
<span class="result-label">更新</span>
|
>
|
||||||
<strong class="bd-mono">{{ result.updated }}</strong>
|
{{ importing ? '导入中…' : '开始导入' }}
|
||||||
</div>
|
</button>
|
||||||
<div class="result-card">
|
|
||||||
<span class="result-label">跳过</span>
|
|
||||||
<strong class="bd-mono">{{ result.skipped }}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="result.errors.length > 0" class="errors">
|
|
||||||
<div class="errors-head">
|
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
|
||||||
<span class="mx-badge mx-badge-warning">{{ result.errors.length }} 行有问题</span>
|
<MxIcon name="lock" :size="14" />
|
||||||
<span class="bd-note">这些行未导入,修正后可重新上传</span>
|
当前不可写入(未登录或服务端未连接),导入按钮已停用。
|
||||||
|
</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>
|
</div>
|
||||||
<ul class="error-list">
|
<div v-if="result.errors.length > 0" class="errors">
|
||||||
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
|
<div class="errors-head">
|
||||||
</ul>
|
<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>
|
</div>
|
||||||
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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,
|
Team,
|
||||||
TeamInput,
|
TeamInput,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
|
import { REQUIRED_COLUMNS } from './entrySelection'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础数据页的离线示例数据源。
|
* 基础数据页的离线示例数据源。
|
||||||
@@ -170,10 +171,8 @@ const ATHLETE_NAMES = [
|
|||||||
'杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓',
|
'杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓',
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 报名表模板列,与服务端 importColumns 保持一致(大小写不敏感)。 */
|
/** 报名表模板列,与导入通道共用同一份定义(大小写不敏感)。 */
|
||||||
const IMPORT_COLUMNS = [
|
const IMPORT_COLUMNS = REQUIRED_COLUMNS
|
||||||
'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names',
|
|
||||||
]
|
|
||||||
|
|
||||||
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
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