fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -144,6 +144,8 @@ function reportBatch(verb: string, result: AthleteBatchResult, labels: Map<numbe
|
||||
|
||||
function askBatchDisable(): void {
|
||||
if (selectedCount.value === 0) return
|
||||
// 示例数据模式下停用只改内存、不会落库,提示「已停用」等于谎报;先拦下。
|
||||
if (data.blockDestructiveWrite('批量停用')) return
|
||||
batchTargetIds.value = [...selectedIds.value]
|
||||
batchMessage.value = null
|
||||
batchDisableOpen.value = true
|
||||
@@ -169,6 +171,8 @@ async function confirmBatchDisable(): Promise<void> {
|
||||
/** 先预览再确认:彻底删除不可逆,必须让操作员看到会删掉谁、重算哪些小项。 */
|
||||
async function askBatchPurge(): Promise<void> {
|
||||
if (selectedCount.value === 0) return
|
||||
// 示例数据里「删掉」只是内存变化,连预览都不该发;直接给出未连接服务端的说明。
|
||||
if (data.blockDestructiveWrite('批量彻底删除')) return
|
||||
batchTargetIds.value = [...selectedIds.value]
|
||||
batchMessage.value = null
|
||||
bulkPurgePreview.value = null
|
||||
@@ -383,6 +387,8 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
}
|
||||
|
||||
function askPurge(athlete: Athlete): void {
|
||||
// 单条彻底删除与批量同一口径:示例数据模式下不弹确认框、不发请求。
|
||||
if (data.blockDestructiveWrite('彻底删除')) return
|
||||
purgeTarget.value = athlete
|
||||
purgeError.value = null
|
||||
purgeOpen.value = true
|
||||
@@ -620,10 +626,10 @@ async function confirmPurge(): Promise<void> {
|
||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||
</FormField>
|
||||
<FormField label="证件号" hint="可选;不参与认人,但不能与别人重号">
|
||||
<FormField label="证件号" hint="人员的唯一标识;有就填,且不能与别人重号">
|
||||
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
||||
</FormField>
|
||||
<FormField label="号码布" hint="认人依据;建议每人一条且全库唯一">
|
||||
<FormField label="号码布" hint="证件号为空时按它认人;建议每人一条">
|
||||
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
|
||||
</FormField>
|
||||
<FormField label="状态" wide>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeCountToTeam } from '@/shared'
|
||||
import { normalizeCountToMedals, normalizeCountToTeam, normalizeCountToTeamMedals } from '@/shared'
|
||||
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
@@ -30,6 +30,10 @@ interface ChannelRow {
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
countToTeam: boolean
|
||||
/** 该通道名次是否发奖牌(缺省发)。 */
|
||||
countToMedals: boolean
|
||||
/** 该通道的个人奖牌是否并入单位金牌榜(缺省并入)。scope=team 的行不显示,但值照样回传。 */
|
||||
countToTeamMedals: boolean
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
@@ -81,6 +85,8 @@ function toRow(channel: ScoreChannel): ChannelRow {
|
||||
profileId: String(channel.profileId),
|
||||
sortOrder: String(channel.sortOrder),
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(channel.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(channel.countToTeamMedals),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +100,9 @@ function addRow(): void {
|
||||
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
countToTeam: true,
|
||||
// 新通道默认发奖牌并并入单位榜,与库表的两个默认值一致。
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -136,6 +145,10 @@ function buildInputs(): ScoreChannelInput[] {
|
||||
profileId: Number(row.profileId),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
countToTeam: row.countToTeam,
|
||||
// 两个奖牌开关必须一起回传:PUT 是整体替换,漏掉就等于把它们重置成缺省(发牌)。
|
||||
// scope=team 的行不显示 countToTeamMedals,但值原样带上,字段不会因为切换 scope 被清掉。
|
||||
countToMedals: row.countToMedals,
|
||||
countToTeamMedals: row.countToTeamMedals,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -212,6 +225,9 @@ async function save(): Promise<void> {
|
||||
|
||||
<div class="bd-note count-to-team-note">
|
||||
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
|
||||
<br />
|
||||
「发奖牌」关掉后该通道只出积分不出奖牌;「个人奖牌并入单位金牌榜」只对个人名次的通道有意义,
|
||||
关掉后这些个人奖牌仍进个人奖牌榜,但不计入单位金牌榜。
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
@@ -222,6 +238,7 @@ async function save(): Promise<void> {
|
||||
<th class="bd-select">名次来源</th>
|
||||
<th>积分模板</th>
|
||||
<th class="bd-select">计入单位总分</th>
|
||||
<th class="bd-select">奖牌通道</th>
|
||||
<th class="bd-cell-mini">排序</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
@@ -256,6 +273,33 @@ async function save(): Promise<void> {
|
||||
<span>{{ row.countToTeam ? '计入' : '不计入' }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="medal-toggles">
|
||||
<label
|
||||
class="medal-toggle"
|
||||
:title="row.countToMedals ? '该通道的名次发奖牌' : '该通道的名次只出积分、不发奖牌'"
|
||||
>
|
||||
<input v-model="row.countToMedals" type="checkbox" :disabled="!data.canWrite.value" />
|
||||
<span>发奖牌</span>
|
||||
</label>
|
||||
<!--
|
||||
scope=team 的名次本来就记在单位头上,没有「个人奖牌并入单位榜」这回事,
|
||||
所以不显示;但值仍原样回传,切换 scope 时不会被清掉。
|
||||
-->
|
||||
<label
|
||||
v-if="row.scope === 'athlete'"
|
||||
class="medal-toggle"
|
||||
:title="
|
||||
row.countToTeamMedals
|
||||
? '该通道的个人奖牌计入单位金牌榜'
|
||||
: '该通道的个人奖牌只进个人奖牌榜,不计入单位金牌榜'
|
||||
"
|
||||
>
|
||||
<input v-model="row.countToTeamMedals" type="checkbox" :disabled="!data.canWrite.value" />
|
||||
<span>个人奖牌并入单位金牌榜</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
|
||||
</td>
|
||||
@@ -268,7 +312,7 @@ async function save(): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="6">
|
||||
<td colspan="7">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项没有计分通道</div>
|
||||
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
|
||||
@@ -327,4 +371,19 @@ async function save(): Promise<void> {
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 两个奖牌开关竖排:表格已经很宽,横排会把「个人奖牌并入单位金牌榜」挤断行。 */
|
||||
.medal-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.medal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -438,7 +438,7 @@ async function submitTeam(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看号码布);种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看证件号,没有证件号时看号码布);种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ConflictAction, EntryImportResult, ImportConflict } from '@/shared'
|
||||
import type { ConflictAction, EntryImportResult, ImportConflict, SameNameNewPerson } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import EntryPicker from './EntryPicker.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
@@ -130,6 +130,18 @@ async function applyDecisions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同名新建报告的一行说明:只列真的对不上的那几项身份字段。 */
|
||||
function sameNameIdentityText(person: SameNameNewPerson): string {
|
||||
const parts: string[] = []
|
||||
if (person.existingIdCard !== person.newIdCard) {
|
||||
parts.push(`证件号 ${person.existingIdCard || '—'} → ${person.newIdCard || '—'}`)
|
||||
}
|
||||
if (person.existingBib !== person.newBib) {
|
||||
parts.push(`号码布 ${person.existingBib || '—'} → ${person.newBib || '—'}`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(';') : '身份字段与库里那条相同'
|
||||
}
|
||||
|
||||
function onPick(event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
@@ -193,10 +205,10 @@ function downloadTemplate(): void {
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
号码布是人员的唯一标识:先按号码布匹配;号码布为空或没匹配到,再按「姓名 + 单位」找——
|
||||
单位内恰好一人且对方还没有号码布就命中并回填;恰好一人但对方已有不同的号码布,按「同名的另一个人」新建档案
|
||||
(结果里会单独列出,不报错);多人则算歧义,列入冲突逐条决定。证件号是可选档案信息,只写入 / 更新、不参与匹配,
|
||||
但一个人的证件号不能与库里另一个人相同,撞号会明确报错并指出占用者。
|
||||
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
|
||||
两者都没命中才按「姓名 + 单位」找——单位内恰好一人且身份字段对得上(或有一边没填)就命中并把证件号 / 号码布回填进档案;
|
||||
恰好一人但两边的证件号(或号码布)对不上,按「同名的另一个人」新建档案(结果里会单独列出,不报错);
|
||||
多人则算歧义,列入冲突逐条决定。一个证件号在库里只能属于一个人。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -264,16 +276,26 @@ function downloadTemplate(): void {
|
||||
</div>
|
||||
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
|
||||
|
||||
<div v-if="result.notices.length > 0" class="notices">
|
||||
<div class="errors-head">
|
||||
<span class="mx-badge mx-badge-neutral">提示(不影响导入)</span>
|
||||
<span class="bd-note">这些行已经导入成功,仅提醒核对。</span>
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li v-for="(notice, index) in result.notices" :key="index">{{ notice }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="result.sameNameNewPersons.length > 0" class="same-name">
|
||||
<div class="errors-head">
|
||||
<span class="mx-badge mx-badge-warning">
|
||||
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(号码布不同)
|
||||
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(证件号 / 号码布不同)
|
||||
</span>
|
||||
<span class="bd-note">这些行已导入成功,仅提醒核对;若号码布填错,请到「运动员」里改回并删除多余档案。</span>
|
||||
<span class="bd-note">这些行已导入成功,仅提醒核对;若证件号或号码布填错,请到「运动员」里改回并删除多余档案。</span>
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li v-for="(person, index) in result.sameNameNewPersons" :key="index">
|
||||
{{ person.name }}({{ person.teamCode }}):原有号码布 {{ person.existingBib }} → 新号码布 {{ person.newBib }}
|
||||
{{ person.name }}({{ person.teamCode }}):{{ sameNameIdentityText(person) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -282,7 +304,7 @@ function downloadTemplate(): void {
|
||||
<div class="conflicts-head">
|
||||
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
||||
<span class="bd-note">
|
||||
号码布(或姓名 + 单位)命中了库里已有人员,但姓名 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
|
||||
证件号(或号码布、姓名 + 单位)命中了库里已有人员,但姓名 / 证件号 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
|
||||
本轮未落库,也不建报名。
|
||||
</span>
|
||||
</div>
|
||||
@@ -346,6 +368,10 @@ function downloadTemplate(): void {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="conflict.duplicate" class="bd-note conflict-duplicate">
|
||||
本表内重复行:同一证件号 + 同一个小项同一个组别出现了两次。字段下方没有高亮是正常的——
|
||||
保留已有 = 已导入的那条报名保持不动,使用新数据 = 用本行的值覆盖它。
|
||||
</p>
|
||||
<div class="conflict-diffs">
|
||||
<div
|
||||
v-for="diff in conflictDiffs(conflict)"
|
||||
@@ -464,7 +490,8 @@ function downloadTemplate(): void {
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.same-name {
|
||||
.same-name,
|
||||
.notices {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.error-list {
|
||||
@@ -533,6 +560,9 @@ function downloadTemplate(): void {
|
||||
.conflict-head .conflict-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.conflict-duplicate {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.conflict-diffs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import {
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
normalizeCountToTeam,
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
CompareMode,
|
||||
@@ -116,6 +122,8 @@ const channelSummary = computed(() =>
|
||||
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||
award: profile?.awardRanks ?? null,
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(channel.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(channel.countToTeamMedals),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -219,6 +227,8 @@ async function submitSport(): Promise<void> {
|
||||
|
||||
function askDeleteSport(): void {
|
||||
if (selectedSport.value === null) return
|
||||
// 删除不可恢复:示例数据模式下只会在内存里消失,不能弹确认框假装能删。
|
||||
if (data.blockDestructiveWrite('删除大项')) return
|
||||
if (sportEvents.value.length > 0) {
|
||||
toast.warning('该大项下还有小项', `请先删除 ${sportEvents.value.length} 个小项,再删除大项`)
|
||||
return
|
||||
@@ -251,6 +261,8 @@ interface EventForm {
|
||||
resultType: ResultType
|
||||
compare: CompareMode
|
||||
scored: boolean
|
||||
/** 该小项是否结算奖牌;关掉后只出积分不出奖牌。 */
|
||||
awardMedals: boolean
|
||||
recordEligible: boolean
|
||||
multiplier: string
|
||||
status: EventStatus
|
||||
@@ -291,6 +303,8 @@ function emptyEventForm(): EventForm {
|
||||
resultType: 'time',
|
||||
compare: 'asc',
|
||||
scored: true,
|
||||
// 新建小项默认结算奖牌,与库表 award_medals 的默认值一致。
|
||||
awardMedals: true,
|
||||
recordEligible: true,
|
||||
multiplier: '1',
|
||||
status: 'pending',
|
||||
@@ -338,6 +352,7 @@ function openEditEvent(event: Event): void {
|
||||
resultType: event.resultType,
|
||||
compare: event.compare,
|
||||
scored: event.scored,
|
||||
awardMedals: normalizeAwardMedals(event.awardMedals),
|
||||
recordEligible: event.recordEligible,
|
||||
multiplier: String(event.multiplier),
|
||||
status: event.status,
|
||||
@@ -384,6 +399,7 @@ async function submitEvent(): Promise<void> {
|
||||
compare: eventForm.value.compare,
|
||||
recordEligible: eventForm.value.recordEligible,
|
||||
scored: eventForm.value.scored,
|
||||
awardMedals: eventForm.value.awardMedals,
|
||||
multiplier: Number(eventForm.value.multiplier),
|
||||
status: eventForm.value.status,
|
||||
scheduledAt: fromLocalInput(eventForm.value.scheduledAt),
|
||||
@@ -405,6 +421,8 @@ async function submitEvent(): Promise<void> {
|
||||
}
|
||||
|
||||
function askDeleteEvent(event: Event): void {
|
||||
// 与删除大项同一口径:示例数据模式下不弹确认框、不提交。
|
||||
if (data.blockDestructiveWrite('删除小项')) return
|
||||
eventConfirmTarget.value = event
|
||||
eventConfirmOpen.value = true
|
||||
}
|
||||
@@ -588,6 +606,9 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<span class="bd-mono">{{ channel.profile }}</span>
|
||||
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
|
||||
<span v-if="!channel.countToTeam" class="mx-badge mx-badge-warning">不计入单位总分</span>
|
||||
<!-- 关掉奖牌的通道在这里明说,否则要翻回「计分通道」页才知道它不发牌。 -->
|
||||
<span v-if="!channel.countToMedals" class="mx-badge mx-badge-warning">不发奖牌</span>
|
||||
<span v-if="!channel.countToTeamMedals" class="mx-badge mx-badge-warning">个人奖牌不并入单位榜</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -696,8 +717,15 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<FormField label="选项" wide>
|
||||
<div class="check-row">
|
||||
<label class="bd-check"><input v-model="eventForm.scored" type="checkbox" /> 计分(取消则只排名不计分)</label>
|
||||
<label
|
||||
class="bd-check"
|
||||
title="关闭后该小项只出积分不出奖牌:名次与积分照算,奖牌榜里不再出现它"
|
||||
>
|
||||
<input v-model="eventForm.awardMedals" type="checkbox" /> 该项启用奖牌结算
|
||||
</label>
|
||||
<label class="bd-check"><input v-model="eventForm.recordEligible" type="checkbox" /> 参与纪录榜</label>
|
||||
</div>
|
||||
<span class="bd-note">关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌。</span>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormDialog>
|
||||
|
||||
@@ -238,6 +238,8 @@ const purgeDetails = computed(() =>
|
||||
)
|
||||
|
||||
function askPurge(team: Team): void {
|
||||
// 与运动员页同一口径:示例数据模式下彻底删除不提交,先给出未连接服务端的说明。
|
||||
if (data.blockDestructiveWrite('彻底删除')) return
|
||||
purgeTarget.value = team
|
||||
purgeError.value = null
|
||||
purgeOpen.value = true
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
|
||||
import {
|
||||
ApiError,
|
||||
adminApi,
|
||||
isSampleData,
|
||||
mockMode,
|
||||
mockReason,
|
||||
registerFallbackSource,
|
||||
sampleDataBlockedError,
|
||||
} from '../../api/client'
|
||||
import type { AthleteBatchAction, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||
import type {
|
||||
Athlete,
|
||||
@@ -28,8 +36,8 @@ import { mockBaseData } from './mockBaseData'
|
||||
* 只有「服务端未就绪」(网络/超时/解析失败/404)才落回示例数据,
|
||||
* 业务错误(鉴权、校验、冲突)原样抛出,避免把真实报错伪装成示例数据。
|
||||
*
|
||||
* 之所以不直接用 adminApi 的封装:api/client.ts 不在本页允许修改的范围内,
|
||||
* 且这里需要区分「基础数据接口没实现」与「整个后端没起来」两种降级。
|
||||
* 例外是破坏性操作(彻底删除、批量停用、删大项/小项):示例数据模式下不提交也不降级,
|
||||
* 统一走 callDestructive,免得内存里的「删掉了」被当成真删了(07 §5.3)。
|
||||
*/
|
||||
|
||||
const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
@@ -41,6 +49,7 @@ const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
* 此时 adminApi.bootstrap 会把 mockMode 重置为 false,页面却仍在用示例数据。
|
||||
*/
|
||||
export const baseDataFallback = ref(false)
|
||||
registerFallbackSource(() => baseDataFallback.value)
|
||||
|
||||
interface CallOptions {
|
||||
/**
|
||||
@@ -82,6 +91,27 @@ async function call<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 破坏性写操作的统一入口(彻底删除、批量停用等)。
|
||||
*
|
||||
* 与 call 的两点差别:
|
||||
* 1. 示例数据模式下先拦下——不发请求、也不返回假「成功」,否则操作员会以为人真被删了;
|
||||
* 2. 连不上服务端时不落到 mock——内存里删掉的人会在后端恢复后全部回来,比直接报错更糟。
|
||||
*/
|
||||
async function callDestructive<T>(action: string, real: () => Promise<T>): Promise<T> {
|
||||
if (isSampleData()) throw sampleDataBlockedError(action)
|
||||
try {
|
||||
return await real()
|
||||
} catch (error) {
|
||||
if (!isFallback(error, false)) throw error
|
||||
// 后端在请求途中掉线:与读路径同口径切到示例数据,并明确告知这次没有提交。
|
||||
baseDataFallback.value = true
|
||||
mockMode.value = true
|
||||
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
|
||||
throw sampleDataBlockedError(action)
|
||||
}
|
||||
}
|
||||
|
||||
export const baseDataApi = {
|
||||
teams: {
|
||||
list: (): Promise<Team[]> => call(() => adminApi.client.teams.list(), () => mockBaseData.listTeams()),
|
||||
@@ -92,11 +122,11 @@ export const baseDataApi = {
|
||||
/**
|
||||
* 彻底删除(假删除):单位从列表里消失,数据全部保留,失败原样抛出即可。
|
||||
*
|
||||
* 与 athletes.purge 同理,这里不让 404 降级:404 是「单位已不在」的真实回答,
|
||||
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」。网络 / 超时仍照常降级。
|
||||
* 破坏性操作:示例数据模式下直接拦下(不发请求、也不假装删掉了),
|
||||
* 连不上服务端时同样不落到 mock;404 是「单位已不在」的真实回答,原样抛出。
|
||||
*/
|
||||
purge: (id: number): Promise<void> =>
|
||||
call(() => adminApi.purgeTeam(id), () => mockBaseData.purgeTeam(id), { fallbackOn404: false }),
|
||||
callDestructive('彻底删除参赛单位', () => adminApi.purgeTeam(id)),
|
||||
},
|
||||
|
||||
sports: {
|
||||
@@ -106,7 +136,7 @@ export const baseDataApi = {
|
||||
update: (id: number, input: SportInput): Promise<Sport> =>
|
||||
call(() => adminApi.client.sports.update(id, input), () => mockBaseData.updateSport(id, input)),
|
||||
remove: (id: number): Promise<void> =>
|
||||
call(() => adminApi.client.sports.remove(id), () => mockBaseData.removeSport(id)),
|
||||
callDestructive('删除大项', () => adminApi.client.sports.remove(id)),
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -117,7 +147,7 @@ export const baseDataApi = {
|
||||
update: (id: number, input: EventInput): Promise<Event> =>
|
||||
call(() => adminApi.client.events.update(id, input), () => mockBaseData.updateEvent(id, input)),
|
||||
remove: (id: number): Promise<void> =>
|
||||
call(() => adminApi.client.events.remove(id), () => mockBaseData.removeEvent(id)),
|
||||
callDestructive('删除小项', () => adminApi.client.events.remove(id)),
|
||||
saveCategories: (eventId: number, inputs: EventCategoryInput[]): Promise<EventCategory[]> =>
|
||||
call(
|
||||
() => adminApi.client.events.saveCategories(eventId, inputs),
|
||||
@@ -162,24 +192,22 @@ export const baseDataApi = {
|
||||
/**
|
||||
* 彻底删除:服务端物理删除该人员及其报名、成绩并重算积分,返回删除规模统计。
|
||||
*
|
||||
* 与同组的 remove 不同,这里故意不让 404 降级:404 是「人员已不在」的真实回答,
|
||||
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」,现场就出事了。
|
||||
* 后端整体连不上(网络 / 超时)时仍照常降级,示例数据里可以照删。
|
||||
* 破坏性操作:示例数据模式下先拦下,连不上服务端时也不落到 mock——
|
||||
* 内存里「删掉」会让人以为真删了,后端恢复后这些人又会全部回来。
|
||||
* 404 是「人员已不在」的真实回答,原样抛出。
|
||||
*/
|
||||
purge: (id: number): Promise<AthletePurgeResult> =>
|
||||
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
|
||||
fallbackOn404: false,
|
||||
}),
|
||||
callDestructive('彻底删除运动员', () => adminApi.purgeAthlete(id)),
|
||||
/**
|
||||
* 批量停用 / 批量彻底删除:一个请求完成整批,逐条成败在响应里。
|
||||
*
|
||||
* 与 purge 同理不让 404 降级:404 是「这批人已经被别人处理过」的真实回答,
|
||||
* 落回示例数据会把「一个都没删掉」显示成「删掉了」。
|
||||
* 与 purge 同一口径:示例数据模式下这两个按钮不提交(07 §5.3 的承诺),
|
||||
* 连不上服务端时也不落到 mock;404 是「这批人已经被别人处理过」的真实回答,原样抛出。
|
||||
*/
|
||||
batch: (action: AthleteBatchAction, ids: number[], dryRun = false): Promise<AthleteBatchResult> =>
|
||||
call(() => adminApi.client.athletes.batch(action, ids, dryRun), () => mockBaseData.batchAthletes(action, ids, dryRun), {
|
||||
fallbackOn404: false,
|
||||
}),
|
||||
callDestructive(action === 'purge' ? '批量彻底删除' : '批量停用', () =>
|
||||
adminApi.client.athletes.batch(action, ids, dryRun),
|
||||
),
|
||||
},
|
||||
|
||||
entries: {
|
||||
|
||||
@@ -289,6 +289,7 @@ const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
|
||||
age: '年龄',
|
||||
teamCode: '单位',
|
||||
employeeNo: '工号 / 学号',
|
||||
idCard: '证件号',
|
||||
bib: '号码布',
|
||||
}
|
||||
|
||||
@@ -317,25 +318,25 @@ function formatFieldValue(value: string | number | null | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突的决策键:号码布优先,号码布为空再回落证件号,两样都没有时回落姓名 + 单位。
|
||||
* 冲突的决策键:证件号优先,没有证件号时用号码布,两样都没有时回落姓名 + 单位。
|
||||
*
|
||||
* 号码布才是人员的唯一标识;证件号自「号码布优先匹配」之后不再参与匹配,
|
||||
* 同一份表里两个不同的人可以共用一个证件号,拿它当键会让两条冲突共用一行决策
|
||||
* (面板 :key 重复、勾选互相串)。
|
||||
* 证件号才是人员的唯一标识;同一份表里重复出现同一证件号 = 同一个人(多半是报了多个组别),
|
||||
* 所以同一条决策正好覆盖那个人的所有行。与后端 repo_import.go 的 athleteImportRow.decisionKey()
|
||||
* 必须逐字一致,否则用户点的决策会落到别的冲突上(或两条冲突并成一条)。
|
||||
*
|
||||
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
|
||||
* 服务端按自己的 decisionKey()(repo_import.go)重算,键本身不出现在载荷里。
|
||||
* 服务端按同一口径重算,键本身不出现在载荷里。
|
||||
*/
|
||||
export function decisionKeyOf(conflict: ImportConflict): string {
|
||||
if (conflict.bib !== '') return conflict.bib
|
||||
if (conflict.idCard !== '') return conflict.idCard
|
||||
if (conflict.bib !== '') return conflict.bib
|
||||
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
|
||||
}
|
||||
|
||||
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
|
||||
export function conflictLabel(conflict: ImportConflict): string {
|
||||
const key = decisionKeyOf(conflict)
|
||||
return key === '' ? '(无号码布 / 证件号 / 姓名)' : key
|
||||
return key === '' ? '(无证件号 / 号码布 / 姓名)' : key
|
||||
}
|
||||
|
||||
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import {
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
normalizeCountToTeam,
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type { AthleteBatchAction, AthleteBatchItem, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||
import type {
|
||||
Athlete,
|
||||
@@ -97,16 +103,22 @@ const SEED_PROFILES: ScoringProfile[] = [
|
||||
]
|
||||
|
||||
interface EventSeed {
|
||||
/** avgMode 统一由构造函数补默认值,示例数据里不必逐个写。 */
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode'>
|
||||
/** avgMode / awardMedals 统一由构造函数补默认值,示例数据里不必逐个写。 */
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode' | 'awardMedals'>
|
||||
category: string
|
||||
scope: ScoreChannel['scope']
|
||||
basis: ScoreChannel['basis']
|
||||
profileId: number
|
||||
/** 通道是否计入单位总分;缺省 true。 */
|
||||
countToTeam?: boolean
|
||||
/** 该通道是否发奖牌;缺省 true。 */
|
||||
countToMedals?: boolean
|
||||
/** 该通道的个人奖牌是否并入单位榜;缺省 true。 */
|
||||
countToTeamMedals?: boolean
|
||||
/** 团体成绩的平均口径;缺省 valid。 */
|
||||
avgMode?: Event['avgMode']
|
||||
/** 小项是否结算奖牌;缺省 true。 */
|
||||
awardMedals?: boolean
|
||||
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
|
||||
entryCount: number
|
||||
}
|
||||
@@ -189,15 +201,20 @@ const IMPORT_COLUMNS = MANDATORY_COLUMNS
|
||||
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
||||
|
||||
/**
|
||||
* 冲突决策键:与服务端一致,证件号优先、为空时取号码布。
|
||||
* 冲突决策键:与服务端 athleteImportRow.decisionKey() 一致,证件号优先、为空时取号码布。
|
||||
*
|
||||
* 载荷里 idCard 与 bib 两个字段都带,服务端按自己的口径重算键(repo_import.go 的 decisionKey()),
|
||||
* 载荷里 idCard 与 bib 两个字段都带,服务端按同一口径重算键(repo_import.go),
|
||||
* 所以这里必须与服务端同序,否则决策会落到另一条冲突上。
|
||||
*/
|
||||
function importDecisionKey(key: { idCard: string; bib: string }): string {
|
||||
return key.idCard !== '' ? key.idCard : key.bib
|
||||
}
|
||||
|
||||
/** 身份字段(证件号 / 号码布)对不上:两边都给了值且不一致才算。 */
|
||||
function identityDiffers(existing: string | null, incoming: string): boolean {
|
||||
return incoming !== '' && existing !== null && existing !== '' && existing !== incoming
|
||||
}
|
||||
|
||||
/** 报名表里一行的人员信息(姓名已确定非空),供匹配逻辑使用。 */
|
||||
interface ImportRow {
|
||||
line: number
|
||||
@@ -207,14 +224,18 @@ interface ImportRow {
|
||||
idCard: string
|
||||
bib: string
|
||||
team: Team
|
||||
/** 与同一份表里的前一行「同证件号 + 同一个小项同一个组别」= 真重复行。 */
|
||||
duplicate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员匹配结果:冲突行不落库、等用户决策;error 是可直接回显的行级报错文案。
|
||||
* sameNameNewPerson 只在「号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
|
||||
* sameNameNewPerson 只在「姓名 + 单位相同、证件号 / 号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
|
||||
* skipped 是真重复行 + 用户选了「保留已有」:这一行不落库。
|
||||
*/
|
||||
type AthleteMatch =
|
||||
| { kind: 'reused' }
|
||||
| { kind: 'skipped' }
|
||||
| { kind: 'conflict'; conflict: ImportConflict }
|
||||
| { kind: 'created'; sameNameNewPerson?: SameNameNewPerson }
|
||||
| { kind: 'error'; message: string }
|
||||
@@ -245,7 +266,12 @@ export class MockBaseData {
|
||||
constructor() {
|
||||
SEED_EVENTS.forEach((seed, index) => {
|
||||
const id = 101 + index
|
||||
this.events.push({ ...seed.event, id, avgMode: normalizeAvgMode(seed.avgMode) })
|
||||
this.events.push({
|
||||
...seed.event,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(seed.avgMode),
|
||||
awardMedals: normalizeAwardMedals(seed.awardMedals),
|
||||
})
|
||||
this.entryCounts.set(id, seed.entryCount)
|
||||
this.categories.push({
|
||||
id: (this.nextCategoryId += 1),
|
||||
@@ -265,6 +291,8 @@ export class MockBaseData {
|
||||
profileId: seed.profileId,
|
||||
sortOrder: 1,
|
||||
countToTeam: seed.countToTeam ?? true,
|
||||
countToMedals: seed.countToMedals ?? true,
|
||||
countToTeamMedals: seed.countToTeamMedals ?? true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,7 +396,12 @@ export class MockBaseData {
|
||||
createEvent(input: EventInput): Event {
|
||||
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
const id = (this.nextEventId += 1)
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
const event: Event = {
|
||||
...input,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(input.avgMode),
|
||||
awardMedals: normalizeAwardMedals(input.awardMedals),
|
||||
}
|
||||
this.events = [...this.events, event]
|
||||
this.entryCounts.set(id, 0)
|
||||
return { ...event }
|
||||
@@ -380,7 +413,12 @@ export class MockBaseData {
|
||||
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
|
||||
throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
}
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
const event: Event = {
|
||||
...input,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(input.avgMode),
|
||||
awardMedals: normalizeAwardMedals(input.awardMedals),
|
||||
}
|
||||
this.events[index] = event
|
||||
return { ...event }
|
||||
}
|
||||
@@ -456,6 +494,8 @@ export class MockBaseData {
|
||||
id: (this.nextChannelId += 1),
|
||||
eventId,
|
||||
countToTeam: normalizeCountToTeam(input.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(input.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(input.countToTeamMedals),
|
||||
})
|
||||
}
|
||||
return this.getEvent(eventId).channels ?? []
|
||||
@@ -514,7 +554,7 @@ export class MockBaseData {
|
||||
return { ...athlete }
|
||||
}
|
||||
|
||||
/** 证件号 / 号码布都是人员唯一标识,重复时报出具体是哪个字段。 */
|
||||
/** 证件号是人员唯一标识、号码布是替补:重复时报出具体是哪个字段。 */
|
||||
private assertAthleteIdentityFree(input: AthleteInput, selfId: number | null): void {
|
||||
const idCard = input.idCard ?? ''
|
||||
if (idCard !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.idCard === idCard)) {
|
||||
@@ -604,7 +644,9 @@ export class MockBaseData {
|
||||
}
|
||||
|
||||
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
|
||||
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [], sameNameNewPersons: [] })
|
||||
const empty = (errors: string[]): EntryImportResult => ({
|
||||
created: 0, updated: 0, skipped: 0, errors, notices: [], conflicts: [], sameNameNewPersons: [],
|
||||
})
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||
if (extension !== '.csv') return empty([XLSX_HINT])
|
||||
const rows = parseCsv(await file.text())
|
||||
@@ -615,8 +657,15 @@ export class MockBaseData {
|
||||
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
||||
}
|
||||
|
||||
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [], sameNameNewPersons: [] }
|
||||
const seenBibs = new Set<string>()
|
||||
const result: EntryImportResult = {
|
||||
created: 0, updated: 0, skipped: 0, errors: [], notices: [], conflicts: [], sameNameNewPersons: [],
|
||||
}
|
||||
// 证件号是人员唯一标识:同一份表里重复出现 = 同一个人(多半是报多个组别),逐行落库并给一条提示;
|
||||
// 只有「同证件号 + 同一个小项同一个组别」才是真重复,进冲突面板。
|
||||
const idCardCounts = new Map<string, number>()
|
||||
const seenIdentity = new Set<string>()
|
||||
// 号码布不再是唯一标识,但两个不同的人共用同一个号码布仍然拦:号码布 -> 该行的证件号。
|
||||
const bibOwners = new Map<string, string>()
|
||||
rows.slice(1).forEach((row, offset) => {
|
||||
const line = offset + 2
|
||||
if (row.every((cell) => cell.trim() === '')) return
|
||||
@@ -675,17 +724,27 @@ export class MockBaseData {
|
||||
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
|
||||
return
|
||||
}
|
||||
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||
// 证件号只是可选档案信息,同一份表内重复不再拦截(与服务端口径一致)。
|
||||
if (bib !== '' && seenBibs.has(bib)) {
|
||||
result.skipped += 1
|
||||
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
||||
return
|
||||
// 同一个人报多个组别时可以共用号码布;不同的人共用同一个号码布仍然按行报错。
|
||||
if (bib !== '') {
|
||||
const owner = bibOwners.get(bib)
|
||||
if (owner !== undefined && (idCard === '' || owner !== idCard)) {
|
||||
result.skipped += 1
|
||||
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
||||
return
|
||||
}
|
||||
bibOwners.set(bib, idCard)
|
||||
}
|
||||
// 真重复判据与服务端一致:同一证件号 + 同一个小项同一个组别;组别留空时服务端取第一个组别。
|
||||
let duplicate = false
|
||||
if (idCard !== '') {
|
||||
idCardCounts.set(idCard, (idCardCounts.get(idCard) ?? 0) + 1)
|
||||
const identity = `${event.id}\u0000${categoryName !== '' ? categoryName : (categories[0]?.name ?? '')}`
|
||||
duplicate = seenIdentity.has(identity)
|
||||
seenIdentity.add(identity)
|
||||
}
|
||||
if (bib !== '') seenBibs.add(bib)
|
||||
const rawGender = cell('gender')
|
||||
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
|
||||
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team }, decisions)
|
||||
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team, duplicate }, decisions)
|
||||
if (match.kind === 'conflict') {
|
||||
result.conflicts.push(match.conflict)
|
||||
return
|
||||
@@ -695,21 +754,35 @@ export class MockBaseData {
|
||||
result.errors.push(`第 ${line} 行:${match.message}`)
|
||||
return
|
||||
}
|
||||
if (match.kind === 'skipped') {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.skipped += 1
|
||||
return
|
||||
}
|
||||
if (match.kind === 'created' && match.sameNameNewPerson !== undefined) {
|
||||
result.sameNameNewPersons.push(match.sameNameNewPerson)
|
||||
}
|
||||
result.created += 1
|
||||
})
|
||||
// 同一证件号在本表里出现多次 = 同一个人报了多个组别:不报错,但要让现场看见(顺序按证件号排序)。
|
||||
for (const idCard of [...idCardCounts.keys()].sort()) {
|
||||
const count = idCardCounts.get(idCard) ?? 0
|
||||
if (count > 1) result.notices.push(`证件号 ${idCard} 在本表中出现 ${count} 次(同一人报名多个组别)`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员匹配:号码布精确命中 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
|
||||
* 人员匹配:证件号 → 号码布 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
|
||||
*
|
||||
* 证件号不参与匹配(只是可选档案信息),只在新建 / 回填 / keep-new 时写入;
|
||||
* 撞到别人的证件号仍然是行级报错,不允许静默改库或静默跳过。
|
||||
* 证件号是人员唯一标识,命中即认定同一个人;证件号为空、或库里那条还没填证件号时退到号码布;
|
||||
* 两者都没命中才按姓名 + 单位认人并回填证件号 / 号码布 / 年龄。
|
||||
*/
|
||||
private matchAthlete(row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||
if (row.idCard !== '') {
|
||||
const byIDCard = this.athletes.find((athlete) => athlete.idCard === row.idCard)
|
||||
if (byIDCard !== undefined) return this.reuseOrConflict(byIDCard, row, decisions)
|
||||
}
|
||||
if (row.bib !== '') {
|
||||
const byBib = this.athletes.find((athlete) => athlete.bib === row.bib)
|
||||
if (byBib !== undefined) return this.reuseOrConflict(byBib, row, decisions)
|
||||
@@ -723,17 +796,21 @@ export class MockBaseData {
|
||||
// 同名同单位有多个人:不能替用户挑人,没决策就进面板(与服务端一样以第一条候选为代表)。
|
||||
// 字段全对得上时也拦:这里认不出「是哪一个人」,猜错就会改错档案。
|
||||
if (decisions.has(importDecisionKey(row))) return this.reuseOrConflict(first, row, decisions)
|
||||
if (row.duplicate) return { kind: 'conflict', conflict: this.duplicateConflict(first, row) }
|
||||
return { kind: 'conflict', conflict: this.buildConflict(first, row, this.changedFields(first, row)) }
|
||||
}
|
||||
// 表里也给了号码布、且与库里不同 → 同名的另一个人:新建并报告,不拦截导入。
|
||||
if (first.bib !== null && first.bib !== '' && row.bib !== '' && first.bib !== row.bib) {
|
||||
// 两边都给了身份字段、且对不上 → 同名的另一个人:新建并报告,不拦截导入。
|
||||
// 一边没给只是「未指定」,按同一人处理并回填。
|
||||
if (identityDiffers(first.idCard, row.idCard) || identityDiffers(first.bib, row.bib)) {
|
||||
const created = this.createImportedAthlete(row)
|
||||
if (created.kind === 'created') {
|
||||
created.sameNameNewPerson = {
|
||||
name: row.name,
|
||||
teamCode: row.team.code,
|
||||
newIdCard: row.idCard,
|
||||
existingIdCard: first.idCard ?? '',
|
||||
newBib: row.bib,
|
||||
existingBib: first.bib,
|
||||
existingBib: first.bib ?? '',
|
||||
}
|
||||
}
|
||||
return created
|
||||
@@ -743,12 +820,18 @@ export class MockBaseData {
|
||||
|
||||
/** 命中已有人员:无差异直接复用并回填空字段;有差异时按决策落库,没决策就进冲突面板。 */
|
||||
private reuseOrConflict(ex: Athlete, row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||
const action = decisions.get(importDecisionKey(row))
|
||||
if (row.duplicate) {
|
||||
// 同一份表里同一证件号 + 同一个小项同一个组别出现两次 = 真重复:
|
||||
// 两行字段可能完全一致,但要不要覆盖已经落库的那条报名必须由用户决定。
|
||||
if (action === undefined) return { kind: 'conflict', conflict: this.duplicateConflict(ex, row) }
|
||||
if (action === 'keep-existing') return { kind: 'skipped' }
|
||||
}
|
||||
const conflict = this.conflictFor(ex, row)
|
||||
if (conflict === null) {
|
||||
const taken = this.backfillAthlete(ex, row)
|
||||
return taken === null ? { kind: 'reused' } : { kind: 'error', message: taken }
|
||||
}
|
||||
const action = decisions.get(importDecisionKey(conflict))
|
||||
if (action === undefined) return { kind: 'conflict', conflict }
|
||||
// keep-existing:人员字段保持库里的值;keep-new:用表里的字段覆盖(表里没填的保留)。
|
||||
if (action === 'keep-new') {
|
||||
@@ -795,7 +878,8 @@ export class MockBaseData {
|
||||
}
|
||||
|
||||
/**
|
||||
* 证件号不参与匹配,但仍一人一号:撞到别人时报出占用者是谁。
|
||||
* 证件号是人员唯一标识,正常路径上带库里已有证件号的行会先按证件号命中那个人(差异进冲突决策),
|
||||
* 所以这里主要兜住「姓名 + 单位命中另一个人、证件号却属于别人」的回填路径。
|
||||
* 文案与服务端 idCardTakenError 一致,现场在降级模式与真实服务端看到的是同一句话。
|
||||
*/
|
||||
private idCardTakenError(idCard: string, excludeId: number): string | null {
|
||||
@@ -812,7 +896,7 @@ export class MockBaseData {
|
||||
return changed.length === 0 ? null : this.buildConflict(athlete, row, changed)
|
||||
}
|
||||
|
||||
/** name / gender / age / teamCode / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
|
||||
/** name / gender / age / teamCode / idCard / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
|
||||
private changedFields(athlete: Athlete, row: ImportRow): ImportConflictField[] {
|
||||
const changed: ImportConflictField[] = []
|
||||
if (athlete.name !== row.name) changed.push('name')
|
||||
@@ -820,13 +904,17 @@ export class MockBaseData {
|
||||
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
|
||||
if (row.age !== null && athlete.age !== null && athlete.age !== row.age) changed.push('age')
|
||||
if (athlete.teamId !== row.team.id) changed.push('teamCode')
|
||||
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
||||
if (row.bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== row.bib) {
|
||||
changed.push('bib')
|
||||
}
|
||||
// 证件号 / 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
||||
if (identityDiffers(athlete.idCard, row.idCard)) changed.push('idCard')
|
||||
if (identityDiffers(athlete.bib, row.bib)) changed.push('bib')
|
||||
return changed
|
||||
}
|
||||
|
||||
/** 真重复行的冲突:两行可能一个字段都不差,标上 duplicate 让面板说明为什么没有差异高亮。 */
|
||||
private duplicateConflict(athlete: Athlete, row: ImportRow): ImportConflict {
|
||||
return { ...this.buildConflict(athlete, row, this.changedFields(athlete, row)), duplicate: true }
|
||||
}
|
||||
|
||||
/** 冲突对象;changed 为空也算冲突(同名同单位认不出是哪一个,同样要进面板等决策)。 */
|
||||
private buildConflict(athlete: Athlete, row: ImportRow, changed: ImportConflictField[]): ImportConflict {
|
||||
return {
|
||||
@@ -834,12 +922,14 @@ export class MockBaseData {
|
||||
idCard: row.idCard,
|
||||
bib: row.bib,
|
||||
athleteId: athlete.id,
|
||||
duplicate: false,
|
||||
existing: {
|
||||
name: athlete.name,
|
||||
gender: athlete.gender,
|
||||
age: athlete.age,
|
||||
teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '',
|
||||
employeeNo: athlete.employeeNo,
|
||||
idCard: athlete.idCard,
|
||||
bib: athlete.bib,
|
||||
},
|
||||
incoming: {
|
||||
@@ -848,6 +938,7 @@ export class MockBaseData {
|
||||
age: row.age,
|
||||
teamCode: row.team.code,
|
||||
employeeNo: null,
|
||||
idCard: row.idCard === '' ? null : row.idCard,
|
||||
bib: row.bib === '' ? null : row.bib,
|
||||
},
|
||||
changedFields: changed,
|
||||
|
||||
@@ -20,9 +20,10 @@ import type {
|
||||
TeamInput,
|
||||
} from '@/shared'
|
||||
import type { AthletePurgeResult } from '@/shared/api'
|
||||
import { adminApi, mockMode, mockReason } from '../../api/client'
|
||||
import { adminApi, mockMode, mockReason, sampleDataBlockedNotice } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useEventStore } from '../../stores/event'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { baseDataApi, baseDataFallback } from './baseDataApi'
|
||||
|
||||
/**
|
||||
@@ -317,6 +318,8 @@ export interface BaseDataController {
|
||||
isMock: ComputedRef<boolean>
|
||||
mockLabel: ComputedRef<string | null>
|
||||
canWrite: ComputedRef<boolean>
|
||||
/** 示例数据模式下拦下破坏性操作(统一判定 + 提示);返回 true 表示已拦下。 */
|
||||
blockDestructiveWrite: (action: string) => boolean
|
||||
/** 赛事设置(含榜单口径);GET /api/settings 仅管理员可见,只读访客拿不到时为 null。 */
|
||||
settings: ComputedRef<EventSettings | null>
|
||||
load: (force?: boolean) => Promise<void>
|
||||
@@ -354,6 +357,21 @@ export function useBaseData(): BaseDataController {
|
||||
const auth = useAuthStore()
|
||||
// 榜单口径是全局设置,唯一真值在 event store(bootstrap / 实时快照都写它)。
|
||||
const eventStore = useEventStore()
|
||||
const toast = useToastStore()
|
||||
const isMock = computed(() => baseDataFallback.value || mockMode.value)
|
||||
|
||||
/**
|
||||
* 示例数据模式下拦下破坏性操作并给出统一提示。
|
||||
*
|
||||
* 判定集中在这里(isMock 同时看全局降级与基础数据接口降级),页面不用各写一份;
|
||||
* 返回 true 表示已拦下,调用方直接 return 即可。
|
||||
*/
|
||||
function blockDestructiveWrite(action: string): boolean {
|
||||
if (!isMock.value) return false
|
||||
const notice = sampleDataBlockedNotice(action)
|
||||
toast.error(notice.title, notice.detail)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
teams,
|
||||
@@ -365,7 +383,8 @@ export function useBaseData(): BaseDataController {
|
||||
loaded,
|
||||
error,
|
||||
selectedEventId,
|
||||
isMock: computed(() => baseDataFallback.value || mockMode.value),
|
||||
isMock,
|
||||
blockDestructiveWrite,
|
||||
mockLabel: computed(() =>
|
||||
baseDataFallback.value || mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null,
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user