需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 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 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
308 lines
11 KiB
Vue
308 lines
11 KiB
Vue
<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>
|