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:
AskaEth committed 2026-09-10 02:09:15 +08:00
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>
@@ -83,6 +83,11 @@ function isRegistered(athlete: Athlete): boolean {
return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id)
}
/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */
function missingIdCard(athlete: Athlete): boolean {
return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '')
}
async function loadEntries(id: number): Promise<void> {
entriesLoading.value = true
try {
@@ -133,7 +138,7 @@ async function submit(): Promise<void> {
categoryName: category.name,
teamCode: team.code,
mode: mode.value,
athletes: chosen.map((athlete) => ({ name: athlete.name })),
athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })),
})
const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' })
const result = await data.importEntries(file)
@@ -222,11 +227,12 @@ async function submit(): Promise<void> {
v-model="checked"
type="checkbox"
:value="athlete.id"
:disabled="!data.canWrite.value || isRegistered(athlete)"
:disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(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>
<span v-else-if="missingIdCard(athlete)" class="mx-badge mx-badge-warning">缺证件号</span>
</label>
<div v-if="filteredAthletes.length === 0" class="bd-note picker-empty">
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }}
@@ -238,6 +244,7 @@ async function submit(): Promise<void> {
<template v-else>团体 / 对抗项目:勾选的人合成一条报名,队员名单按列表顺序写入;已报名时再次提交会更新队员名单。</template>
</p>
<p class="bd-note">号码布、种子等字段仍通过下方报名表导入填写。</p>
<p v-if="mode === 'individual'" class="bd-note">证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。</p>
<div v-if="error !== null" class="mx-badge mx-badge-error picker-error">{{ error }}</div>
</template>
@@ -1,19 +1,27 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { EntryImportResult } from '@/shared'
import type { ConflictAction, EntryImportResult, ImportConflict } from '@/shared'
import MxIcon from '../MxIcon.vue'
import EntryPicker from './EntryPicker.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import { REQUIRED_COLUMNS } from './entrySelection'
import {
REQUIRED_COLUMNS,
conflictDiffs,
decisionsPayload,
pruneDecisions,
recordDecisions,
summarizeDecisions,
type DecisionMap,
} from './entrySelection'
const data = useBaseData()
const toast = useToastStore()
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,101,,registered,',
'BB,混合组,F2,,,种子1,registered,第一分公司代表队',
'M100,男子组,F1,张伟,110101199001011234,男,101,,registered,',
'BB,混合组,F2,,,,,种子1,registered,第一分公司代表队',
].join('\n')
const MAX_SIZE = 16 * 1024 * 1024
@@ -25,6 +33,19 @@ const importing = ref(false)
const result = ref<EntryImportResult | null>(null)
const error = ref<string | null>(null)
/** 冲突决策面板的暂存状态:证件号 → 保留哪一侧,点「应用决策」才提交。 */
const decisions = ref<DecisionMap>({})
const selected = ref<string[]>([])
const resolving = ref(false)
const resolveError = ref<string | null>(null)
const conflicts = computed<ImportConflict[]>(() => result.value?.conflicts ?? [])
const summary = computed(() => summarizeDecisions(decisions.value, conflicts.value))
const pendingCount = computed(() => summary.value.keepExisting + summary.value.keepNew)
const canResolve = computed(
() => data.canWrite.value && pendingCount.value > 0 && !resolving.value && picked.value !== null,
)
const fileLabel = computed(() => (picked.value === null ? '' : `${picked.value.name} · ${formatSize(picked.value.size)}`))
function formatSize(bytes: number): string {
@@ -50,6 +71,59 @@ function acceptFile(file: File): void {
picked.value = file
result.value = null
error.value = null
resetDecisions()
}
function resetDecisions(): void {
decisions.value = {}
selected.value = []
resolveError.value = null
}
function clearFile(): void {
picked.value = null
result.value = null
error.value = null
resetDecisions()
}
function selectAll(): void {
selected.value = conflicts.value.map((conflict) => conflict.idCard)
}
function clearSelection(): void {
selected.value = []
}
/** 批量决策只作用于勾选行;逐行按钮则直接改单条。 */
function decideSelected(action: ConflictAction): void {
decisions.value = recordDecisions(decisions.value, conflicts.value, selected.value, action)
}
function decideOne(conflict: ImportConflict, action: ConflictAction): void {
decisions.value = recordDecisions(decisions.value, conflicts.value, [conflict.idCard], action)
}
async function applyDecisions(): Promise<void> {
const file = picked.value
if (file === null) return
const payload = decisionsPayload(decisions.value, conflicts.value)
if (payload.length === 0) return
resolving.value = true
resolveError.value = null
try {
const next = await data.resolveImportConflicts(file, payload)
result.value = next
// 已决策的人不再出现在剩余冲突里,同步清掉暂存与勾选,避免串到下一轮。
decisions.value = pruneDecisions(decisions.value, next.conflicts)
const remaining = new Set(next.conflicts.map((conflict) => conflict.idCard))
selected.value = selected.value.filter((idCard) => remaining.has(idCard))
toast.success('冲突决策已应用', `新增 ${next.created} · 更新 ${next.updated} · 跳过 ${next.skipped}`)
} catch (caught) {
resolveError.value = caught instanceof Error ? caught.message : '应用决策失败'
} finally {
resolving.value = false
}
}
function onPick(event: Event): void {
@@ -72,6 +146,7 @@ async function runImport(): Promise<void> {
importing.value = true
error.value = null
result.value = null
resetDecisions()
try {
result.value = await data.importEntries(picked.value)
toast.success('导入完成', `新增 ${result.value.created} · 更新 ${result.value.updated} · 跳过 ${result.value.skipped}`)
@@ -111,8 +186,9 @@ function downloadTemplate(): void {
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender 可缺省,
但有姓名的行必须填证件号,否则该行会报错并跳过。证件号是人员唯一标识:先按证件号匹配,
未命中再按「姓名 + 单位」匹配库里证件号为空的人并回填。
</span>
</div>
@@ -133,7 +209,7 @@ function downloadTemplate(): void {
<div v-if="picked !== null" class="picked">
<span class="bd-mono">{{ fileLabel }}</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="clearFile">移除</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
@@ -176,7 +252,102 @@ function downloadTemplate(): void {
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
</ul>
</div>
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
<section v-if="conflicts.length > 0" class="conflicts">
<div class="conflicts-head">
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
<span class="bd-note">
同一证件号在库里与报名表里的姓名 / 性别 / 单位不一致,本轮未落库,也不建报名。
</span>
</div>
<p class="bd-note">逐条选择保留哪一侧;未决策的冲突会一直留在列表里,不会静默丢弃。</p>
<div class="bd-toolbar conflict-toolbar">
<div class="bd-toolbar-left">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="selectAll">全选</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="selected.length === 0" @click="clearSelection">
清空
</button>
<span class="bd-note">已选 {{ selected.length }} / {{ conflicts.length }}</span>
</div>
<div class="bd-toolbar-right">
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="selected.length === 0"
@click="decideSelected('keep-existing')"
>
保留已有数据
</button>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="selected.length === 0"
@click="decideSelected('keep-new')"
>
使用新数据
</button>
</div>
</div>
<div class="conflict-list">
<div
v-for="conflict in conflicts"
:key="conflict.idCard"
class="conflict-item"
:class="{ decided: decisions[conflict.idCard] !== undefined }"
>
<div class="conflict-head">
<label class="bd-check conflict-check">
<input v-model="selected" type="checkbox" :value="conflict.idCard" />
<span class="conflict-id bd-mono">{{ conflict.idCard }}</span>
<span class="conflict-name">{{ conflict.existing.name }}</span>
<span class="bd-note">第 {{ conflict.row }} 行</span>
</label>
<span
v-if="decisions[conflict.idCard] !== undefined"
class="mx-badge"
:class="decisions[conflict.idCard] === 'keep-existing' ? 'mx-badge-neutral' : 'mx-badge-success'"
>
{{ decisions[conflict.idCard] === 'keep-existing' ? '保留已有数据' : '使用新数据' }}
</span>
<div class="bd-actions conflict-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-existing')">
保留已有
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="decideOne(conflict, 'keep-new')">
使用新数据
</button>
</div>
</div>
<div class="conflict-diffs">
<div
v-for="diff in conflictDiffs(conflict)"
:key="diff.field"
class="conflict-diff"
:class="{ changed: diff.changed }"
>
<span class="conflict-field">{{ diff.label }}</span>
<span class="conflict-value">{{ diff.existing }}</span>
<span class="conflict-arrow">→</span>
<span class="conflict-value">{{ diff.incoming }}</span>
</div>
</div>
</div>
</div>
<div class="conflict-foot">
<span class="bd-note">
已决策 {{ pendingCount }} 条(保留已有 {{ summary.keepExisting }} · 使用新数据 {{ summary.keepNew }}),
未决策 {{ summary.undecided }} 条
</span>
<button class="mx-btn mx-btn-primary mx-btn-sm" type="button" :disabled="!canResolve" @click="applyDecisions">
{{ resolving ? '应用中…' : '应用决策' }}
</button>
</div>
<div v-if="resolveError !== null" class="mx-badge mx-badge-error import-error">{{ resolveError }}</div>
</section>
</div>
</div>
</section>
@@ -281,9 +452,104 @@ function downloadTemplate(): void {
color: var(--mx-t2);
font-family: var(--mx-mono);
}
.conflicts {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed var(--mx-divider);
}
.conflicts-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.conflict-toolbar {
margin: 12px 0 10px;
}
.conflict-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
overflow-y: auto;
padding-right: 2px;
}
.conflict-item {
padding: 10px 12px;
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
}
.conflict-item.decided {
border-color: var(--mx-primary);
background: var(--mx-primary-glow);
}
.conflict-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.conflict-check {
gap: 10px;
}
.conflict-id {
color: var(--mx-t2);
}
.conflict-name {
font-size: 13px;
font-weight: 600;
color: var(--mx-text);
}
.conflict-head .conflict-actions {
margin-left: auto;
}
.conflict-diffs {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 8px;
}
.conflict-diff {
display: grid;
grid-template-columns: 84px minmax(0, 1fr) 20px minmax(0, 1fr);
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mx-t3);
}
.conflict-field {
color: var(--mx-t3);
}
.conflict-value {
color: var(--mx-t2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conflict-diff.changed .conflict-value {
color: var(--mx-amber);
font-weight: 600;
}
.conflict-arrow {
text-align: center;
color: var(--mx-t3);
}
.conflict-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
}
@media (max-width: 767px) {
.result-cards {
grid-template-columns: 1fr;
}
.conflict-diff {
grid-template-columns: 64px minmax(0, 1fr) 16px minmax(0, 1fr);
}
}
</style>
@@ -8,6 +8,7 @@ import type {
EventCategory,
EventCategoryInput,
EventInput,
ImportConflictDecision,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
@@ -128,5 +129,10 @@ export const baseDataApi = {
() => adminApi.client.entries.importFile(file),
() => mockBaseData.importEntries(file),
),
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> =>
call(
() => adminApi.client.entries.resolveImport(file, decisions),
() => mockBaseData.resolveImportConflicts(file, decisions),
),
},
}
@@ -1,23 +1,41 @@
/**
* 「从已有名单勾选报名」的纯函数部分。
* 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
*/
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
import type {
ConflictAction,
ImportConflict,
ImportConflictDecision,
ImportConflictField,
} from '@/shared'
/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */
export const REQUIRED_COLUMNS = [
'event_code',
'category',
'team_code',
'athlete_name',
'id_card',
'gender',
'bib',
'seed',
'status',
'member_names',
] as const
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
(column) => !OPTIONAL_SET.includes(column),
)
export type SelectionMode = 'individual' | 'team'
export interface SelectionInput {
@@ -26,7 +44,8 @@ export interface SelectionInput {
teamCode: string
status?: string
mode: SelectionMode
athletes: { name: string }[]
/** 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号才能命中证件号优先的匹配规则。 */
athletes: { name: string; idCard?: string | null }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
memberNames?: string[]
}
@@ -42,12 +61,23 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
const rows: string[][] = [[...REQUIRED_COLUMNS]]
if (input.mode === 'individual') {
for (const athlete of input.athletes) {
rows.push([input.eventCode, input.categoryName, input.teamCode, athlete.name, '', '', status, ''])
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
athlete.name,
athlete.idCard ?? '',
'',
'',
'',
status,
'',
])
}
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', status, names.join('/')])
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', '', '', status, names.join('/')])
return rows
}
@@ -60,3 +90,91 @@ export function toCsv(rows: string[][]): string {
function escapeField(field: string): string {
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
}
// ── 导入冲突:差异计算与决策 ────────────────────────────────────────────────
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
name: '姓名',
gender: '性别',
teamCode: '单位',
employeeNo: '工号 / 学号',
}
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
export interface ConflictFieldDiff {
field: ImportConflictField
label: string
existing: string
incoming: string
changed: boolean
}
/** 逐字段展开 existing / incoming;顺序固定,界面与测试都不依赖对象键序。 */
export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
return (Object.keys(CONFLICT_FIELD_LABELS) as ImportConflictField[]).map((field) => ({
field,
label: CONFLICT_FIELD_LABELS[field],
existing: formatFieldValue(conflict.existing[field]),
incoming: formatFieldValue(conflict.incoming[field]),
changed: conflict.changedFields.includes(field),
}))
}
function formatFieldValue(value: string | null): string {
return value === null || value === '' ? '—' : value
}
/** 证件号 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
export type DecisionMap = Record<string, ConflictAction>
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏证件号混进载荷。 */
export function recordDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
idCards: readonly string[],
action: ConflictAction,
): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
const next: DecisionMap = { ...current }
for (const idCard of idCards) {
if (known.has(idCard)) next[idCard] = action
}
return next
}
/** 丢弃已不存在的证件号,换文件或 resolve 之后调用。 */
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
const known = new Set(conflicts.map((conflict) => conflict.idCard))
return Object.fromEntries(Object.entries(current).filter(([idCard]) => known.has(idCard)))
}
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
export function decisionsPayload(
current: DecisionMap,
conflicts: readonly ImportConflict[],
): ImportConflictDecision[] {
return conflicts
.filter((conflict) => current[conflict.idCard] !== undefined)
.map((conflict) => ({ idCard: conflict.idCard, action: current[conflict.idCard] as ConflictAction }))
}
export interface DecisionSummary {
keepExisting: number
keepNew: number
undecided: number
}
/** 面板工具条上的计数:已决策分组 + 未决策数,未决策的冲突必须保持可见。 */
export function summarizeDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
): DecisionSummary {
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
for (const conflict of conflicts) {
const action = current[conflict.idCard]
if (action === 'keep-existing') summary.keepExisting += 1
else if (action === 'keep-new') summary.keepNew += 1
else summary.undecided += 1
}
return summary
}
@@ -1,11 +1,15 @@
import type {
Athlete,
AthleteInput,
ConflictAction,
EntryImportResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
ImportConflict,
ImportConflictDecision,
ImportConflictField,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
@@ -15,7 +19,7 @@ import type {
Team,
TeamInput,
} from '@/shared'
import { REQUIRED_COLUMNS } from './entrySelection'
import { MANDATORY_COLUMNS } from './entrySelection'
/**
* 基础数据页的离线示例数据源。
@@ -172,11 +176,12 @@ const ATHLETE_NAMES = [
]
/** 报名表模板列,与导入通道共用同一份定义(大小写不敏感)。 */
const IMPORT_COLUMNS = REQUIRED_COLUMNS
const IMPORT_COLUMNS = MANDATORY_COLUMNS
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
class MockBaseData {
/** 导出类本身供测试构造隔离实例;页面用下方单例。 */
export class MockBaseData {
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport }))
private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({
@@ -231,6 +236,7 @@ class MockBaseData {
gender: nameIndex % 2 === 0 ? '男' : '女',
teamId: team.id,
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
idCard: null,
active: true,
})
})
@@ -425,10 +431,18 @@ class MockBaseData {
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
return this.runImport(file, new Map())
}
/** 冲突决策重跑:decisions 为空的证件号仍视为未决策,继续留在返回的 conflicts 里。 */
async resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
return this.runImport(file, new Map(decisions.map((decision) => [decision.idCard, decision.action])))
}
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [] })
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv') {
return { created: 0, updated: 0, skipped: 0, errors: [XLSX_HINT] }
}
if (extension !== '.csv') return empty([XLSX_HINT])
const rows = parseCsv(await file.text())
if (rows.length < 2) throw new Error('报名表为空')
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
@@ -437,7 +451,8 @@ class MockBaseData {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
}
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [] }
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [] }
const seenIdCards = new Set<string>()
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
if (row.every((cell) => cell.trim() === '')) return
@@ -448,6 +463,7 @@ class MockBaseData {
const eventCode = cell('event_code')
const teamCode = cell('team_code')
const categoryName = cell('category')
const athleteName = cell('athlete_name')
if (eventCode === '' || teamCode === '') {
result.skipped += 1
result.errors.push(`第 ${line} 行:event_code / team_code 必填`)
@@ -476,10 +492,94 @@ class MockBaseData {
result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`)
return
}
// 团体 / 对抗行没有姓名,行为不变。
if (athleteName === '') {
result.created += 1
return
}
const idCard = cell('id_card')
if (idCard === '') {
result.skipped += 1
result.errors.push(`第 ${line} 行:有姓名的行必须填证件号`)
return
}
if (seenIdCards.has(idCard)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:证件号 ${idCard} 在文件中重复出现`)
return
}
seenIdCards.add(idCard)
const rawGender = cell('gender')
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
const matched = this.athletes.find((athlete) => athlete.idCard === idCard)
if (matched !== undefined) {
const conflict = this.detectConflict(matched, line, idCard, athleteName, gender, team)
if (conflict !== null && decisions.get(idCard) === undefined) {
result.conflicts.push(conflict)
return
}
if (conflict !== null && decisions.get(idCard) === 'keep-new') {
matched.name = athleteName
if (gender !== null) matched.gender = gender
matched.teamId = team.id
}
result.created += 1
return
}
// 证件号未命中,但 (姓名, 单位) 命中且库里证件号为空:视为同一人并回填。
const legacy = this.athletes.find(
(athlete) =>
athlete.name === athleteName &&
athlete.teamId === team.id &&
(athlete.idCard === null || athlete.idCard === ''),
)
if (legacy !== undefined) {
legacy.idCard = idCard
result.created += 1
return
}
this.athletes.push({
id: (this.nextAthleteId += 1),
name: athleteName,
gender,
teamId: team.id,
employeeNo: null,
idCard,
active: true,
})
result.created += 1
})
return result
}
/** name / gender / teamCode 与库里不一致才算冲突;表里 gender 为空视为未指定。 */
private detectConflict(
athlete: Athlete,
row: number,
idCard: string,
name: string,
gender: Athlete['gender'],
team: Team,
): ImportConflict | null {
const changed: ImportConflictField[] = []
if (athlete.name !== name) changed.push('name')
if (gender !== null && athlete.gender !== gender) changed.push('gender')
if (athlete.teamId !== team.id) changed.push('teamCode')
if (changed.length === 0) return null
return {
row,
idCard,
athleteId: athlete.id,
existing: {
name: athlete.name,
gender: athlete.gender,
teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '',
employeeNo: athlete.employeeNo,
},
incoming: { name, gender, teamCode: team.code, employeeNo: null },
changedFields: changed,
}
}
}
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
@@ -8,6 +8,7 @@ import type {
EventCategory,
EventCategoryInput,
EventInput,
ImportConflictDecision,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
@@ -235,6 +236,10 @@ function importEntries(file: File): Promise<EntryImportResult> {
return baseDataApi.entries.importFile(file)
}
function resolveImportConflicts(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult> {
return baseDataApi.entries.resolveImportConflicts(file, decisions)
}
export interface BaseDataController {
teams: Ref<Team[]>
sports: Ref<Sport[]>
@@ -268,6 +273,7 @@ export interface BaseDataController {
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
removeAthlete: (id: number) => Promise<void>
importEntries: (file: File) => Promise<EntryImportResult>
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
}
export function useBaseData(): BaseDataController {
@@ -309,5 +315,6 @@ export function useBaseData(): BaseDataController {
saveAthlete,
removeAthlete,
importEntries,
resolveImportConflicts,
}
}
+29 -9
View File
@@ -1,6 +1,12 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { REQUIRED_COLUMNS, buildSelectionRows, toCsv } from '../components/baseData/entrySelection'
import {
MANDATORY_COLUMNS,
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
toCsv,
} from '../components/baseData/entrySelection'
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a', 'b']]), 'a,b')
@@ -11,19 +17,33 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('buildSelectionRows 个人项目:一人一行,共 8 列', () => {
test('表头含证件号 / 性别可选列,必填列不含这两列', () => {
assert.deepEqual(
[...REQUIRED_COLUMNS],
['event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'bib', 'seed', 'status', 'member_names'],
)
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
})
test('buildSelectionRows 个人项目:一人一行,共 10 列并带上证件号', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '张伟' }, { name: '李强' }],
athletes: [
{ name: '张伟', idCard: '110101199001011234' },
{ name: '李强', idCard: null },
],
})
assert.equal(rows.length, 3, '表头 + 2 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '', '', 'registered', ''])
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', 'registered', ''])
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '', 'registered', ''])
// 证件号为空时留空列,服务端按「有姓名的行必须填证件号」报错。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
@@ -35,8 +55,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 8)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'registered', '王芳/刘洋/陈晨'])
assert.equal(rows[1]?.length, 10)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
@@ -49,5 +69,5 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', 'scratched', '刘洋/王芳'])
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', 'scratched', '刘洋/王芳'])
})
@@ -0,0 +1,179 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { ImportConflict } from '@/shared'
import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
conflictDiffs,
decisionsPayload,
pruneDecisions,
recordDecisions,
summarizeDecisions,
toCsv,
type DecisionMap,
} from '../components/baseData/entrySelection'
import { MockBaseData } from '../components/baseData/mockBaseData'
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
return {
row: 12,
athleteId: 42,
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: '1001' },
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
changedFields: ['teamCode'],
...overrides,
}
}
function csv(rows: string[][]): File {
return new File([toCsv([[...REQUIRED_COLUMNS], ...rows])], 'import.csv', { type: 'text/csv' })
}
/** event_code / category / team_code / athlete_name / id_card / gender / bib / seed / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, '', '', 'registered', '']
}
test('conflictDiffs 展开两侧字段并标出差异字段', () => {
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
assert.deepEqual(
diffs.map((diff) => diff.field),
['name', 'gender', 'teamCode', 'employeeNo'],
)
assert.deepEqual(
diffs.map((diff) => diff.label),
['姓名', '性别', '单位', '工号 / 学号'],
)
assert.deepEqual(
diffs.map((diff) => diff.changed),
[false, false, true, false],
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
)
assert.equal(diffs[3]?.incoming, '—', 'null 展示为占位符')
})
test('recordDecisions 只接受仍存在的证件号,且不改原 map', () => {
const conflicts = [conflict({ idCard: 'A' }), conflict({ idCard: 'B' })]
const current = recordDecisions({}, conflicts, ['A', '不存在的证件号'], 'keep-existing')
assert.deepEqual(current, { A: 'keep-existing' })
const next = recordDecisions(current, conflicts, ['B'], 'keep-new')
assert.deepEqual(next, { A: 'keep-existing', B: 'keep-new' })
assert.deepEqual(current, { A: 'keep-existing' }, '原 map 不应被修改')
})
test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收敛', () => {
const conflicts = [conflict({ idCard: 'B' }), conflict({ idCard: 'A' })]
const decisions: DecisionMap = { A: 'keep-new', B: 'keep-existing', 旧的证件号: 'keep-new' }
assert.deepEqual(pruneDecisions(decisions, conflicts), { A: 'keep-new', B: 'keep-existing' })
assert.deepEqual(
decisionsPayload(decisions, conflicts),
[
{ idCard: 'B', action: 'keep-existing' },
{ idCard: 'A', action: 'keep-new' },
],
'载荷顺序与冲突列表一致',
)
assert.deepEqual(summarizeDecisions(decisions, conflicts), { keepExisting: 1, keepNew: 1, undecided: 0 })
assert.deepEqual(summarizeDecisions({}, conflicts), { keepExisting: 0, keepNew: 0, undecided: 2 })
})
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
const mock = new MockBaseData()
const before = mock.listAthletes().length
const created = await mock.importEntries(csv([row('新人甲', '110101199001010001', 'F1', '男')]))
assert.equal(created.created, 1)
assert.deepEqual(created.conflicts, [])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '新人甲')?.idCard, '110101199001010001')
// 种子数据里 F1 的「刘洋」证件号为空,应按姓名 + 单位回填而不是新建。
const backfilled = await mock.importEntries(csv([row('刘洋', '110101199001010002', 'F1')]))
assert.equal(backfilled.created, 1)
assert.equal(backfilled.conflicts.length, 0)
assert.equal(mock.listAthletes().length, before + 1, '回填不新增运动员')
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
})
test('导入:有姓名的行缺证件号 / 证件号重复都按行报错', async () => {
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号/)
const duplicated = await mock.importEntries(
csv([row('重号甲', '110101199001010003'), row('重号乙', '110101199001010003', 'F2')]),
)
assert.equal(duplicated.created, 1, '第一行仍导入')
assert.equal(duplicated.skipped, 1)
assert.match(duplicated.errors[0] ?? '', /重复出现/)
})
test('导入:同一证件号但单位不同记为冲突且不落库,决策后按所选一侧处理', async () => {
const idCard = '110101199001010004'
const mock = new MockBaseData()
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(conflicted.created, 0, '冲突行不建报名')
assert.equal(conflicted.conflicts.length, 1)
assert.deepEqual(conflicted.conflicts[0], {
row: 2,
idCard,
athleteId,
existing: { name: '张三', gender: '男', teamCode: 'F1', employeeNo: null },
incoming: { name: '张三', gender: '男', teamCode: 'F2', employeeNo: null },
changedFields: ['teamCode'],
})
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(again.conflicts.length, 1)
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, action: 'keep-existing' },
])
assert.equal(keptExisting.conflicts.length, 0)
assert.equal(keptExisting.created, 1)
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
{ idCard, action: 'keep-new' },
])
assert.equal(keptNew.conflicts.length, 0)
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
})
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
const captured: { url?: string; body?: FormData } = {}
const client = createApiClient({
baseUrl: '/api',
fetchImpl: async (input, init) => {
captured.url = String(input)
captured.body = init?.body as FormData
return new Response(
JSON.stringify({ ok: true, data: { created: 1, updated: 0, skipped: 0, errors: [], conflicts: [] } }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
)
},
})
const file = new File(['a'], 'entries.csv', { type: 'text/csv' })
const decisions = [{ idCard: '110101199001010001', action: 'keep-new' as const }]
const result = await client.entries.resolveImport(file, decisions)
assert.equal(captured.url, '/api/entries/import/resolve')
assert.equal(captured.body?.get('decisions'), JSON.stringify(decisions))
assert.equal(captured.body?.get('file'), file)
assert.equal(result.created, 1)
assert.deepEqual(result.conflicts, [])
})
test('导入:gender 只认男 / 女,其它值忽略', async () => {
const mock = new MockBaseData()
await mock.importEntries(csv([row('性别异常', '110101199001010005', 'F1', '未知')]))
const athlete = mock.listAthletes().find((item) => item.idCard === '110101199001010005')
assert.equal(athlete?.gender, null)
})
+9
View File
@@ -19,6 +19,7 @@ import type {
EventInput,
EventSettings,
HealthInfo,
ImportConflictDecision,
Page,
PageQuery,
PointAdjustment,
@@ -139,6 +140,8 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
}
export interface ResultsApi {
@@ -381,6 +384,12 @@ export class ApiClient {
form.append('file', file)
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
},
resolveImport: (file, decisions) => {
const form = new FormData()
form.append('file', file)
form.append('decisions', JSON.stringify(decisions))
return http.request<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
},
}
this.results = createResultsApi(http)
+33
View File
@@ -27,6 +27,8 @@ export interface Athlete {
gender: Gender | null
teamId: number
employeeNo: string | null
/** 人员唯一标识(证件号);历史数据可能为空。 */
idCard: string | null
active: boolean
}
@@ -150,11 +152,42 @@ export interface EntryWithResult extends Entry {
result: Result | null
}
/** 导入冲突中参与比对的人员字段。 */
export interface ImportConflictFields {
name: string
gender: Gender | null
teamCode: string
employeeNo: string | null
}
export type ImportConflictField = keyof ImportConflictFields
/** 同一证件号在库里与报名表中的人员信息不一致;本轮不落库,等用户决策。 */
export interface ImportConflict {
/** 报名表原始行号(含表头,从 1 开始)。 */
row: number
idCard: string
athleteId: number
existing: ImportConflictFields
incoming: ImportConflictFields
changedFields: ImportConflictField[]
}
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
export type ConflictAction = 'keep-existing' | 'keep-new'
export interface ImportConflictDecision {
idCard: string
action: ConflictAction
}
export interface EntryImportResult {
created: number
updated: number
skipped: number
errors: string[]
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
conflicts: ImportConflict[]
}
export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn'