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 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
This commit is contained in:
1 parent
c9d5b8ea89
commit
24fff8dd08
21 files changed
+1590
-67
No files matched your search
@@ -16,6 +16,7 @@ interface AthleteForm {
|
||||
gender: '' | Gender
|
||||
teamId: string
|
||||
employeeNo: string
|
||||
idCard: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
@@ -37,7 +38,7 @@ const confirmTarget = ref<Athlete | null>(null)
|
||||
const confirmPending = ref(false)
|
||||
|
||||
function emptyForm(): AthleteForm {
|
||||
return { name: '', gender: '', teamId: '', employeeNo: '', active: true }
|
||||
return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true }
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
@@ -51,6 +52,7 @@ const filtered = computed(() => {
|
||||
return (
|
||||
athlete.name.toLowerCase().includes(keyword) ||
|
||||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
|
||||
(athlete.idCard ?? '').toLowerCase().includes(keyword) ||
|
||||
(team?.name ?? '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
@@ -74,6 +76,7 @@ function openEdit(athlete: Athlete): void {
|
||||
gender: athlete.gender ?? '',
|
||||
teamId: String(athlete.teamId),
|
||||
employeeNo: athlete.employeeNo ?? '',
|
||||
idCard: athlete.idCard ?? '',
|
||||
active: athlete.active,
|
||||
}
|
||||
errors.value = {}
|
||||
@@ -100,6 +103,7 @@ async function submit(): Promise<void> {
|
||||
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 {
|
||||
@@ -131,6 +135,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
gender: athlete.gender,
|
||||
teamId: athlete.teamId,
|
||||
employeeNo: athlete.employeeNo,
|
||||
idCard: athlete.idCard,
|
||||
active: !athlete.active,
|
||||
},
|
||||
athlete.id,
|
||||
@@ -157,7 +162,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<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="搜索姓名 / 工号" />
|
||||
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号 / 证件号" />
|
||||
</div>
|
||||
<select v-model="teamFilter" class="mx-cell-input bd-select">
|
||||
<option value="all">全部单位</option>
|
||||
@@ -184,13 +189,14 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<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="6">
|
||||
<td colspan="7">
|
||||
<div class="mx-empty bd-empty">
|
||||
<span class="mx-spinner" />
|
||||
<div class="mx-empty-title">加载中…</div>
|
||||
@@ -203,6 +209,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<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 ? '启用' : '已停用' }}
|
||||
@@ -226,7 +233,7 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="6">
|
||||
<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>
|
||||
@@ -271,6 +278,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user