fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -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 再决定是否调用。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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\('删除小项'\)/)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
compare: 'asc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
|
||||
@@ -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, '写请求撞上后端不可达后,页面应切到示例数据标记')
|
||||
})
|
||||
@@ -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, /当前为访客只读视图,团体名次只能查看,无法录入/)
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)] : []
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user