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:
AskaEth committed 2026-09-11 21:47:07 +08:00
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
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
+125 -34
View File
@@ -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,
),