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

@@ -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>