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
@@ -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>
|
||||
Reference in new issue
Block a user