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

+62 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { ApiError, createApiClient } from '@/shared/api'
import type { AthletePurgeResult, DisplayInstance } from '@/shared/api'
import type { AthletePurgeResult, DisplayInstance, EntryBoard } from '@/shared/api'
import type {
AthleteStanding,
AuditLog,
@@ -25,6 +25,8 @@ import type {
StandingRow,
Team,
TeamBreakdown,
TeamRanksInput,
TeamRanksView,
} from '@/shared'
import { mockDb } from './mock'
@@ -43,6 +45,45 @@ const FALLBACK_CODES = ['NETWORK', 'TIMEOUT']
export const mockMode = ref(false)
export const mockReason = ref<string | null>(null)
/**
* 页面级降级标记也参与「示例数据」判定。
*
* 只看全局 mockMode 不够:服务端整体正常、只有某个页面的接口尚未实现时,
* bootstrap 会把 mockMode 复位成 false,那个页面却仍在用示例数据。
*/
const fallbackSources = new Set<() => boolean>()
export function registerFallbackSource(source: () => boolean): void {
fallbackSources.add(source)
}
/** 统一的「当前是否处于示例数据模式」判定;破坏性写操作一律先问它。 */
export function isSampleData(): boolean {
if (mockMode.value) return true
for (const source of fallbackSources) {
if (source()) return true
}
return false
}
/** 示例数据模式下写操作被拦下时的错误码。 */
export const SAMPLE_DATA_BLOCKED = 'SAMPLE_DATA_BLOCKED'
/**
* 示例数据模式下破坏性操作的统一拦截错误。
*
* 用它把「没有提交」与「真的失败了」分开:页面据此给出「未连接服务端」的说明,
* 而不是把内存里的改动当成落库成功。
*/
export function sampleDataBlockedError(action: string): ApiError {
return new ApiError(SAMPLE_DATA_BLOCKED, `当前是示例数据,未连接服务端,无法${action}`, 0)
}
/** 与上一条同源的提示文案(标题 / 说明),供页面 toast 直接使用。 */
export function sampleDataBlockedNotice(action: string): { title: string; detail: string } {
return { title: `示例数据模式不能${action}`, detail: '未连接服务端,操作不会提交' }
}
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(handler: () => void): void {
@@ -142,6 +183,26 @@ export const adminApi = {
entries: (eventId: number): Promise<EntryWithResult[]> =>
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
/**
* 录入页的完整读数:成绩行 + 随行的团体手工名次。
*
* 与 entries 是同一个接口,只是这里把 teamRanks 也留下;拿不到时页面再退到
* `GET .../team-ranks`,不会因为老服务端不多发这个字段就丢掉已有名次。
*/
entryBoard: (eventId: number): Promise<EntryBoard> =>
call(() => client.events.entryBoard(eventId), () => mockDb.entryBoard(eventId)),
/** 读某个组别的团体手工名次(设计 17 §七)。 */
teamRanks: (eventId: number, categoryId: number): Promise<TeamRanksView> =>
call(() => client.events.teamRanks(eventId, categoryId), () => mockDb.teamRanks(eventId, categoryId)),
/**
* 写团体手工名次:写操作不降级。示例数据模式下没有后端可落库,
* 页面会先看 data.isMock 再决定是否调用(与成绩草稿、发布同一口径)。
*/
saveTeamRanks: (eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView> =>
callWrite(() => client.events.saveTeamRanks(eventId, categoryId, input)),
/**
* 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库,
* 页面会先看 data.isMock 再决定是否调用。
+83 -3
View File
@@ -26,6 +26,9 @@ import type {
Team,
TeamBreakdown,
BreakdownItem,
TeamRankEntry,
TeamRanksInput,
TeamRanksView,
} from '@/shared'
import { computeEventScore, type ScorableRow } from './scoring'
@@ -110,6 +113,8 @@ interface EventSeed {
resultType: ResultType
compare: Event['compare']
scored: boolean
/** 是否结算奖牌;缺省 true(与库里的 award_medals 默认值同口径)。 */
awardMedals?: boolean
categoryName: string
/** 「个人+团体」的团体成绩平均口径;缺省按 valid。 */
avgMode?: AvgMode
@@ -221,6 +226,7 @@ const EVENT_SEEDS: EventSeed[] = [
resultType: 'none',
compare: 'none',
scored: false,
awardMedals: false,
categoryName: '混合组',
},
{
@@ -295,6 +301,8 @@ class MockDb {
private readonly categories: EventCategory[]
private readonly channels: ScoreChannel[]
private readonly entries: Entry[] = []
/** 团体手工名次,键为 `eventId:categoryId`(与库表 (小项, 组别, scope, basis) 的定位口径一致)。 */
private readonly teamManualRanks = new Map<string, TeamRankEntry[]>()
private readonly results = new Map<number, Result>()
private readonly logs: AuditLog[] = []
private nextResultId = 9000
@@ -312,6 +320,7 @@ class MockDb {
compare: seed.compare,
recordEligible: seed.resultType === 'time' || seed.resultType === 'distance',
scored: seed.scored,
awardMedals: seed.awardMedals ?? true,
multiplier: 1,
status: seed.published === undefined ? 'pending' : 'published',
scheduledAt: null,
@@ -332,12 +341,16 @@ class MockDb {
sortOrder: 1,
}))
// 「个人+团体」按规则包默认模板配两条通道:个人通道只记个人分,团体通道取队内平均。
// 「个人+团体」按规则包默认模板配通道:个人通道只记个人分,团体通道取队内平均,
// 另加一条按单位手工录名次的团体通道(团体引体向上就是这么记分的),
// 录入页的「团体名次」区块在示例数据里也就能走通。
this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => {
if (seed.eventType === 'individual_team') {
return [
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false },
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true },
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false, countToMedals: true, countToTeamMedals: true },
// 队内平均通道只出积分、不发牌,正好演示「已关闭奖牌」的徽标。
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true, countToMedals: false, countToTeamMedals: true },
{ id: seed.id * 10 + 3, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 3, countToTeam: true, countToMedals: true, countToTeamMedals: true },
]
}
return [{
@@ -348,9 +361,17 @@ class MockDb {
profileId: seed.eventType === 'individual' ? 1 : 2,
sortOrder: 1,
countToTeam: true,
countToMedals: true,
countToTeamMedals: true,
}]
})
// 团体手工名次:TPULL(108)预先填两个单位,用来演示「已有值回填」。
this.teamManualRanks.set(teamRanksKey(108, 108 * 10 + 1), [
{ teamId: this.teams[0]?.id ?? 1, rank: 1 },
{ teamId: this.teams[1]?.id ?? 2, rank: 2 },
])
for (const seed of EVENT_SEEDS) this.seedEntries(seed)
for (const seed of EVENT_SEEDS) this.seedResults(seed)
}
@@ -549,6 +570,60 @@ class MockDb {
})
}
/**
* 成绩录入接口的完整读数:成绩行 + 该小项的团体手工名次。
*
* 真服务端把 teamRanks 随行下发是为了省一轮请求;示例数据同样给,
* 录入页的「团体名次」区块在示例数据模式下才走得到与真服务端一致的分支。
*/
entryBoard(eventId: number): { entries: EntryWithResult[]; teamRanks: TeamRanksView[] } {
return { entries: this.listEntries(eventId), teamRanks: this.teamRanksViewsOf(eventId) }
}
/** 该小项所有配了团体手工通道的组别视图。 */
private teamRanksViewsOf(eventId: number): TeamRanksView[] {
return this.categories
.filter(
(category) =>
category.eventId === eventId &&
this.channels.some(
(channel) =>
channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual',
),
)
.map((category) => this.teamRanksView(eventId, category.id))
}
teamRanks(eventId: number, categoryId: number): TeamRanksView {
return this.teamRanksView(eventId, categoryId)
}
private teamRanksView(eventId: number, categoryId: number): TeamRanksView {
return {
categoryId,
channels: this.channels
.filter((channel) => channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual')
.map((channel) => ({
channelId: channel.id,
scope: channel.scope,
basis: channel.basis,
profileId: channel.profileId,
})),
teams: this.teams
.filter((team) => team.active)
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName })),
ranks: (this.teamManualRanks.get(teamRanksKey(eventId, categoryId)) ?? []).map((entry) => ({ ...entry })),
}
}
/** 整组替换该组别的团体手工名次;rank <= 0 表示清空(与真服务端一致)。 */
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): TeamRanksView {
const ranks = input.ranks.filter((entry) => entry.rank > 0).map((entry) => ({ ...entry }))
this.teamManualRanks.set(teamRanksKey(eventId, categoryId), ranks)
this.log('channel.team_ranks', 'event', eventId, `保存团体名次 ${ranks.length} 条`)
return this.teamRanksView(eventId, categoryId)
}
saveDraft(payload: SaveDraftPayload): Result[] {
const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft')
this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`)
@@ -765,4 +840,9 @@ function round(value: number): number {
return Number(value.toFixed(2))
}
/** 团体手工名次的定位键:(小项, 组别) —— 与 channel_manual_ranks 的唯一键同口径。 */
function teamRanksKey(eventId: number, categoryId: number): string {
return `${eventId}:${categoryId}`
}
export const mockDb = new MockDb()
@@ -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,
),
+307 -10
View File
@@ -13,6 +13,7 @@ import {
type Event,
type ResultState,
type ResultStatus,
type TeamRanksView,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -28,6 +29,15 @@ import {
type ResultCellHint,
} from './entryGuards'
import { entryProgress } from './entryProgress'
import {
hasTeamManualChannel,
teamRankFlaggedTeams,
teamRankRowsOf,
teamRankWarnings,
teamRanksDirty,
teamRanksPayload,
type TeamRankRow,
} from './teamRanks'
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
import {
entryBatchOutcome,
@@ -168,6 +178,34 @@ const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
/**
* 「团体名次」区块(设计 17 §八第 3 条):只有该组别配了「团体 + 手动名次」通道时才出现。
*
* 这类通道读的是 channel_manual_ranks(按单位录),与成绩表的个人名次是两套数据,
* 所以这里单开一块,而不是塞进成绩表格里。
*/
const showTeamRanks = computed(() => hasTeamManualChannel(channels.value))
/**
* 每个组别各自的编辑中行:组别间互不影响,切走再切回时未保存的输入还在
* (成绩表也是这个口径——切换组别不会丢正在编辑的成绩)。
*/
const teamRanksByCategory = ref(new Map<number, TeamRankRow[]>())
/** 录入接口随行下发的团体名次,按组别 id 缓存;0 号键放服务端没带 categoryId 的那一份。 */
const embeddedTeamRanks = ref(new Map<number, TeamRanksView>())
const teamRanksLoading = ref(false)
const teamRanksSaving = ref(false)
/** 当前组别的团体名次行(表格直接绑它)。 */
const teamRankRows = computed<TeamRankRow[]>(() => {
const categoryId = selectedCategoryId.value
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
})
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
const teamRankIssues = computed(() => teamRankWarnings(teamRankRows.value))
/** 疑似笔误的行:给输入框描红,理由由 teamRankIssues 说清。 */
const teamRankIssueTeams = computed(() => teamRankFlaggedTeams(teamRankRows.value))
function unitOf(type: Event['resultType']): string {
if (type === 'time') return 's'
if (type === 'distance') return 'm'
@@ -354,10 +392,23 @@ const pendingSettlement = computed<SettlementGroup | null>(() => {
) ?? null
)
})
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
/**
* dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。
*
* 团体名次的编辑也算在内:它是独立保存的(PUT team-ranks),
* 不并入这里的话切页会把它静默丢掉,而它同样要重算名次与积分。
*/
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)) || teamRankDirty.value)
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
/** 丢弃确认框的明细:成绩与团体名次分开说,别让人看到「0 行」以为没有改动。 */
const discardDetails = computed(() => {
const details = [`未保存的成绩行:${dirtyCount.value} 行`]
if (teamRankDirty.value) details.push('还有未保存的团体名次(该组「团体名次」区块里)')
details.push('如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页')
return details
})
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
const leaveGuard = createLeaveGuard(() => dirty.value)
@@ -678,6 +729,7 @@ function selectCategory(id: number): void {
selectedCategoryId.value = id
selectedHeat.value = 'all'
selectedEntryIds.value = []
void loadTeamRanks()
}
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
@@ -692,21 +744,30 @@ async function loadEvent(id: number): Promise<void> {
const seq = (requestSeq += 1)
loading.value = true
try {
const [event, entries, records] = await Promise.all([
// 用 entryBoard 而不是 entries:同一次请求就会带回团体手工名次(设计 17 §七),
// 不必再多发一轮 GET team-ranks。
const [event, board, records] = await Promise.all([
adminApi.eventDetail(id),
adminApi.entries(id),
adminApi.entryBoard(id),
adminApi.records(),
])
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
if (seq !== requestSeq) return
detail.value = event
avgMode.value = loadAvgMode(id, event.avgMode)
// 换项目等于换一份数据:团体名次的草稿与随行视图都先清掉,再归位新的一份。
// 归位必须赶在下面改 selectedCategoryId 之前——组别 watch 会立刻按新组别取数据。
teamRanksByCategory.value = new Map()
collectEmbeddedTeamRanks(board.teamRanks)
selectedCategoryId.value = event.categories?.[0]?.id ?? null
selectedHeat.value = 'all'
selectedEntryIds.value = []
allRows.value = entries.map(toRow)
allRows.value = board.entries.map(toRow)
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
// 不用 watch(selectedCategoryId) 是为了不依赖「赋值后回调晚于归位」这种时序巧合。
void loadTeamRanks()
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
@@ -769,6 +830,114 @@ async function reload(): Promise<void> {
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
function collectEmbeddedTeamRanks(views: readonly TeamRanksView[]): void {
const next = new Map<number, TeamRanksView>()
for (const view of views) next.set(view.categoryId ?? 0, view)
embeddedTeamRanks.value = next
}
/**
* 取某个组别的视图:优先用录入接口随行下发的那一份(省一轮请求)。
*
* 服务端不带 categoryId 时无从归位,只有一个组别的事件才敢直接用它——
* 猜错组别会把别的组的名次显示成本组的名次,比多一次请求危险得多,宁可回退到 GET。
*/
function embeddedTeamRanksFor(categoryId: number): TeamRanksView | null {
const exact = embeddedTeamRanks.value.get(categoryId)
if (exact !== undefined) return exact
return categories.value.length === 1 ? (embeddedTeamRanks.value.get(0) ?? null) : null
}
/** 把最新视图写进缓存:无 categoryId 的旧视图可能就是这个组别的,一并清掉免得盖过新值。 */
function cacheTeamRanksView(categoryId: number, view: TeamRanksView): void {
const next = new Map(embeddedTeamRanks.value)
next.delete(0)
next.set(categoryId, view)
embeddedTeamRanks.value = next
}
/**
* 载入当前组别的团体名次。
*
* 已经有未保存输入的组别不重载——否则切走再切回会把操作员刚填的名次冲掉。
*/
async function loadTeamRanks(): Promise<void> {
const eventId = selectedEventId.value
const categoryId = selectedCategoryId.value
if (eventId === null || categoryId === null || !showTeamRanks.value) {
// 该组别没有团体手工通道:即使以前填过也不显示(服务端不会读它)。
return
}
const draft = teamRanksByCategory.value.get(categoryId)
if (draft !== undefined && teamRanksDirty(draft)) return
const embedded = embeddedTeamRanksFor(categoryId)
if (embedded !== null) {
writeTeamRanks(categoryId, teamRankRowsOf(embedded, data.teams))
return
}
const seq = requestSeq
teamRanksLoading.value = true
try {
const view = await adminApi.teamRanks(eventId, categoryId)
if (seq !== requestSeq || categoryId !== selectedCategoryId.value) return
cacheTeamRanksView(categoryId, view)
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
} catch (caught) {
if (seq !== requestSeq) return
// 读不到就按空表处理:这是辅助区块,不能挡住成绩录入。
writeTeamRanks(categoryId, [])
toast.error('读取团体名次失败', caught instanceof Error ? caught.message : null)
} finally {
if (seq === requestSeq) teamRanksLoading.value = false
}
}
function writeTeamRanks(categoryId: number, rows: TeamRankRow[]): void {
const next = new Map(teamRanksByCategory.value)
next.set(categoryId, rows)
teamRanksByCategory.value = next
}
/**
* 保存当前组别的团体名次(PUT .../team-ranks,整组替换)。
*
* 保存会重算该组的名次与积分(成绩表的名次列、积分预览、榜单都会变),
* 所以成功后按「发布」同一套流程整页刷新一次;服务端回的就是保存后的视图,直接回填。
*/
async function saveTeamRanks(): Promise<void> {
const eventId = selectedEventId.value
const categoryId = selectedCategoryId.value
if (eventId === null || categoryId === null) return
if (data.isMock) {
// 与草稿 / 发布同一口径:示例数据模式下写接口没有后端可落库,提示成功等于谎报。
toast.error('示例数据模式不能保存团体名次', '后端未就绪,改动不会写入真实赛事')
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (teamRankRows.value.length === 0) {
toast.info('没有可录入的参赛单位')
return
}
teamRanksSaving.value = true
try {
const view = await adminApi.saveTeamRanks(eventId, categoryId, teamRanksPayload(teamRankRows.value))
cacheTeamRanksView(categoryId, view)
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
toast.success('团体名次已保存', '该组名次与积分榜会按新名次重算')
await reloadAfterSettlementChange()
} catch (caught) {
toast.error('保存团体名次失败', caught instanceof Error ? caught.message : null)
} finally {
teamRanksSaving.value = false
}
}
function buildDraftPayload(): DraftResultInput[] {
// 已发布行由服务端锁定,不能再存草稿(服务端会 409 STATE_INVALID);
// 分批发布时本组会同时存在「已发布」与「未发布」的行,只提交未发布的那些。
@@ -1087,8 +1256,9 @@ function canBatch(): boolean {
return false
}
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
// 团体名次同样是「重算的输入」,也在这里挡住(dirty 已把它算进去)。
if (dirty.value) {
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
toast.error('有未保存的修改', '批量操作会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
return false
}
return true
@@ -1793,6 +1963,72 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</aside>
</div>
<!--
团体名次:按单位录入,只喂给「团体 + 手动名次」的通道(设计 17 §八第 3 条)。
与上面的成绩表是两套数据,所以单开一块,且不参与成绩表的行选择 / 批次筛选。
-->
<section v-if="showTeamRanks" class="mx-card team-ranks-card" :class="{ 'is-readonly': !canWrite }">
<div class="mx-card-head">
<span class="mx-card-title">团体名次</span>
<div class="team-ranks-actions">
<span v-if="teamRanksLoading" class="team-ranks-hint">读取中…</span>
<span v-if="teamRankDirty" class="mx-badge mx-badge-warning">未保存</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite || teamRanksSaving || !teamRankDirty"
:title="teamRankDirty ? '保存本组团体名次并重算名次与积分' : '没有改动'"
@click="saveTeamRanks"
>
<MxIcon name="check" :size="14" /> {{ teamRanksSaving ? '保存中…' : '保存团体名次' }}
</button>
</div>
</div>
<div class="mx-card-body">
<!--
guest / 断线只读时整块置灰并说明原因:团体名次是写操作,
读得到、改不了,必须让访客知道为什么输不进去。
-->
<div v-if="!canWrite" class="team-ranks-readonly-note">
<MxIcon name="warning" :size="15" />
<span>{{ auth.readOnly ? '当前为访客只读视图,团体名次只能查看,无法录入。' : '服务端未连接,暂时不能录入团体名次。' }}</span>
</div>
<p class="team-ranks-desc">
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
</p>
<ul class="team-rank-list">
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item">
<span class="team-rank-code mono">{{ row.code }}</span>
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
<input
v-model="row.input"
class="mx-cell-input rank-input mono"
type="number"
min="1"
:max="teamRankRows.length"
:disabled="!canWrite"
:class="{ 'is-invalid': teamRankIssueTeams.has(row.teamId) }"
:placeholder="`1-${teamRankRows.length}`"
:title="teamRankIssueTeams.has(row.teamId) ? '名次重复或超出参赛单位数,请确认' : ''"
/>
</li>
<li v-if="teamRankRows.length === 0" class="team-rank-empty">
没有可录入的参赛单位(单位列表为空或都已停用)
</li>
</ul>
<ul v-if="teamRankIssues.length > 0" class="issue-list team-rank-issues">
<li v-for="(message, index) in teamRankIssues" :key="index" class="warn">
<MxIcon name="bell" :size="14" />
<span>{{ message }}(只提示,不阻止保存)</span>
</li>
</ul>
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
</div>
</div>
</section>
<div v-if="issues.length > 0" class="mx-card issues-card">
<div class="mx-card-head">
<span class="mx-card-title">防呆校验</span>
@@ -1882,10 +2118,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
tone="warning"
:title="DISCARD_CONFIRM_TITLE"
:message="DISCARD_CONFIRM_MESSAGE"
:details="[
`未保存的行:${dirtyCount} 行`,
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
]"
:details="discardDetails"
confirm-text="丢弃并继续"
cancel-text="留在本页"
@confirm="confirmDiscard"
@@ -2175,6 +2408,70 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-cell {
font-weight: 600;
}
/*
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
*/
.team-ranks-card.is-readonly {
opacity: 0.7;
}
.team-ranks-actions {
display: flex;
align-items: center;
gap: 8px;
}
.team-ranks-readonly-note {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: var(--mx-r-sm);
font-size: 12px;
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.team-ranks-desc {
margin-bottom: 10px;
font-size: 12px;
line-height: 1.6;
color: var(--mx-t3);
}
.team-rank-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.team-rank-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
font-size: 12px;
color: var(--mx-t2);
}
.team-rank-code {
color: var(--mx-t3);
}
.team-rank-name {
min-width: 72px;
}
.team-rank-empty {
font-size: 12px;
color: var(--mx-t3);
}
.team-rank-issues {
margin-top: 10px;
}
.team-ranks-foot {
margin-top: 10px;
}
.team-ranks-hint {
font-size: 12px;
color: var(--mx-t3);
}
.center {
text-align: center;
}
+137
View File
@@ -0,0 +1,137 @@
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared'
/**
* 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。
*
* 团体名次是**按单位**录入的,与成绩表(按报名项)不是一套数据:只有
* `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。
* 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」,
* 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。
*/
export interface TeamRankRow {
teamId: number
code: string
name: string
shortName: string | null
/** 输入框里的原文;空串 = 未填(保存时按 0 = 清空发给服务端)。 */
input: string
/** 载入时的原文:用来判断这一行有没有被改过。 */
original: string
}
/**
* 该组别是否需要「团体名次」区块。
*
* 只看通道配置,不看有没有报名:纯团体项目(篮球等)走团体报名项的 results.rank 兜底,
* 不需要在这里录;只有配了「团体 + 手动名次」通道的小项才出现。
*/
export function hasTeamManualChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
return (channels ?? []).some((channel) => channel.scope === 'team' && channel.basis === 'manual')
}
/**
* 输入框原文 → 请求体里的名次。
*
* 空、0、负数、非整数一律归一为 0:服务端口径就是「rank 0 = 清空该单位」,
* 页面上留空即清空,不需要再让操作员先填一个 0。
*/
export function parseTeamRankInput(raw: string): number {
const trimmed = raw.trim()
if (trimmed === '') return 0
const value = Number(trimmed)
if (!Number.isInteger(value) || value < 1) return 0
return value
}
/** 名次 → 输入框原文;0 / 负数视为未填。 */
function inputOfRank(rank: number | undefined): string {
return rank !== undefined && rank >= 1 ? String(rank) : ''
}
/**
* 组装表格行。
*
* 单位名单优先用接口返回的 `teams`(服务端已经按「启用且未假删除」过滤过);
* 接口没带单位时退回全局单位表,同样只列启用中的单位——假删除的单位不该出现在录入界面。
*/
export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[]): TeamRankRow[] {
const ranks = new Map((view?.ranks ?? []).map((entry) => [entry.teamId, entry.rank]))
const source =
view !== null && view.teams.length > 0
? view.teams.map((team) => ({
teamId: team.teamId,
code: team.code,
name: team.name,
shortName: team.shortName,
}))
: teams
.filter((team) => team.active && (team.deletedAt === undefined || team.deletedAt === null))
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName }))
return source.map((team) => {
const input = inputOfRank(ranks.get(team.teamId))
return { ...team, input, original: input }
})
}
export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean {
return rows.some((row) => row.input !== row.original)
}
/**
* 保存载荷:整组回传。
*
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现,
* 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。
*/
export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput {
return {
scope: 'team',
basis: 'manual',
ranks: rows.map((row) => ({ teamId: row.teamId, rank: parseTeamRankInput(row.input) })),
}
}
/**
* 非阻塞提示(只提示不拦截,风格与成绩表的「名次重复」一致)。
*
* - 名次重复:**允许**,服务端按 tieMode 处理并列,这里只提醒操作员确认;
* - 名次超出参赛单位数:一定是笔误,但改不改仍由操作员决定,所以同样不挡保存。
*/
export function teamRankWarnings(rows: readonly TeamRankRow[]): string[] {
const counts = new Map<number, number>()
for (const row of rows) {
const rank = parseTeamRankInput(row.input)
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
}
const warnings: string[] = []
for (const [rank, count] of [...counts.entries()].sort((a, b) => a[0] - b[0])) {
if (count > 1) warnings.push(`名次 ${rank} 重复 ${count} 次,允许并列,请确认是否为想要的结果`)
}
const max = rows.length
if (max > 0) {
for (const row of rows) {
const rank = parseTeamRankInput(row.input)
if (rank > max) {
warnings.push(`${row.shortName ?? row.name}:名次 ${rank} 超出参赛单位数 ${max}`)
}
}
}
return warnings
}
/** 有问题的单位(名次重复或超出参赛单位数):给输入框描红,理由由 teamRankWarnings 说。 */
export function teamRankFlaggedTeams(rows: readonly TeamRankRow[]): Set<number> {
const counts = new Map<number, number>()
for (const row of rows) {
const rank = parseTeamRankInput(row.input)
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
}
const max = rows.length
const flagged = new Set<number>()
for (const row of rows) {
const rank = parseTeamRankInput(row.input)
if (rank >= 1 && ((counts.get(rank) ?? 0) > 1 || (max > 0 && rank > max))) flagged.add(row.teamId)
}
return flagged
}
+15 -14
View File
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
@@ -164,22 +164,23 @@ test('remove 的 404 仍降级到示例数据(既有行为未受影响)', as
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
// 后端整体连不上时不再降级示例数据:内存里「删掉」会让操作员以为人真被删了,
// 恢复连接后这些人又会全部回来。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
test('后端不可用时彻底删除被阻断:不降级示例数据、不返回假成功', async () => {
baseDataFallback.value = false
mockMode.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.athletes.purge(1002)
assert.deepEqual(result, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
assert.equal(baseDataFallback.value, true)
await assert.rejects(
() => baseDataApi.athletes.purge(1002),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
return true
},
)
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
})
@@ -46,7 +46,7 @@ function requestBody(): Record<string, unknown> {
return parsed
}
const { adminApi } = await import('../api/client')
const { ApiError, SAMPLE_DATA_BLOCKED, adminApi } = await import('../api/client')
const input = {
name: '男子甲组',
@@ -95,7 +95,7 @@ test('服务端 409 的原文要能被页面拿到(提示里那句话是现场
)
})
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
test('服务端不可达时,组别就地更新仍降级到示例数据,运动员批量则被阻断', async () => {
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
const { mockBaseData } = await import('../components/baseData/mockBaseData')
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
@@ -114,9 +114,15 @@ test('服务端不可达时,组别就地更新与运动员批量都降级到
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
assert.equal(baseDataFallback.value, true)
// 批量停用是破坏性操作:示例数据模式下必须拦下,不能假装停用成功(P1(17))。
const athlete = mockBaseData.listAthletes()[0]
assert.ok(athlete !== undefined)
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 0)
await assert.rejects(
() => baseDataApi.athletes.batch('disable', [athlete.id]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
return true
},
)
})
@@ -0,0 +1,52 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
// 计分通道页的两个奖牌开关(设计 17 §八第 2 条)与小项页的「该项启用奖牌结算」。
// 项目没有组件测试工具,按仓库既有做法(avg-mode.test.ts)读源码断言接线。
const CHANNELS_SOURCE = readFileSync(
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
'utf8',
)
const EVENTS_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('通道行带两个奖牌开关,缺字段按 true 归一', () => {
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
assert.match(CHANNELS_SOURCE, /发奖牌/)
assert.match(CHANNELS_SOURCE, /个人奖牌并入单位金牌榜/)
})
test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被清掉', () => {
// buildInputs 必须带上两个字段:PUT 是整体替换,漏掉等于把它们重置回默认(发牌)。
assert.match(CHANNELS_SOURCE, /countToMedals: row\.countToMedals/)
assert.match(CHANNELS_SOURCE, /countToTeamMedals: row\.countToTeamMedals/)
// 「个人奖牌并入单位金牌榜」只在 scope=athlete 的行显示;值本身原样回传。
assert.match(CHANNELS_SOURCE, /v-if="row\.scope === 'athlete'"/)
assert.match(CHANNELS_SOURCE, /切换 scope 时不会被清掉/)
})
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
})
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
assert.match(EVENTS_SOURCE, /awardMedals: true,/)
assert.match(EVENTS_SOURCE, /该项启用奖牌结算/)
assert.match(EVENTS_SOURCE, /关闭后该小项只出积分不出奖牌|关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌/)
// 编辑已有小项时按当前值回填,不能每次都变成开。
assert.match(EVENTS_SOURCE, /awardMedals: normalizeAwardMedals\(event\.awardMedals\)/)
// 保存时带上该字段(PUT 整体替换)。
assert.match(EVENTS_SOURCE, /awardMedals: eventForm\.value\.awardMedals/)
})
test('小项页的通道摘要给「已关闭奖牌」的通道加可见徽标', () => {
assert.match(EVENTS_SOURCE, /!channel\.countToMedals/)
assert.match(EVENTS_SOURCE, /不发奖牌/)
assert.match(EVENTS_SOURCE, /!channel\.countToTeamMedals/)
assert.match(EVENTS_SOURCE, /个人奖牌不并入单位榜/)
})
@@ -37,10 +37,13 @@ test('选中「个人+团体」时提示双通道口径', () => {
assert.match(SECTION_SOURCE, /计分通道/)
})
test('示例数据里的「个人+团体」小项配了个人与团体两条通道', () => {
test('示例数据里的「个人+团体」小项配了个人与团体通道(含按单位手工录名次)', () => {
const event = mockDb.eventDetail(108)
assert.equal(event.eventType, 'individual_team')
const scopes = (event.channels ?? []).map((channel) => channel.scope)
assert.deepEqual(scopes, ['athlete', 'team'])
// athlete=个人成绩、team+aggregate=队内平均、team+manual=按单位手工录名次
// (设计 17 的「团体名次」区块就靠最后这条通道才会出现在录入页)。
assert.deepEqual(scopes, ['athlete', 'team', 'team'])
assert.ok((event.channels ?? []).some((channel) => channel.basis === 'manual'))
})
+192 -54
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { AthleteInput, ImportConflict } from '@/shared'
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
@@ -15,13 +15,28 @@ import {
} from '../components/baseData/entrySelection'
import { MockBaseData } from '../components/baseData/mockBaseData'
/** 冲突两端可比的字段快照;证件号默认两侧相同(同一个人),号码布默认相同。 */
function fields(overrides: Partial<ImportConflictFields> = {}): ImportConflictFields {
return {
name: '张三',
gender: '男',
age: 26,
teamCode: 'F1',
employeeNo: '1001',
idCard: '110101199001010001',
bib: '101',
...overrides,
}
}
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
return {
row: 12,
bib: '',
athleteId: 42,
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
duplicate: false,
existing: fields(),
incoming: fields({ age: 27, teamCode: 'F2', employeeNo: null, bib: '102' }),
changedFields: ['age', 'teamCode', 'bib'],
...overrides,
}
@@ -32,38 +47,71 @@ function csv(rows: string[][]): File {
}
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
function rowAt(
eventCode: string,
category: string,
name: string,
idCard: string,
teamCode = 'F1',
gender = '',
bib = '',
age = '',
): string[] {
return [eventCode, category, teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
}
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
/** 默认小项与组别(M100 / 男子组)的一行。 */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return rowAt('M100', '男子组', name, idCard, teamCode, gender, bib, age)
}
test('conflictDiffs 展开两侧字段并标出差异字段(含证件号 / 年龄 / 号码布)', () => {
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
assert.deepEqual(
diffs.map((diff) => diff.field),
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'idCard', 'bib'],
)
assert.deepEqual(
diffs.map((diff) => diff.label),
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
['姓名', '性别', '年龄', '单位', '工号 / 学号', '证件号', '号码布'],
)
assert.deepEqual(
diffs.map((diff) => diff.changed),
[false, false, true, true, false, true],
[false, false, true, true, false, false, true],
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
)
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
assert.equal(diffs[2]?.incoming, '27')
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
assert.equal(diffs[5]?.existing, '101')
assert.equal(diffs[5]?.incoming, '102')
assert.equal(diffs[6]?.existing, '101')
assert.equal(diffs[6]?.incoming, '102')
})
// P1(16):服务端只差证件号时回的是 changedFields:["idCard"],前端必须有对应标签并高亮,
// 否则冲突卡片上一个差异都不亮,用户无从判断该保留哪一侧。
test('changedFields 含 idCard 时证件号一行有标签并高亮', () => {
const diffs = conflictDiffs(
conflict({
idCard: 'C2',
existing: fields({ idCard: 'C1' }),
incoming: fields({ idCard: 'C2' }),
changedFields: ['idCard'],
}),
)
const idCard = diffs.find((diff) => diff.field === 'idCard')
assert.deepEqual([idCard?.label, idCard?.existing, idCard?.incoming, idCard?.changed], ['证件号', 'C1', 'C2', true])
assert.deepEqual(
diffs.filter((diff) => diff.changed).map((diff) => diff.field),
['idCard'],
)
})
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
const diffs = conflictDiffs(
conflict({
idCard: '110101199001011234',
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
existing: fields({ age: null }),
incoming: fields({ age: null }),
changedFields: [],
}),
)
@@ -99,7 +147,7 @@ test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
// 只勾选第一条:另一条必须仍是未决策,按证件号作键时两条都会被空串键带偏。
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
assert.deepEqual(first, { 101: 'keep-existing' })
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
@@ -120,26 +168,33 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
})
})
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
// 决策键的优先级:证件号 → 号码布 → 姓名 + 单位,与服务端 athleteImportRow.decisionKey() 逐字一致。
test('决策键证件号优先:同证件号的两行是同一个人,共用一条决策', () => {
const shared = '110101199001010001'
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
// 同一证件号 = 同一个人(多半是报了多个组别),号码布不同也只做一次决策。
const first = conflict({ row: 2, idCard: shared, bib: '101' })
const second = conflict({ row: 3, idCard: shared, bib: '102' })
assert.equal(decisionKeyOf(first), '101')
assert.equal(decisionKeyOf(second), '102')
assert.equal(decisionKeyOf(first), shared)
assert.equal(decisionKeyOf(second), shared)
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
assert.deepEqual(decisions, { 101: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
{ idCard: shared, bib: '101', action: 'keep-new' },
])
const decisions = recordDecisions({}, [first, second], [shared], 'keep-new')
assert.deepEqual(decisions, { [shared]: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 2, undecided: 0 })
assert.deepEqual(
decisionsPayload(decisions, [first, second]),
[
{ idCard: shared, bib: '101', action: 'keep-new' },
{ idCard: shared, bib: '102', action: 'keep-new' },
],
'同一个人的两条都带上证件号,服务端按证件号重算键后一次决策覆盖两行',
)
})
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
const idCard = '110101199001010002'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
// 证件号优先于号码布:库里那条的号码布与表里一致、证件号不同时,键也必须取证件号。
test('决策键证件号优先于号码布', () => {
const idCard = '110101199001010003'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '101' })), idCard)
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '101' })), '101', '没有证件号才退到号码布')
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
})
@@ -161,25 +216,62 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
})
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
test('导入:有姓名的行缺证件号 / 号码布按行报错', async () => {
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
const before = mock.listAthletes().length
const duplicated = await mock.importEntries(
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
)
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
assert.equal(duplicated.created, 2, '两行都落库')
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
})
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
test('导入:同一证件号报多个组别不报错,逐行落库并给出提示', async () => {
const mock = new MockBaseData()
const idCard = '110101199001010003'
const before = mock.listAthletes().length
// 同一个人报两个小项:号码布也可以照抄(号码布不再是唯一标识)。
const result = await mock.importEntries(
csv([
rowAt('M100', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
rowAt('M800', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
]),
)
assert.deepEqual(result.errors, [], '同一证件号重复出现不再拦截')
assert.equal(result.created, 2, '同一人的两条报名项都落库')
assert.deepEqual(result.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
assert.deepEqual(result.conflicts, [], '不同小项不是真重复')
assert.equal(mock.listAthletes().length, before + 1, '同一个人只建一份档案')
})
test('导入:同证件号 + 同一个小项同一个组别才是真重复,等用户决策', async () => {
const mock = new MockBaseData()
const idCard = '110101199001010004'
const body = [row('重复甲', idCard, 'F1', '男', '9401'), row('重复甲', idCard, 'F1', '男', '9402')]
const conflicted = await mock.importEntries(csv(body))
assert.equal(conflicted.created, 1, '首行落库')
assert.equal(conflicted.skipped, 0, '真重复行不进「跳过」,要等决策')
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.equal(conflict?.duplicate, true, '标记为真重复行,面板才能说明为什么没有差异高亮')
assert.equal(conflict?.row, 3)
assert.deepEqual(conflict?.changedFields, ['bib'])
assert.deepEqual(conflicted.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401', '未决策前保持首行的值')
// 保留已有:真重复行不落库,同一个人一个字段都不动。
const keptExisting = await mock.resolveImportConflicts(csv(body), [
{ idCard, bib: '9402', action: 'keep-existing' },
])
assert.equal(keptExisting.skipped, 1)
assert.deepEqual(keptExisting.conflicts, [])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401')
// 使用新数据:用真重复行的字段覆盖同一个人。
const keptNew = await mock.resolveImportConflicts(csv(body), [{ idCard, bib: '9402', action: 'keep-new' }])
assert.deepEqual(keptNew.conflicts, [])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9402')
})
test('导入:证件号优先命中,复用同一个人时不算差异', async () => {
const idCard = '110101199001010004'
const mock = new MockBaseData()
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
@@ -188,21 +280,17 @@ test('导入:证件号撞别人按行报错,复用同一个人时不算差
assert.deepEqual(first.conflicts, [])
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
// 同一证件号换一个小项再来一行:按证件号命中同一个人,不算冲突。
const again = await mock.importEntries(csv([rowAt('M800', '男子组', '张三', idCard, 'F1', '男')]))
assert.equal(again.created, 1)
assert.deepEqual(again.conflicts, [])
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(taken.created, 0)
assert.equal(taken.skipped, 1)
assert.deepEqual(taken.conflicts, [])
assert.equal(
taken.errors[0],
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
'与服务端 idCardTakenError 同一句中文文案',
)
// 别的人拿着这个证件号来:按证件号命中张三,姓名 / 单位对不上 → 冲突面板,不是行级报错。
const taken = await mock.importEntries(csv([row('李四', idCard, 'F2', '男')]))
assert.equal(taken.skipped, 0)
assert.deepEqual(taken.errors, [], '证件号命中那个人就不再报「已被占用」')
assert.equal(taken.conflicts.length, 1)
assert.deepEqual(taken.conflicts[0]?.changedFields, ['name', 'teamCode'])
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
})
@@ -319,7 +407,14 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
{
name: '张伟',
teamCode: 'JG',
newIdCard: '110101199001010007',
existingIdCard: '',
newBib: '999',
existingBib: '101',
},
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
@@ -328,6 +423,49 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
})
// 证件号是唯一标识:号码布写错不影响认人,只把差异交给冲突决策。
test('导入:证件号优先命中,号码布对不上也只是冲突', async () => {
const mock = new MockBaseData()
// 种子数据里 JG 的「张伟」号码布为 101;先给他登记证件号。
const zhangWei = mock.listAthletes().find((athlete) => athlete.bib === '101')
assert.ok(zhangWei !== undefined)
const { id: athleteId, ...base } = zhangWei
mock.updateAthlete(athleteId, { ...base, idCard: 'C30003' })
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([row('张伟', 'C30003', 'JG', '男', '7777')]))
assert.equal(result.created, 0, '冲突行不落库')
assert.equal(mock.listAthletes().length, before, '证件号命中那个人,不新建档案')
assert.equal(result.conflicts.length, 1)
assert.equal(result.conflicts[0]?.athleteId, athleteId)
assert.deepEqual(result.conflicts[0]?.changedFields, ['bib'])
})
// 姓名 + 单位相同、证件号不同 → 同名的另一个人:新建并报告(与号码布不同同一条路径)。
test('导入:姓名 + 单位相同但证件号不同 → 按同名的另一个人新建并报告', async () => {
const mock = new MockBaseData()
const first = await mock.importEntries(csv([rowAt('M100', '男子组', '同名甲', 'C10001', 'F1', '男')]))
assert.equal(first.created, 1)
assert.deepEqual(first.sameNameNewPersons, [])
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([rowAt('M800', '男子组', '同名甲', 'C20002', 'F1', '男')]))
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '证件号不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{
name: '同名甲',
teamCode: 'F1',
newIdCard: 'C20002',
existingIdCard: 'C10001',
newBib: '',
existingBib: '',
},
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === 'C10001')?.idCard, 'C10001', '原档案证件号不动')
})
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
const mock = new MockBaseData()
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
@@ -50,6 +50,7 @@ test('示例数据的组别就地更新:重名与不存在给与服务端一
compare: seed.compare,
recordEligible: seed.recordEligible,
scored: seed.scored,
awardMedals: seed.awardMedals,
multiplier: seed.multiplier,
status: seed.status,
scheduledAt: null,
@@ -0,0 +1,148 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 示例数据模式(后端不可达 / mock 生效)下破坏性写操作的统一拦截(P1(17))。
*
* 以前「批量停用 / 批量彻底删除」会照常走 mock 分支:内存里把人删掉、还提示
* 「已彻底删除 N 人」,操作员以为真删了。这里钉住三件事——
* 示例数据模式下不发写请求、返回可识别的「未提交」结果;连上服务端时行为不变;
* 拦截文案存在且页面入口会触发它。
*/
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** 记录每一次真实发出的请求;破坏性操作在示例数据模式下应当一条都不产生。 */
let calls: string[] = []
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push(`${init?.method ?? 'GET'} ${String(input)}`)
return reply()
}) as typeof fetch
const {
ApiError,
SAMPLE_DATA_BLOCKED,
isSampleData,
mockMode,
mockReason,
sampleDataBlockedError,
sampleDataBlockedNotice,
} = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
function blocked(error: unknown): boolean {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
return true
}
/** 切到「后端不可达 / mock 生效」这一态:全局降级为真。 */
function enterSampleData(): void {
mockMode.value = true
mockReason.value = '测试:后端未就绪'
baseDataFallback.value = false
calls = []
}
/** 切回「连得上服务端」这一态。 */
function leaveSampleData(): void {
mockMode.value = false
mockReason.value = null
baseDataFallback.value = false
calls = []
}
test('示例数据模式下批量停用被阻断:不发写请求,返回可识别的「未提交」结果', async () => {
enterSampleData()
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1, 2]), blocked)
assert.deepEqual(calls, [], '示例数据模式下不应发出任何写请求')
})
test('示例数据模式下批量彻底删除(预览与提交)都被阻断且不发请求', async () => {
enterSampleData()
// dryRun 预览与真删是同一流程的两步,示例数据里连预览都不该发。
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1], true), blocked)
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1]), blocked)
assert.deepEqual(calls, [])
})
test('示例数据模式下单条彻底删除(人员 / 单位)也被阻断', async () => {
enterSampleData()
await assert.rejects(() => baseDataApi.athletes.purge(1), blocked)
await assert.rejects(() => baseDataApi.teams.purge(1), blocked)
assert.deepEqual(calls, [])
})
test('基础数据接口降级(仅页面级)同样算示例数据模式', async () => {
// 服务端整体正常、只有基础数据接口未实现:mockMode 为 false,页面却仍在用示例数据。
mockMode.value = false
baseDataFallback.value = true
calls = []
assert.equal(isSampleData(), true, '统一判定要同时看全局与页面级降级')
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1]), blocked)
assert.deepEqual(calls, [])
})
test('连上服务端时行为不变:真发请求并原样带回服务端结果', async () => {
leaveSampleData()
reply = () => jsonResponse({ ok: true, data: { action: 'purge', items: [{ id: 1, ok: true }] } })
const result = await baseDataApi.athletes.batch('purge', [1])
assert.equal(result.succeeded, 1)
assert.deepEqual(calls, ['POST /api/athletes/batch'])
assert.equal(mockMode.value, false)
})
test('拦截错误与提示文案可识别:说清是示例数据、未连接服务端', () => {
assert.equal(SAMPLE_DATA_BLOCKED, 'SAMPLE_DATA_BLOCKED')
const error = sampleDataBlockedError('批量彻底删除')
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
assert.match(error.message, /未连接服务端/)
assert.match(error.message, /批量彻底删除/)
const notice = sampleDataBlockedNotice('批量彻底删除')
assert.equal(notice.title, '示例数据模式不能批量彻底删除')
assert.match(notice.detail, /未连接服务端/)
})
// 项目没有组件测试工具(无 @vue/test-utils / jsdom),界面部分按仓库既有做法读源码断言。
const ATHLETES_SOURCE = readFileSync(
new URL('../components/baseData/AthletesSection.vue', import.meta.url),
'utf8',
)
const TEAMS_SOURCE = readFileSync(
new URL('../components/baseData/TeamsSection.vue', import.meta.url),
'utf8',
)
const SPORTS_EVENTS_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('运动员页的批量停用 / 批量彻底删除 / 单条彻底删除都先过统一拦截', () => {
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量停用'\)/)
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量彻底删除'\)/)
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
})
test('单位与大小项页的破坏性入口同样先过统一拦截', () => {
assert.match(TEAMS_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除大项'\)/)
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除小项'\)/)
})
+1
View File
@@ -18,6 +18,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
compare: 'asc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
+15 -8
View File
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
test('彻底删除走 DELETE /api/teams/{id}?purge=1', async () => {
@@ -117,16 +117,23 @@ test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
// 后端整体连不上时不再降级示例数据:内存里「删掉」只是假象,后端恢复后单位又会回来,
// 操作员却以为已经彻底删了。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
test('后端不可用时彻底删除单位被阻断:不降级示例数据、不假装删掉了', async () => {
baseDataFallback.value = false
mockMode.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.teams.purge(2)
assert.equal(result, undefined)
assert.equal(baseDataFallback.value, true)
await assert.rejects(
() => baseDataApi.teams.purge(2),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
return true
},
)
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
})
+182
View File
@@ -0,0 +1,182 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { ScoreChannel, Team, TeamRanksView } from '@/shared'
import {
hasTeamManualChannel,
parseTeamRankInput,
teamRankFlaggedTeams,
teamRankRowsOf,
teamRankWarnings,
teamRanksDirty,
teamRanksPayload,
} from '../pages/teamRanks'
import { mockDb } from '../api/mock'
// 录入页「团体名次」区块(设计 17 §八第 3 条):纯逻辑在 teamRanks.ts,
// 界面部分与仓库既有做法一致——没有组件测试工具,读源码断言关键接线。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 108, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeTeam(id: number, overrides: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `单位${id}`,
shortName: `单位${id}`,
logoPath: null,
color: null,
sortOrder: id,
active: true,
...overrides,
}
}
function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
return {
channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }],
teams: [
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
],
ranks: [{ teamId: 1, rank: 2 }],
...overrides,
}
}
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
assert.equal(hasTeamManualChannel([makeChannel()]), true)
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
assert.equal(hasTeamManualChannel([makeChannel({ scope: 'athlete' })]), false)
assert.equal(
hasTeamManualChannel([makeChannel({ scope: 'athlete', basis: 'result' }), makeChannel()]),
true,
)
assert.equal(hasTeamManualChannel([]), false)
assert.equal(hasTeamManualChannel(null), false)
assert.equal(hasTeamManualChannel(undefined), false)
})
test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => {
assert.equal(parseTeamRankInput('3'), 3)
assert.equal(parseTeamRankInput(' 4 '), 4)
// 留空 = 清空该单位,服务端口径是 rank 0。
assert.equal(parseTeamRankInput(''), 0)
assert.equal(parseTeamRankInput(' '), 0)
assert.equal(parseTeamRankInput('0'), 0)
assert.equal(parseTeamRankInput('-2'), 0)
assert.equal(parseTeamRankInput('2.5'), 0)
assert.equal(parseTeamRankInput('abc'), 0)
})
test('表格行:单位来自接口,已有名次回填,未填留空', () => {
const rows = teamRankRowsOf(makeView(), [])
assert.deepEqual(
rows.map((row) => [row.teamId, row.code, row.input, row.original]),
[
[1, 'JG', '2', '2'],
[2, 'F1', '', ''],
],
)
assert.equal(teamRanksDirty(rows), false)
})
test('接口没带单位时退回全局单位表,且不列停用 / 假删除的单位', () => {
const rows = teamRankRowsOf(makeView({ teams: [], ranks: [] }), [
makeTeam(1),
makeTeam(2, { active: false }),
makeTeam(3, { deletedAt: '2026-01-01' }),
])
assert.deepEqual(rows.map((row) => row.teamId), [1])
})
test('rank=0(清空)在表格里显示为未填,而不是 0', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 0 }] }), [])
assert.equal(rows[0]?.input, '')
assert.equal(rows[0]?.original, '')
})
test('改动检测与保存载荷:整组回传,清空发 0', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = ''
assert.equal(teamRanksDirty(rows), true)
assert.deepEqual(teamRanksPayload(rows), {
scope: 'team',
basis: 'manual',
ranks: [
{ teamId: 1, rank: 1 },
{ teamId: 2, rank: 0 },
],
})
})
test('名次重复只提示不拦截,超出单位数同样只是提醒', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '1'
const warnings = teamRankWarnings(rows)
assert.equal(warnings.length, 1)
assert.match(warnings[0] ?? '', /名次 1 重复 2 次,允许并列/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2])
// 超出参赛单位数(两个单位却填了 5)同样不抛错,只多一条提示。
rows[1]!.input = '5'
assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [2])
})
test('没问题的输入不产生提示', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '2'
assert.deepEqual(teamRankWarnings(rows), [])
assert.equal(teamRankFlaggedTeams(rows).size, 0)
})
test('示例数据:录入接口随行下发团体名次,保存后能原样读回', () => {
const event = mockDb.eventDetail(108)
assert.equal(hasTeamManualChannel(event.channels), true)
const board = mockDb.entryBoard(108)
const view = board.teamRanks.find((item) => item.channels.length > 0)
assert.ok(view !== undefined, 'TPULL 应随行下发团体名次视图')
assert.equal(view.categoryId, 108 * 10 + 1)
const rows = teamRankRowsOf(view, [])
assert.equal(rows.length, 7, '启用单位全部列出')
// 示例数据里预填了前两个单位,用来演示「已有值回填」。
assert.deepEqual(rows.slice(0, 2).map((row) => row.input), ['1', '2'])
// 改一个、清一个,保存后读回的口径与提交的一致。
rows[0]!.input = '3'
rows[1]!.input = ''
const saved = mockDb.saveTeamRanks(108, view.categoryId ?? 0, teamRanksPayload(rows))
const after = teamRankRowsOf(saved, [])
assert.equal(after[0]?.input, '3')
assert.equal(after[1]?.input, '', '空值 = 清空该单位')
assert.equal(mockDb.teamRanks(108, view.categoryId ?? 0).ranks.length, 1)
})
test('录入页按契约接线:区块可见性、随行数据优先、保存与 guest 只读', () => {
// 区块出现条件 = 该组别有团体手工通道。
assert.match(ENTRY_SOURCE, /hasTeamManualChannel\(channels\.value\)/)
assert.match(ENTRY_SOURCE, /v-if="showTeamRanks"/)
// 数据来源:录入接口随行的 teamRanks 优先,拿不到才退到 GET team-ranks。
assert.match(ENTRY_SOURCE, /adminApi\.entryBoard\(/)
assert.match(ENTRY_SOURCE, /adminApi\.teamRanks\(/)
// 保存走 PUT,并且是整组替换的载荷。
assert.match(ENTRY_SOURCE, /adminApi\.saveTeamRanks\(eventId, categoryId, teamRanksPayload\(/)
// 保存后整页刷新(名次 / 积分 / 榜单都会变)。
assert.match(ENTRY_SOURCE, /await reloadAfterSettlementChange\(\)/)
// 说明文案必须点明「按单位录入」且不影响个人名次。
assert.match(ENTRY_SOURCE, /按<strong>单位<\/strong>录入的团体名次/)
assert.match(ENTRY_SOURCE, /不影响个人名次/)
// guest / 断线只读:整块置灰 + 提示条。
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
})
+11 -1
View File
@@ -1,4 +1,9 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import {
normalizeAvgMode,
normalizeCountToMedals,
normalizeCountToTeam,
normalizeCountToTeamMedals,
} from '@/shared'
import type {
AthleteStanding,
BreakdownItem,
@@ -142,6 +147,9 @@ export function parseScoreChannel(source: unknown): ScoreChannel {
profileId: num(rec.profileId),
sortOrder: num(rec.sortOrder),
countToTeam: normalizeCountToTeam(rec.countToTeam),
// 旧服务端 / 旧快照不下发奖牌开关:缺字段一律按「发牌 / 并入单位榜」处理。
countToMedals: normalizeCountToMedals(rec.countToMedals),
countToTeamMedals: normalizeCountToTeamMedals(rec.countToTeamMedals),
}
}
@@ -158,6 +166,8 @@ export function parseEvent(source: unknown): Event {
compare: oneOf(rec.compare, COMPARE_MODES, 'none'),
recordEligible: bool(rec.recordEligible),
scored: bool(rec.scored, true),
// 旧服务端不下发 awardMedals,按 true 处理:升级前的口径就是「计分的小项发牌」。
awardMedals: bool(rec.awardMedals, true),
multiplier: num(rec.multiplier, 1),
status: oneOf(rec.status, EVENT_STATUSES, 'pending'),
scheduledAt: strOrNull(rec.scheduledAt),
+2
View File
@@ -109,6 +109,8 @@ function buildEvents(): Event[] {
compare,
recordEligible: index < 3,
scored: true,
// 示例大屏里每个小项都正常结算奖牌(缺字段时 guards 也按 true 处理)。
awardMedals: true,
multiplier: 1,
status: index < 3 ? 'published' : 'finished',
scheduledAt: null,
+67 -1
View File
@@ -6,6 +6,8 @@
*
* 失败口径按设计稿 §3.1 / §9:取不到值 → 空串 + 一条警告,不打断渲染;
* 块结构错误 → 返回结构化 error(带行列号),**不抛异常**,由调用方决定要不要降级。
* 超过安全阀(嵌套太深 / 体积太大,见 MAX_TEMPLATE_DEPTH、MAX_TEMPLATE_SOURCE_LENGTH)
* 也走同一条 error 路径——不能抛,否则 Promise 里的 RangeError 会把整块屏打白。
*/
/** 一条警告的成因。渲染期只产这些码,解析期不产警告。 */
@@ -47,6 +49,10 @@ export type TemplateErrorCode =
| 'duplicate-else'
| 'unknown-block'
| 'empty-block-path'
/** 嵌套层数超过 MAX_TEMPLATE_DEPTH(安全阀,位置指向超出的那个块标记) */
| 'too-deep'
/** 单个模板字符串超过 MAX_TEMPLATE_SOURCE_LENGTH(安全阀,位置恒为 1:1) */
| 'too-large'
export interface TemplateError {
code: TemplateErrorCode
@@ -118,6 +124,32 @@ const LOOP_KEYS = new Set(['@index', '@first', '@last'])
const BLOCK_KEYWORDS = new Set(['each', 'if'])
/**
* 嵌套层数上限(安全阀)。
*
* 为什么需要:渲染是递归下降(renderNodes),而模板按设计是**不可信输入**。
* 实测(node 22 默认栈)3000 层 {{#if}} 还能跑完,3200 层就抛
* `RangeError: Maximum call stack size exceeded`(源码只有约 51 KB,远不到 §6.2 的 1 MiB 上传上限),
* 违反「任何情况下都不抛异常」。所以上限钉在**解析期**:AST 深度一旦有界,渲染的递归深度
* 自然有界(AST 只能由 parseTemplate 产出)。模板语法里没有 {{> partial}} 之类的 include,
* 不存在跨模板的循环引用;数据侧的循环引用也只是沿 AST 一层层走,同样受这里约束。
*
* 为什么是 128:设计稿 §3.7 的示例模板最深 3 层(#if → #each → #if),人工/社区模板再复杂
* 也就是个位数;128 相对崩溃点(约 3100 层)留了 20 倍以上余量——正常模板碰不到,
* 极端模板也不会把调用栈打爆。
*/
export const MAX_TEMPLATE_DEPTH = 128
/**
* 单个模板字符串的长度上限(UTF-16 码元数,即 `source.length`)。
*
* 设计稿 §6.2 的「单文件 1 MiB」是**上传**侧的口径(服务端 413),引擎这层只做兜底:
* 留 4 倍余量,既不顶掉调用方自己的限制(1 MiB 附近的模板照常渲染,见 template-limits.test.ts),
* 又能挡住把整个赛事包、几十 MB 字符串误传进来的情形——那会让 tokenize 白跑一遍并撑出巨大的 token 数组。
* 超限不解析,直接给 too-large。
*/
export const MAX_TEMPLATE_SOURCE_LENGTH = 4 * 1024 * 1024
const ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
@@ -213,6 +245,8 @@ const ERROR_TEXT: Record<TemplateErrorCode, string> = {
'duplicate-else': '同一个 {{#if}} 块里出现了多个 {{else}}',
'unknown-block': '不支持的块标记',
'empty-block-path': '块标记缺少路径',
'too-deep': '模板嵌套太深',
'too-large': '模板太大',
}
/** 同一 (code, path) 只留第一条,重复出现只累加 count:循环里的笔误不该刷满警告清单。 */
@@ -395,6 +429,19 @@ function buildAst(
continue
}
if (token.kind === 'open') {
// 深度上限在解析期卡死(见 MAX_TEMPLATE_DEPTH):stack 里 root 占一格,
// 所以这个新块的层数正好是 stack.length;超限就返回结构化 error,绝不继续往下建。
if (stack.length > MAX_TEMPLATE_DEPTH) {
return {
ast: [],
error: makeError(
'too-deep',
starts,
token.start,
`最多 ${MAX_TEMPLATE_DEPTH} 层,这里已经是第 ${stack.length} 层`,
),
}
}
const node: SectionNode = {
type: token.block as 'each' | 'if',
path: parsePath(token.rawPath),
@@ -493,6 +540,10 @@ function resolvePath(ref: PathRef, stack: Frame[]): { value: unknown; code: Temp
return { value: current, code: null }
}
/**
* 递归下降渲染。它每下一层块就多一帧栈,所以深度必须靠解析期的 MAX_TEMPLATE_DEPTH 兜住
* (AST 只由 parseTemplate 产出,深度必然 ≤ 上限)——这里是唯一会把栈打爆的地方。
*/
function renderNodes(nodes: TemplateNode[], stack: Frame[], out: string[], sink: WarningCollector): void {
for (const node of nodes) {
if (node.type === 'text') {
@@ -543,6 +594,18 @@ function valueToText(value: unknown): { text: string; code: TemplateWarningCode
}
export function parseTemplate(source: string): ParseResult {
// 体积上限先判:病态输入连 buildLineStarts 都不必跑(starts 传 [0] 表示不分行,位置就是 1:1)
if (source.length > MAX_TEMPLATE_SOURCE_LENGTH) {
return {
ast: [],
error: makeError(
'too-large',
[0],
0,
`${source.length} 个字符,最多 ${MAX_TEMPLATE_SOURCE_LENGTH}`,
),
}
}
const starts = buildLineStarts(source)
const { tokens, error } = tokenize(source, starts)
if (error) return { ast: [], error }
@@ -550,7 +613,10 @@ export function parseTemplate(source: string): ParseResult {
return buildAst(tokens, starts)
}
/** 渲染。任何情况下都不抛异常:结构错误走返回值里的 error,取不到值走 html 空串 + warnings。 */
/**
* 渲染。任何情况下都不抛异常:结构错误(含超深/超大)走返回值里的 error,
* 取不到值走 html 空串 + warnings。
*/
export function renderTemplate(source: string, data: unknown = {}): RenderResult {
const { ast, error } = parseTemplate(source)
if (error) return { html: '', warnings: [], error }
@@ -21,6 +21,34 @@ test('parseScoreChannel 保留 aggregate 并按 true 补 countToTeam', () => {
assert.equal(parseScoreChannel({ basis: 'sum' }).basis, 'result')
})
// 奖牌开关是设计 17 新增的:旧服务端 / 旧快照根本没有这些字段,
// 缺字段必须按「开」处理,否则大屏与录入页会因为一次升级集体变成不发牌。
test('parseEvent 缺 awardMedals 时按「结算奖牌」处理', () => {
assert.equal(parseEvent({ id: 1, code: 'TPULL' }).awardMedals, true)
assert.equal(parseEvent({ id: 1, awardMedals: false }).awardMedals, false)
assert.equal(parseEvent({ id: 1, awardMedals: true }).awardMedals, true)
// 脏值(数字 / 字符串)不能把奖牌发出去:只有显式 false 才关。
assert.equal(parseEvent({ id: 1, awardMedals: 0 }).awardMedals, true)
})
test('parseScoreChannel 缺两个奖牌开关时都按 true 补', () => {
const legacy = parseScoreChannel({ id: 3, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1 })
assert.equal(legacy.countToMedals, true)
assert.equal(legacy.countToTeamMedals, true)
const off = parseScoreChannel({
id: 4,
eventId: 1,
scope: 'athlete',
basis: 'result',
profileId: 1,
countToMedals: false,
countToTeamMedals: false,
})
assert.equal(off.countToMedals, false)
assert.equal(off.countToTeamMedals, false)
})
test('parseEvent 同时解析详情接口带出的通道', () => {
const event = parseEvent({
id: 1,
+75 -1
View File
@@ -1,8 +1,9 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderTemplate } from '../template'
import { MAX_TEMPLATE_DEPTH, MAX_TEMPLATE_SOURCE_LENGTH, renderTemplate } from '../template'
// 设计稿 §10.1 的「边界/性能」组:单文件上限是 1 MiB(§6.2),引擎不能在这个量级上炸或卡死。
// 后面几组钉住审查 [11-1] 要求的安全阀:嵌套太深 / 体积太大一律降级成结构化 error,不抛异常。
test('1 MiB 量级的模板能正常渲染', () => {
const filler = 'x'.repeat(1024)
@@ -47,3 +48,76 @@ test('深嵌套 #each/#if(30 层)不炸,作用域逐层正确', () => {
assert.equal(result.error, null)
assert.equal(result.html, `n${depth - 1}`)
})
test('10 层嵌套 #if 渲染正确,没有警告(人工模板的正常量级)', () => {
const depth = 10
const source = '{{#if ok}}'.repeat(depth) + '{{ title }}' + '{{/if}}'.repeat(depth)
const result = renderTemplate(source, { ok: true, title: '十层' })
assert.equal(result.error, null)
assert.equal(result.html, '十层')
assert.deepEqual(result.warnings, [])
})
test(`正好 ${MAX_TEMPLATE_DEPTH} 层照常渲染,多一层降级成 too-deep`, () => {
const openTag = '{{#if ok}}'
const wrap = (depth: number): string =>
openTag.repeat(depth) + 'x' + '{{/if}}'.repeat(depth)
const atLimit = renderTemplate(wrap(MAX_TEMPLATE_DEPTH), { ok: true })
assert.equal(atLimit.error, null)
assert.equal(atLimit.html, 'x')
const overLimit = renderTemplate(wrap(MAX_TEMPLATE_DEPTH + 1), { ok: true })
assert.equal(overLimit.error?.code, 'too-deep')
// 位置指向超出的那个 {{#if}}(全在一行)
assert.equal(overLimit.error?.line, 1)
assert.equal(overLimit.error?.offset, MAX_TEMPLATE_DEPTH * openTag.length)
assert.equal(overLimit.error?.column, overLimit.error!.offset + 1)
assert.ok(overLimit.error?.message.includes(String(MAX_TEMPLATE_DEPTH)))
assert.equal(overLimit.html, '')
assert.deepEqual(overLimit.warnings, [])
})
test('审查 [11-1] 的复现输入(3000 层 {{#if}},约 48 KB)不抛异常,降级为 too-deep', () => {
const depth = 3000
const source = '{{#if a}}'.repeat(depth) + 'x' + '{{/if}}'.repeat(depth)
assert.doesNotThrow(() => renderTemplate(source, { a: true }))
const result = renderTemplate(source, { a: true })
assert.equal(result.error?.code, 'too-deep')
assert.equal(result.html, '')
assert.deepEqual(result.warnings, [])
})
test('超深 {{#each}} 同样降级(两种块共用同一个层数计数器)', () => {
const depth = MAX_TEMPLATE_DEPTH + 1
const source = '{{#each rows}}'.repeat(depth) + 'x' + '{{/each}}'.repeat(depth)
assert.doesNotThrow(() => renderTemplate(source, { rows: [] }))
assert.equal(renderTemplate(source, { rows: [] }).error?.code, 'too-deep')
})
test(`体积超过 ${MAX_TEMPLATE_SOURCE_LENGTH}:不解析、不抛异常,降级为 too-large`, () => {
const source = `{{ a }}${'x'.repeat(MAX_TEMPLATE_SOURCE_LENGTH)}`
assert.doesNotThrow(() => renderTemplate(source, { a: 1 }))
const result = renderTemplate(source, { a: 1 })
assert.equal(result.error?.code, 'too-large')
assert.equal(result.error?.line, 1)
assert.equal(result.error?.column, 1)
assert.equal(result.error?.offset, 0)
assert.equal(result.html, '')
assert.deepEqual(result.warnings, [])
})
test('体积正好等于上限:照常解析(上限是「超过才拒」)', () => {
const source = '{{ a }}'.padEnd(MAX_TEMPLATE_SOURCE_LENGTH, 'x')
assert.equal(source.length, MAX_TEMPLATE_SOURCE_LENGTH)
const result = renderTemplate(source, { a: 1 })
assert.equal(result.error, null)
assert.equal(result.html.startsWith('1'), true)
})
+66 -1
View File
@@ -1,5 +1,6 @@
import { isRawBody, isRecord, readString } from './internal'
import { normalizeRulePack } from './rules'
import { readTeamRanksView, readTeamRanksViews } from './teamRanks'
import { parseYaml } from './yaml'
import type {
AdjustmentInput,
@@ -42,6 +43,8 @@ import type {
SaveDraftPayload,
ScoreChannel,
ScoreChannelInput,
TeamRanksInput,
TeamRanksView,
ScoringProfile,
ScoringProfileInput,
Sport,
@@ -108,6 +111,7 @@ const API_ERROR_CODES: readonly string[] = [
'NETWORK',
'TIMEOUT',
'PARSE_FAILED',
'SAMPLE_DATA_BLOCKED',
]
function isApiErrorCode(value: unknown): value is ApiErrorCode {
@@ -137,8 +141,36 @@ export interface SettingsApi {
update(patch: Partial<EventSettings>): Promise<EventSettings>
}
/**
* 成绩录入接口的完整读数(`GET /api/events/{id}/entries`)。
*
* 旧服务端只回成绩行数组;设计 17 §七 之后同一响应会随行带上该小项的团体手工名次,
* 录入页一次请求就能把「成绩行 + 团体名次」都拿到,少发一轮。
*/
export interface EntryBoard {
entries: EntryWithResult[]
/** 随行下发的团体名次视图;只有存在团体手工通道的组别才非空,拿不到时为空数组。 */
teamRanks: TeamRanksView[]
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
/** 与 entries 同一次请求,但把随行下发的 teamRanks 一并交给调用方。 */
entryBoard(eventId: number): Promise<EntryBoard>
/**
* 读某个组别的团体手工名次(`GET /api/events/{id}/categories/{cid}/team-ranks`)。
*
* 组别下没有团体手工通道时服务端回一组空数组,不是 404——录入页据此隐藏区块,
* 不需要先猜有没有这个接口。
*/
teamRanks(eventId: number, categoryId: number): Promise<TeamRanksView>
/**
* 写某个组别的团体手工名次(`PUT` 同一路径),整组替换。
*
* `ranks[].rank: 0` 表示清空该单位;服务端在同一事务里重算该组名次与积分。
* 返回保存后的视图(口径与 GET 一致),调用方直接拿来回填。
*/
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
/**
@@ -496,10 +528,43 @@ function createAthletesApi(http: Http): AthletesApi {
}
}
/** 团体手工名次的路径:GET 读、PUT 写同一个地址。 */
function teamRanksPath(eventId: number, categoryId: number): string {
return `/events/${eventId}/categories/${categoryId}/team-ranks`
}
/**
* 成绩录入接口的原始响应:裸数组(旧服务端)或 `{entries, teamRanks}`(设计 17 之后)。
*
* 两种形状都能读;都不像时抛 PARSE_FAILED 而不是给空成绩单——
* 把「解析不出来」显示成「这个组没有报名项」,现场会以为报名数据丢了。
*/
function readEntryBoard(value: unknown): EntryBoard {
if (Array.isArray(value)) {
return { entries: value as EntryWithResult[], teamRanks: [] }
}
if (isRecord(value) && Array.isArray(value.entries)) {
return {
entries: value.entries as EntryWithResult[],
teamRanks: readTeamRanksViews(value.teamRanks),
}
}
throw new ApiError('PARSE_FAILED', '成绩录入接口响应格式不符', 0, value)
}
function createEventsApi(http: Http): EventsApi {
const listBoard = async (eventId: number): Promise<EntryBoard> =>
readEntryBoard(await http.request<unknown>(`/events/${eventId}/entries`))
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: (eventId) => http.request<EntryWithResult[]>(`/events/${eventId}/entries`),
entries: async (eventId) => (await listBoard(eventId)).entries,
entryBoard: listBoard,
teamRanks: async (eventId, categoryId) =>
readTeamRanksView(await http.request<unknown>(teamRanksPath(eventId, categoryId))),
saveTeamRanks: async (eventId, categoryId, input) =>
readTeamRanksView(
await http.request<unknown>(teamRanksPath(eventId, categoryId), { method: 'PUT', body: input }),
),
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
saveCategories: (eventId, categories) =>
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
+22 -2
View File
@@ -1,7 +1,7 @@
/**
* 计分通道与「个人+团体」口径的归一化。
*
* 后端旧版本 / 旧缓存里的对象可能没有 avgMode、countToTeam 这两个新字段,
* 后端旧版本 / 旧缓存里的对象可能没有 avgMode、countToTeam、awardMedals 这些新字段,
* 页面直接读会得到 undefined;统一在这里补默认值,读取处不必各写一份兜底。
*/
@@ -11,6 +11,12 @@ export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
export const DEFAULT_COUNT_TO_TEAM = true
/** 通道默认发奖牌;老服务端不下发该字段时的口径。 */
export const DEFAULT_COUNT_TO_MEDALS = true
/** 通道默认把个人奖牌并入单位金牌榜。 */
export const DEFAULT_COUNT_TO_TEAM_MEDALS = true
/** 小项默认结算奖牌;升级前的行为就是「scored=1 的小项前三名发牌」。 */
export const DEFAULT_AWARD_MEDALS = true
export function normalizeAvgMode(value: unknown): AvgMode {
return typeof value === 'string' && (AVG_MODES as readonly string[]).includes(value)
@@ -22,6 +28,18 @@ export function normalizeCountToTeam(value: unknown): boolean {
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM
}
export function normalizeCountToMedals(value: unknown): boolean {
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_MEDALS
}
export function normalizeCountToTeamMedals(value: unknown): boolean {
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM_MEDALS
}
export function normalizeAwardMedals(value: unknown): boolean {
return typeof value === 'boolean' ? value : DEFAULT_AWARD_MEDALS
}
/** 只有存在「按队内平均」通道时,录入页才需要显示平均值口径选择。 */
export function hasAggregateChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
return (channels ?? []).some((channel) => channel.basis === 'aggregate')
@@ -31,7 +49,8 @@ export function hasAggregateChannel(channels: readonly ScoreChannel[] | null | u
* 组装 PUT /api/events/{id} 的载荷。
*
* 服务端按整体替换处理,所以必须把当前小项的全部可写字段原样回传,
* 只改一个 avgMode 而漏掉别的字段会把它们清空。
* 只改一个 avgMode 而漏掉别的字段会把它们清空。录入页改平均值口径就走的这里,
* 新增字段(awardMedals 等)必须一起带上,否则改一次口径就把奖牌开关清空。
*/
export function eventInputOf(event: Event, overrides: Partial<EventInput> = {}): EventInput {
const input: EventInput = {
@@ -44,6 +63,7 @@ export function eventInputOf(event: Event, overrides: Partial<EventInput> = {}):
compare: event.compare,
recordEligible: event.recordEligible,
scored: event.scored,
awardMedals: normalizeAwardMedals(event.awardMedals),
multiplier: event.multiplier,
status: event.status,
scheduledAt: event.scheduledAt,
+7
View File
@@ -11,12 +11,19 @@ export type { ApiClientOptions, QueryParams, QueryValue } from './api'
export {
AVG_MODES,
DEFAULT_AVG_MODE,
DEFAULT_AWARD_MEDALS,
DEFAULT_COUNT_TO_MEDALS,
DEFAULT_COUNT_TO_TEAM,
DEFAULT_COUNT_TO_TEAM_MEDALS,
eventInputOf,
hasAggregateChannel,
normalizeAvgMode,
normalizeAwardMedals,
normalizeCountToMedals,
normalizeCountToTeam,
normalizeCountToTeamMedals,
} from './channels'
export { readTeamRanksView, readTeamRanksViews } from './teamRanks'
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
export { resolveAssetUrl } from './asset'
export { RealtimeClient } from './realtime'
+9 -2
View File
@@ -203,6 +203,8 @@ function normalizeOverrides(raw: unknown): EventOverride[] {
if (typeof rec.scored === 'boolean') override.scored = rec.scored
const multiplier = firstNumberOrNull(rec.multiplier)
if (multiplier !== null) override.multiplier = multiplier
// 三态:缺省表示不动该小项已有的奖牌开关,只有显式写了才落到覆盖对象上。
if (typeof rec.awardMedals === 'boolean') override.awardMedals = rec.awardMedals
if (Array.isArray(rec.channels)) override.channels = normalizeChannels(rec.channels)
out.push(override)
}
@@ -216,13 +218,18 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
const rec = asRecord(item)
const profile = firstString(rec.profile)
if (profile === null || profile === '') continue
out.push({
const channel: RuleChannel = {
scope: oneOf(rec.scope, SCOPES, 'athlete'),
basis: oneOf(rec.basis, BASES, 'result'),
profile,
// 旧规则包不写这个字段,缺省按「计入单位总分」处理。
countToTeam: normalizeCountToTeam(rec.countToTeam),
})
}
// 两个奖牌开关同样是「缺省即开」;只有规则包显式写了才落到对象上,
// 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。
if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals
if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals
out.push(channel)
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 团体手工名次接口(设计 17 §七)的响应归一化。
*
* 与 shared/api.ts 里其它 `readXxx` 一样:HTTP 回来的一律先当 unknown,
* 缺字段补默认值、脏数据丢掉,绝不把 undefined 直接塞进表格。
*/
import { isRecord } from './internal'
import type { ChannelBasis, ChannelScope, TeamRankChannelRef, TeamRankEntry, TeamRankTeam, TeamRanksView } from './types'
function num(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function str(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
const SCOPES: readonly ChannelScope[] = ['athlete', 'team']
const BASES: readonly ChannelBasis[] = ['result', 'manual', 'aggregate']
function scopeOf(value: unknown): ChannelScope {
return typeof value === 'string' && (SCOPES as readonly string[]).includes(value)
? (value as ChannelScope)
: 'team'
}
function basisOf(value: unknown): ChannelBasis {
return typeof value === 'string' && (BASES as readonly string[]).includes(value)
? (value as ChannelBasis)
: 'manual'
}
function readChannelRef(value: unknown): TeamRankChannelRef | null {
if (!isRecord(value)) return null
// 服务端契约用 channelId;有的实现直接序列化领域行时用的是 id,两种都收。
const channelId = num(value.channelId) ?? num(value.id)
if (channelId === null) return null
return {
channelId,
scope: scopeOf(value.scope),
basis: basisOf(value.basis),
profileId: num(value.profileId) ?? 0,
}
}
function readTeam(value: unknown): TeamRankTeam | null {
if (!isRecord(value)) return null
const teamId = num(value.teamId)
if (teamId === null) return null
return {
teamId,
code: str(value.code) ?? '',
name: str(value.name) ?? '',
shortName: str(value.shortName),
}
}
function readRank(value: unknown): TeamRankEntry | null {
if (!isRecord(value)) return null
const teamId = num(value.teamId)
const rank = num(value.rank)
if (teamId === null || rank === null) return null
return { teamId, rank }
}
/**
* 逐条读数:任何一段缺失都退化成空数组,而不是让整个视图变成 null——
* 一个组别没有团体名次时,服务端完全可能只回 `{teams: [...]}`。
*/
export function readTeamRanksView(value: unknown): TeamRanksView {
const rec = isRecord(value) ? value : {}
const categoryId = num(rec.categoryId)
return {
...(categoryId === null ? {} : { categoryId }),
channels: (Array.isArray(rec.channels) ? rec.channels : [])
.map(readChannelRef)
.filter((item): item is TeamRankChannelRef => item !== null),
teams: (Array.isArray(rec.teams) ? rec.teams : [])
.map(readTeam)
.filter((item): item is TeamRankTeam => item !== null),
ranks: (Array.isArray(rec.ranks) ? rec.ranks : [])
.map(readRank)
.filter((item): item is TeamRankEntry => item !== null),
}
}
/**
* 成绩录入接口随行下发的 teamRanks:既接受单个视图,也接受按组别分组的数组。
*
* 拿不到就不返回视图,调用方据此回退到 GET team-ranks(不能拿空视图冒充
* 「这个组别没有团体名次」,否则会把已有名次显示成空)。
*/
export function readTeamRanksViews(value: unknown): TeamRanksView[] {
if (Array.isArray(value)) {
return value.filter(isRecord).map(readTeamRanksView)
}
return isRecord(value) ? [readTeamRanksView(value)] : []
}
+46
View File
@@ -5,7 +5,10 @@ import {
eventInputOf,
hasAggregateChannel,
normalizeAvgMode,
normalizeAwardMedals,
normalizeCountToMedals,
normalizeCountToTeam,
normalizeCountToTeamMedals,
} from '../channels'
// 运行:node --test --import ./src/shared/tests/register.mjs src/shared/tests/
@@ -22,6 +25,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 2,
status: 'pending',
scheduledAt: null,
@@ -53,6 +57,24 @@ test('countToTeam 缺字段时默认为 true(旧数据不进单位总分才是
assert.equal(normalizeCountToTeam('false'), true)
})
// 三个奖牌开关是设计 17 新增的:老服务端不下发、旧缓存里没有,
// 缺字段必须按「开」处理,否则升级后会让所有已经配好的通道突然不发牌。
test('奖牌开关缺字段时一律默认开启', () => {
assert.equal(normalizeAwardMedals(true), true)
assert.equal(normalizeAwardMedals(false), false)
assert.equal(normalizeAwardMedals(undefined), true)
assert.equal(normalizeAwardMedals(null), true)
assert.equal(normalizeAwardMedals('false'), true)
assert.equal(normalizeCountToMedals(false), false)
assert.equal(normalizeCountToMedals(undefined), true)
assert.equal(normalizeCountToMedals(0), true)
assert.equal(normalizeCountToTeamMedals(false), false)
assert.equal(normalizeCountToTeamMedals(undefined), true)
assert.equal(normalizeCountToTeamMedals('false'), true)
})
test('只有存在 aggregate 通道时才需要平均值口径', () => {
assert.equal(hasAggregateChannel([]), false)
assert.equal(hasAggregateChannel(undefined), false)
@@ -74,6 +96,7 @@ test('eventInputOf 回传全部可写字段,并可覆盖 avgMode', () => {
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 2,
status: 'pending',
scheduledAt: null,
@@ -85,3 +108,26 @@ test('eventInputOf 回传全部可写字段,并可覆盖 avgMode', () => {
// 旧数据没有 avgMode 时按默认值回传,不能把字段丢掉。
assert.equal(eventInputOf({ ...event, avgMode: undefined as unknown as Event['avgMode'] }).avgMode, 'valid')
})
// 本任务最容易回归的一条:录入页改平均值口径走的就是 eventInputOf,
// 它必须把 awardMedals 一起带上——漏字段会让服务端按整行覆盖把那开关重置回默认值。
test('eventInputOf 不丢 awardMedals:改 avgMode 后奖牌开关仍在', () => {
const off = makeEvent({ avgMode: 'all', awardMedals: false })
const input = eventInputOf(off, { avgMode: 'valid' })
assert.equal(input.avgMode, 'valid')
assert.equal(input.awardMedals, false, '关掉奖牌的小项改口径后不能又变成开')
const on = eventInputOf(makeEvent({ awardMedals: true }), { avgMode: 'all' })
assert.equal(on.awardMedals, true)
// 旧服务端 / 旧缓存没有这个字段:回传 true,而不是 undefined 把整列清空。
const legacy = eventInputOf({ ...off, awardMedals: undefined as unknown as boolean }, {})
assert.equal(legacy.awardMedals, true)
})
test('eventInputOf 保留 avgMode 且覆盖优先于原值', () => {
const event = makeEvent({ avgMode: undefined as unknown as Event['avgMode'] })
assert.equal(eventInputOf(event).avgMode, 'valid')
assert.equal(eventInputOf(event, { avgMode: 'all' }).avgMode, 'all')
assert.equal(eventInputOf(makeEvent({ avgMode: 'all' })).avgMode, 'all')
})
+101
View File
@@ -0,0 +1,101 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiClient, ApiError } from '../api'
// 团体手工名次接口(设计 17 §七)在前端的接线:
// 老服务端只回裸数组,新服务端会包一层并随行带上 teamRanks,两种都必须能读。
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify({ ok: true, data }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const VIEW = {
channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }],
teams: [{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' }],
ranks: [{ teamId: 1, rank: 2 }],
}
test('entries 兼容裸数组与带 teamRanks 的包装响应', async () => {
const entries = [{ id: 11, eventId: 7, categoryId: 71, teamId: 1, athleteId: null }]
const legacy = new ApiClient({ fetchImpl: async () => jsonResponse(entries) })
assert.deepEqual(await legacy.events.entries(7), entries)
// 裸数组没有随行视图:entryBoard 给空数组,页面据此回退到 GET。
assert.deepEqual((await legacy.events.entryBoard(7)).teamRanks, [])
const wrapped = new ApiClient({
fetchImpl: async () => jsonResponse({ entries, teamRanks: [{ categoryId: 71, ...VIEW }] }),
})
const board = await wrapped.events.entryBoard(7)
assert.deepEqual(board.entries, entries)
assert.equal(board.teamRanks.length, 1)
assert.equal(board.teamRanks[0]?.categoryId, 71)
assert.equal(board.teamRanks[0]?.ranks[0]?.rank, 2)
// entries 与 entryBoard 打的是同一个地址。
assert.deepEqual(await wrapped.events.entries(7), entries)
})
// 既不是数组、也没有 entries 的响应是「读不出来」,不能伪装成「这个组没有报名项」。
test('entries 遇到无法识别的响应体抛 PARSE_FAILED', async () => {
const client = new ApiClient({ fetchImpl: async () => jsonResponse({ rows: [] }) })
await assert.rejects(client.events.entries(7), (error: unknown) => {
return error instanceof ApiError && error.code === 'PARSE_FAILED'
})
})
test('teamRanks 按组别路径读,缺字段的服务端响应也能收下', async () => {
let url = ''
let method: string | undefined
const client = new ApiClient({
fetchImpl: async (input, init) => {
url = String(input)
method = init?.method
return jsonResponse({ teams: VIEW.teams })
},
})
const view = await client.events.teamRanks(7, 71)
assert.equal(url, '/api/events/7/categories/71/team-ranks')
assert.equal(method ?? 'GET', 'GET')
// 没有 channels / ranks 时退化成空数组,页面不必为 undefined 写兜底。
assert.deepEqual(view.channels, [])
assert.deepEqual(view.ranks, [])
assert.deepEqual(view.teams, VIEW.teams)
assert.equal(view.categoryId, undefined)
})
test('saveTeamRanks 用 PUT 提交整组载荷,rank 0 原样发出', async () => {
let url = ''
let method: string | undefined
let body: Record<string, unknown> = {}
const client = new ApiClient({
fetchImpl: async (input, init) => {
url = String(input)
method = init?.method
body = JSON.parse(String(init?.body)) as Record<string, unknown>
return jsonResponse(VIEW)
},
})
const payload = {
scope: 'team' as const,
basis: 'manual' as const,
ranks: [
{ teamId: 1, rank: 3 },
{ teamId: 2, rank: 0 },
],
}
const view = await client.events.saveTeamRanks(7, 71, payload)
assert.equal(url, '/api/events/7/categories/71/team-ranks')
assert.equal(method, 'PUT')
assert.equal(body.scope, 'team')
assert.equal(body.basis, 'manual')
assert.deepEqual(body.ranks, payload.ranks)
// 写操作照旧带幂等键(服务端按 X-Request-Id 去重)。
assert.equal(typeof body.requestId, 'string')
assert.equal(view.ranks[0]?.teamId, 1)
})
+89 -6
View File
@@ -92,10 +92,64 @@ export interface ScoreChannel {
sortOrder: number
/** false 表示该通道产生的分只记在个人头上,不进单位总分。 */
countToTeam: boolean
/**
* false 表示该通道产出的名次不发奖牌(设计 17 §八「奖牌通道」)。
* 缺字段 = 发牌:老服务端根本不下发它,归一化见 normalizeCountToMedals。
*/
countToMedals?: boolean
/**
* false 表示该通道的个人奖牌不并入单位金牌榜。
* 只对 scope=athlete 的通道有意义——scope=team 的名次本来就记在单位头上。缺字段 = 并入。
*/
countToTeamMedals?: boolean
}
export type ScoreChannelInput = Omit<ScoreChannel, 'id' | 'eventId'>
/** GET team-ranks 里的通道引用:页面用它判断该组别有没有团体手工通道。 */
export interface TeamRankChannelRef {
channelId: number
scope: ChannelScope
basis: ChannelBasis
profileId: number
}
/** GET team-ranks 里的单位条目:只列启用单位,假删除的不列。 */
export interface TeamRankTeam {
teamId: number
code: string
name: string
shortName: string | null
}
/** 一个单位的手工名次;引擎只认 rank >= 1 的值。 */
export interface TeamRankEntry {
teamId: number
rank: number
}
/**
* 团体手工名次的视图(设计 17 §七)。
*
* `GET /api/events/{id}/categories/{cid}/team-ranks` 的响应体;成绩录入接口随行下发的
* `teamRanks` 与它同构,只是可能额外带 `categoryId` 供前端把数据归到对应组别。
*/
export interface TeamRanksView {
/** 组别 id;按组别路径取时响应体里没有(路径已经表明了),随行下发时才有。 */
categoryId?: number
/** 该组别参与团体手工名次的通道。 */
channels: TeamRankChannelRef[]
teams: TeamRankTeam[]
ranks: TeamRankEntry[]
}
/** PUT team-ranks 的请求体;`rank: 0` 表示清空该单位。 */
export interface TeamRanksInput {
scope: ChannelScope
basis: ChannelBasis
ranks: TeamRankEntry[]
}
export interface Event {
id: number
sportId: number
@@ -108,6 +162,11 @@ export interface Event {
recordEligible: boolean
/** false 表示只排名不计分(events.scored = 0)。 */
scored: boolean
/**
* false 表示该小项只出积分不出奖牌(events.award_medals,设计 17 §八)。
* 服务端总会下发它;老服务端 / 旧缓存缺字段时按 true 归一(normalizeAwardMedals)。
*/
awardMedals: boolean
multiplier: number
status: EventStatus
scheduledAt: string | null
@@ -181,20 +240,27 @@ export interface ImportConflictFields {
age: number | null
teamCode: string
employeeNo: string | null
/** 人员级证件号;它是人员的唯一标识,也是冲突的决策键(decisionKeyOf)。 */
idCard: string | null
/** 人员级号码布;参与冲突差异展示。 */
bib: string | null
}
export type ImportConflictField = keyof ImportConflictFields
/** 号码布 / 姓名 + 单位命中的人与报名表里的人员信息不一致;本轮不落库,等用户决策。 */
/** 证件号 / 号码布 / 姓名 + 单位命中的人与报名表里的人员信息不一致;本轮不落库,等用户决策。 */
export interface ImportConflict {
/** 报名表原始行号(含表头,从 1 开始)。 */
row: number
idCard: string
/** 报名表该行的号码布;面板按号码布优先给它分决策键(decisionKeyOf)。 */
/** 报名表该行的号码布;证件号为空时它就是这条冲突的决策键。 */
bib: string
athleteId: number
/**
* true 表示这不是人员字段不一致,而是「同一份表里同一证件号 + 同一个小项同一个组别」的真重复行:
* 两行字段可能完全一样,但要不要覆盖已落库的那条报名必须由用户决定。
*/
duplicate: boolean
existing: ImportConflictFields
incoming: ImportConflictFields
changedFields: ImportConflictField[]
@@ -203,17 +269,24 @@ export interface ImportConflict {
/** keep-existing = 保留库里已有的人员字段;keep-new = 用表里的字段更新该人员。 */
export type ConflictAction = 'keep-existing' | 'keep-new'
/** 服务端按证件号优先、为空时取号码布重算决策键,因此两个字段都要带。 */
/** 决策键由服务端判定:证件号优先,为空时取号码布,因此两个字段都要带。 */
export interface ImportConflictDecision {
idCard: string
bib: string
action: ConflictAction
}
/** 号码布不同 → 按「同名的另一个人」新建档案的一行;只作报告,不拦截导入。 */
/**
* 姓名 + 单位相同、但证件号(或号码布)不同 → 按「同名的另一个人」新建档案的一行;
* 只作报告,不拦截导入。
*/
export interface SameNameNewPerson {
name: string
teamCode: string
/** 表里给的新证件号,也是新建档案用的证件号。 */
newIdCard: string
/** 库里同名同单位那个人已有的证件号。 */
existingIdCard: string
/** 表里给的新号码布,也是新建档案用的号码布。 */
newBib: string
/** 库里同名同单位那个人已有的号码布。 */
@@ -225,9 +298,11 @@ export interface EntryImportResult {
updated: number
skipped: number
errors: string[]
/** 非错误提示(例如同一证件号在本表里出现多次 = 同一个人报多个组别);不影响导入结果。 */
notices: string[]
/** 本轮未落库的人员冲突;resolve 接口返回的则是剩余未决策项。 */
conflicts: ImportConflict[]
/** 本次按「同名的另一个人」新建的档案(号码布不同);仅提示,不阻塞导入。 */
/** 本次按「同名的另一个人」新建的档案(证件号 / 号码布不同);仅提示,不阻塞导入。 */
sameNameNewPersons: SameNameNewPerson[]
}
@@ -667,6 +742,10 @@ export interface RuleChannel {
profile: string
/** 缺省视为 true;「个人+团体」模板的个人通道会显式写 false。 */
countToTeam?: boolean
/** 缺省视为 true(该通道发奖牌);规则包显式写 false 才不发。 */
countToMedals?: boolean
/** 缺省视为 true(个人奖牌并入单位榜);规则包显式写 false 才不并入。 */
countToTeamMedals?: boolean
}
export interface ChannelTemplateMatch {
@@ -686,6 +765,8 @@ export interface EventOverride {
scored?: boolean
/** 单个项目的计分权重覆盖;缺省表示不动。 */
multiplier?: number
/** 单个小项的奖牌结算开关覆盖;缺省表示不动该字段。 */
awardMedals?: boolean
channels?: RuleChannel[]
}
@@ -714,11 +795,13 @@ export type ApiErrorCode =
| 'STATE_INVALID'
| 'RATE_LIMITED'
| 'INTERNAL'
// 以下四个由客户端产生,服务端不会返回,但前端要能统一处理
// 以下几个由客户端产生,服务端不会返回,但前端要能统一处理
| 'NETWORK'
| 'TIMEOUT'
| 'ABORTED'
| 'PARSE_FAILED'
// 示例数据模式拦下破坏性写操作:用它把「没有提交」与「真的失败了」区分开
| 'SAMPLE_DATA_BLOCKED'
export interface ApiErrorBody {
code: ApiErrorCode