Files
Scoreboard/web/src/admin/components/baseData/AthletesSection.vue
T
AskaEth 24fff8dd08 feat: 人员唯一标识改为证件号 + 导入冲突逐条决策
需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据,
先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。

服务端:
- 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存)
- athleteDTO/CRUD 读写 idCard,重复返回 409
- 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号
- 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员
- 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建)
- 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new
- 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引

前端:
- 运动员表单与列表新增「证件号」,搜索支持证件号
- 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮
  + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见
- 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示
- 新增 import-conflicts.test.ts 等,前端测试 39/39

文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。

注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
2026-09-10 02:09:15 +08:00

308 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Athlete, AthleteInput, Gender } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
interface AthleteForm {
name: string
gender: '' | Gender
teamId: string
employeeNo: string
idCard: string
active: boolean
}
type AthleteField = keyof AthleteForm
const search = ref('')
const teamFilter = ref<string>('all')
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref<AthleteForm>(emptyForm())
const errors = ref<Partial<Record<AthleteField, string>>>({})
const formError = ref<string | null>(null)
const pending = ref(false)
const confirmOpen = ref(false)
const confirmTarget = ref<Athlete | null>(null)
const confirmPending = ref(false)
function emptyForm(): AthleteForm {
return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true }
}
const filtered = computed(() => {
const keyword = search.value.trim().toLowerCase()
return data.athletes.value.filter((athlete) => {
if (teamFilter.value !== 'all' && String(athlete.teamId) !== teamFilter.value) return false
if (statusFilter.value === 'active' && !athlete.active) return false
if (statusFilter.value === 'inactive' && athlete.active) return false
if (keyword === '') return true
const team = data.teamById(athlete.teamId)
return (
athlete.name.toLowerCase().includes(keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
(athlete.idCard ?? '').toLowerCase().includes(keyword) ||
(team?.name ?? '').toLowerCase().includes(keyword)
)
})
})
const activeCount = computed(() => data.athletes.value.filter((athlete) => athlete.active).length)
function openCreate(): void {
editingId.value = null
form.value = emptyForm()
form.value.teamId = data.teams.value[0] === undefined ? '' : String(data.teams.value[0].id)
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function openEdit(athlete: Athlete): void {
editingId.value = athlete.id
form.value = {
name: athlete.name,
gender: athlete.gender ?? '',
teamId: String(athlete.teamId),
employeeNo: athlete.employeeNo ?? '',
idCard: athlete.idCard ?? '',
active: athlete.active,
}
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function validate(): boolean {
const next: Partial<Record<AthleteField, string>> = {}
if (form.value.name.trim() === '') next.name = '姓名必填'
if (form.value.teamId === '' || data.teamById(Number(form.value.teamId)) === undefined) {
next.teamId = '必须选择一个参赛单位'
}
errors.value = next
return Object.keys(next).length === 0
}
async function submit(): Promise<void> {
if (!validate()) return
pending.value = true
formError.value = null
const input: AthleteInput = {
name: form.value.name.trim(),
gender: form.value.gender === '' ? null : form.value.gender,
teamId: Number(form.value.teamId),
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
idCard: form.value.idCard.trim() === '' ? null : form.value.idCard.trim(),
active: form.value.active,
}
try {
await data.saveAthlete(input, editingId.value)
toast.success(editingId.value === null ? '运动员已新增' : '运动员已保存')
dialogOpen.value = false
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
pending.value = false
}
}
function askToggle(athlete: Athlete): void {
if (athlete.active) {
confirmTarget.value = athlete
confirmOpen.value = true
return
}
void toggleActive(athlete)
}
async function toggleActive(athlete: Athlete): Promise<void> {
confirmPending.value = true
try {
await data.saveAthlete(
{
name: athlete.name,
gender: athlete.gender,
teamId: athlete.teamId,
employeeNo: athlete.employeeNo,
idCard: athlete.idCard,
active: !athlete.active,
},
athlete.id,
)
toast.success(athlete.active ? `已停用「${athlete.name}」` : `已启用「${athlete.name}」`)
confirmOpen.value = false
} catch (caught) {
toast.error('操作失败', caught instanceof Error ? caught.message : null)
} finally {
confirmPending.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">运动员</span>
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.athletes.value.length }} 启用</span>
</div>
<div class="mx-card-body">
<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>
<select v-model="teamFilter" class="mx-cell-input bd-select">
<option value="all">全部单位</option>
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">{{ team.name }}</option>
</select>
<select v-model="statusFilter" class="mx-cell-input bd-select">
<option value="all">全部状态</option>
<option value="active">仅启用</option>
<option value="inactive">仅停用</option>
</select>
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value || data.teams.value.length === 0" @click="openCreate">
<MxIcon name="entry" :size="15" /> 新增运动员
</button>
</div>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>姓名</th>
<th class="bd-cell-mini">性别</th>
<th>所属单位</th>
<th>工号 / 学号</th>
<th>证件号</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="data.loading.value && data.athletes.value.length === 0">
<td colspan="7">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
</div>
</td>
</tr>
<template v-else>
<tr v-for="athlete in filtered" :key="athlete.id">
<td>{{ athlete.name }}</td>
<td>{{ athlete.gender ?? '—' }}</td>
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
<td class="bd-mono">{{ athlete.idCard ?? '—' }}</td>
<td>
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ athlete.active ? '启用' : '已停用' }}
</span>
</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(athlete)">
编辑
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
:class="{ 'danger-text': athlete.active }"
type="button"
:disabled="!data.canWrite.value"
@click="askToggle(athlete)"
>
{{ athlete.active ? '停用' : '启用' }}
</button>
</div>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="7">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史报名与成绩保留,新报名不再可选该运动员。</p>
</div>
<FormDialog
:open="dialogOpen"
:title="editingId === null ? '新增运动员' : '编辑运动员'"
subtitle="个人项目的报名项会关联到运动员;团体项目可留空"
:pending="pending"
:error="formError"
@submit="submit"
@cancel="dialogOpen = false"
>
<div class="bd-form-grid">
<FormField label="姓名" required :error="errors.name">
<input v-model="form.name" class="mx-cell-input" placeholder="例如 张伟" />
</FormField>
<FormField label="性别">
<select v-model="form.gender" class="mx-cell-input">
<option value="">未设置</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</FormField>
<FormField label="所属单位" required :error="errors.teamId">
<select v-model="form.teamId" class="mx-cell-input">
<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>
</FormField>
<FormField label="工号 / 学号" hint="可留空,用于对账">
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
</FormField>
<FormField label="证件号" hint="人员唯一标识,建议必填">
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
</FormField>
<FormField label="状态" wide>
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
</FormField>
</div>
</FormDialog>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
title="停用该运动员?"
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该运动员;历史报名与成绩保留。`"
confirm-text="停用"
:pending="confirmPending"
@confirm="confirmTarget && toggleActive(confirmTarget)"
@cancel="confirmOpen = false"
/>
</section>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
</style>