feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
—— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。
## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。
## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。
## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。
## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。
## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。
## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
This commit is contained in:
1 parent
9663d5bfb0
commit
7a3e4ad365
67 files changed
+4658
-228
No files matched your search
@@ -9,6 +9,7 @@ import type {
|
||||
ResultStatus,
|
||||
ScoreChannel,
|
||||
ScoringProfile,
|
||||
TeamRankEntry,
|
||||
TieMode,
|
||||
} from '@/shared'
|
||||
|
||||
@@ -18,9 +19,29 @@ import type {
|
||||
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
|
||||
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
|
||||
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
|
||||
* 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源,
|
||||
* 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。
|
||||
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
|
||||
*/
|
||||
|
||||
/** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */
|
||||
export const CHANNEL_SCOPE_LABELS: Record<ChannelScope, string> = {
|
||||
athlete: '个人名次',
|
||||
team: '团体名次',
|
||||
}
|
||||
|
||||
/** 通道「名次来源」的界面称谓。 */
|
||||
export const CHANNEL_BASIS_LABELS: Record<ChannelBasis, string> = {
|
||||
result: '按成绩自动排名',
|
||||
manual: '手动录名次',
|
||||
aggregate: '按队内平均',
|
||||
}
|
||||
|
||||
/** 通道在界面上的全称,如「团体名次 / 手动录名次」。 */
|
||||
export function channelLabelOf(scope: ChannelScope, basis: ChannelBasis): string {
|
||||
return `${CHANNEL_SCOPE_LABELS[scope]} / ${CHANNEL_BASIS_LABELS[basis]}`
|
||||
}
|
||||
|
||||
export interface ScorableRow {
|
||||
entryId: number
|
||||
teamId: number
|
||||
@@ -107,8 +128,11 @@ function groupPoints(
|
||||
categoryMultiplier: number,
|
||||
): number {
|
||||
if (startRank <= 0 || size <= 0) return 0
|
||||
// lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。
|
||||
if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) return 0
|
||||
// lastPlaceZero 只抹「末位那一档」,判据是起始名次**正好落在这档**(scoring.go groupPoints)。
|
||||
// 这种组必然只有一个人(startRank == lastRank 意味着 size == 1,lastRank 是所有组占位名次的
|
||||
// 最大值)。判据若写成「区间跨过末位」,两人并列第 1(占 1、2,末位档 = 2)会被整组归零,
|
||||
// 而服务端照发第 1 名的分——这就是现场「预览 0、服务端 10」的来历。
|
||||
if (profile.lastPlaceZero === true && startRank === lastRank) return 0
|
||||
|
||||
const mode = tieModeOf(profile)
|
||||
if (mode === 'average') {
|
||||
@@ -144,6 +168,91 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
|
||||
return out
|
||||
}
|
||||
|
||||
/** 一行的归属份额是否构成联队(与 Go 的 jointTeamEntry 同一判据:至少两个单位)。 */
|
||||
function isJointShares(row: ScorableRow): boolean {
|
||||
return row.shares !== undefined && row.shares.length >= 2
|
||||
}
|
||||
|
||||
/**
|
||||
* 找「主单位 teamID 在本组别的联队报名」(scoring.go jointTeamEntry)。
|
||||
*
|
||||
* 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上,
|
||||
* 所以录名次时得回头找这条报名,否则 A+B 的名次会整份记在 A 头上、B 一分没有。
|
||||
* 只认团体行(athleteId 为 0):个人报名的名次是个人名次,不进团体通道。
|
||||
*/
|
||||
function jointTeamRow(rows: readonly ScorableRow[], teamId: number): ScorableRow | null {
|
||||
let found: ScorableRow | null = null
|
||||
for (const row of rows) {
|
||||
if ((row.athleteId ?? 0) !== 0 || row.teamId !== teamId || !isJointShares(row)) continue
|
||||
// 同一主单位出现份额不一致的两条时宁可退回独占,也不能随便挑一条把分拆错。
|
||||
if (found !== null && !sameShares(found, row)) return null
|
||||
found = row
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function sameShares(a: ScorableRow, b: ScorableRow): boolean {
|
||||
const left = a.shares ?? []
|
||||
const right = b.shares ?? []
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((share, index) => share.teamId === right[index]?.teamId && share.weight === right[index]?.weight)
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按单位录名次」通道(scope=team + basis=manual)的候选来源(scoring.go collectTeamManualCandidates)。
|
||||
*
|
||||
* 两个来源,且**只看团体行**(athleteId 为 0):
|
||||
* 1. teamRanks(channel_manual_ranks,录入页底部的「团体名次」卡片):没有对应的报名项,
|
||||
* 用合成行(entryId 0)承载,分直接记在单位头上;
|
||||
* 2. 兜底:团体行上录的名次(results.rank),纯团体项目(篮球等)沿用原录入流程。
|
||||
*
|
||||
* 同一单位两个来源都有时以 teamRanks 为准;rank <= 0 视为未填 / 清空,直接忽略。
|
||||
* 个人报名行(athleteId != 0)的名次绝不进团体通道——否则双通道项目第 2 次发布时会把
|
||||
* 刚写回 results.rank 的个人名次当成团体名次再发一次分。
|
||||
* 录入页的「团体名次」卡片此前完全没喂给预览,于是「名次填在卡片、预览全 0」成了现场的 P0。
|
||||
*/
|
||||
function collectTeamManualCandidates(
|
||||
rows: readonly ScorableRow[],
|
||||
teamRanks: readonly TeamRankEntry[],
|
||||
): Candidate[] {
|
||||
const overrideOf = new Map<number, number>()
|
||||
const overrides: number[] = []
|
||||
for (const entry of teamRanks) {
|
||||
if (entry.teamId === 0 || entry.rank <= 0) continue
|
||||
if (overrideOf.has(entry.teamId)) continue
|
||||
overrideOf.set(entry.teamId, entry.rank)
|
||||
overrides.push(entry.teamId)
|
||||
}
|
||||
|
||||
const out: Candidate[] = []
|
||||
const taken = new Set<number>()
|
||||
for (const row of rows) {
|
||||
if ((row.athleteId ?? 0) !== 0 || row.status !== 'ok') continue
|
||||
if (row.rank === null || row.rank <= 0) continue
|
||||
const rankIn = overrideOf.get(row.teamId) ?? row.rank
|
||||
taken.add(row.teamId)
|
||||
out.push({ row, value: 0, rankIn, subject: 0, teamId: row.teamId })
|
||||
}
|
||||
for (const teamId of overrides) {
|
||||
if (taken.has(teamId)) continue
|
||||
const rankIn = overrideOf.get(teamId) ?? 0
|
||||
const joint = jointTeamRow(rows, teamId)
|
||||
if (joint !== null) {
|
||||
out.push({ row: joint, value: 0, rankIn, subject: 0, teamId })
|
||||
continue
|
||||
}
|
||||
// 合成行:entryId 0 表示「不对应任何一条报名项」,团体名次的主体是单位。
|
||||
out.push({
|
||||
row: { entryId: 0, teamId, athleteId: 0, resultValue: null, rank: rankIn, status: 'ok', isTie: false },
|
||||
value: 0,
|
||||
rankIn,
|
||||
subject: 0,
|
||||
teamId,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function sortByValue(cands: Candidate[], compare: CompareMode): void {
|
||||
cands.sort((a, b) => {
|
||||
if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId
|
||||
@@ -249,9 +358,11 @@ function scoreChannel(
|
||||
event: Event,
|
||||
category: EventCategory | null,
|
||||
scoreEnabled: boolean,
|
||||
teamRanks: readonly TeamRankEntry[],
|
||||
): RankedRow[] {
|
||||
// compare=none 无法按值排名,退回录入名次;服务端当前会拒绝该组合(见 P1-6)。
|
||||
const basis: ChannelBasis = channel.basis === 'manual' || event.compare === 'none' ? 'manual' : channel.basis
|
||||
// compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回
|
||||
// ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。
|
||||
const basis: ChannelBasis = channel.basis
|
||||
const eventMultiplier = effectiveMultiplier(event.multiplier)
|
||||
const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1)
|
||||
|
||||
@@ -261,7 +372,9 @@ function scoreChannel(
|
||||
sortByValue(aggregated, event.compare)
|
||||
groups = groupByValue(aggregated)
|
||||
} else if (basis === 'manual') {
|
||||
const cands = collectCandidates(rows, 'manual')
|
||||
// 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。
|
||||
const cands =
|
||||
channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual')
|
||||
sortManual(cands)
|
||||
groups = groupByManualRank(cands)
|
||||
} else {
|
||||
@@ -313,6 +426,22 @@ export interface RowTeamShare {
|
||||
points: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览与服务端**都算不出来**的通道配置。
|
||||
*
|
||||
* 目前只有一种:compare=none 的小项配了 basis=result / basis=aggregate 的通道。
|
||||
* 服务端 domain.ScoreEvent 对这种配置直接返回 ErrCompareRequired,发布会 409(STATE_INVALID);
|
||||
* 预览以前把它静默降级成「按录入名次」算出一份服务端根本拿不到的分,操作员照着这个数发布只会失败。
|
||||
*/
|
||||
export interface ScoreConfigError {
|
||||
/** 出问题的通道 id(通道还没入库时可能是 0)。 */
|
||||
channelId: number
|
||||
/** 通道在界面上的称谓,如「团体名次 / 按成绩自动排名」。 */
|
||||
channelLabel: string
|
||||
/** 人话原因,页面直接显示给操作员。 */
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface EventScoreResult {
|
||||
/** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
|
||||
rowPoints: Map<number, number>
|
||||
@@ -329,8 +458,33 @@ export interface EventScoreResult {
|
||||
ranks: Map<number, number | null>
|
||||
/** 按单位聚合的得分增量,用于预览面板。 */
|
||||
teamPoints: Map<number, number>
|
||||
/**
|
||||
* 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。
|
||||
*
|
||||
* 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单,
|
||||
* 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。
|
||||
*/
|
||||
unitPoints: Map<number, number>
|
||||
/** 配置错误(非 null 时所有分都是空的,页面应显示 message 而不是数字)。 */
|
||||
error: ScoreConfigError | null
|
||||
}
|
||||
|
||||
/** compare=none 的小项排不出名次:basis=result / aggregate 的通道服务端一律拒绝(scoring.go 的两处 ErrCompareRequired)。 */
|
||||
function compareRequiredError(event: Event, channel: ScoreChannel): ScoreConfigError | null {
|
||||
if (event.compare !== 'none') return null
|
||||
if (channel.basis !== 'result' && channel.basis !== 'aggregate') return null
|
||||
const label = channelLabelOf(channel.scope, channel.basis)
|
||||
return {
|
||||
channelId: channel.id,
|
||||
channelLabel: label,
|
||||
message:
|
||||
`通道「${label}」要按成绩排名,而小项「${event.name}」是「不比较」(compare=none):` +
|
||||
'服务端算不出名次、发布会直接拒绝(STATE_INVALID)。' +
|
||||
'请到「基础数据 → 计分通道」把它改成「手动录名次」,或先给小项设置比较方向(升序 / 降序)。',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。
|
||||
*
|
||||
@@ -358,6 +512,12 @@ export interface ScoreContext {
|
||||
profiles: readonly ScoringProfile[]
|
||||
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */
|
||||
weight: number
|
||||
/**
|
||||
* channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有
|
||||
* scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。
|
||||
* 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。
|
||||
*/
|
||||
teamRanks?: readonly TeamRankEntry[]
|
||||
}
|
||||
|
||||
/** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */
|
||||
@@ -372,6 +532,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
const scoreEnabled = event.scored && !rankOnly
|
||||
const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels)
|
||||
const weight = context.weight > 0 ? context.weight : 1
|
||||
const teamRanks = context.teamRanks ?? []
|
||||
|
||||
const rowPoints = new Map<number, number>()
|
||||
const rowTeamPoints = new Map<number, number>()
|
||||
@@ -379,6 +540,17 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
const ranks = new Map<number, number | null>()
|
||||
const rankScope = new Map<number, ChannelScope>()
|
||||
const teamPoints = new Map<number, number>()
|
||||
const unitPoints = new Map<number, number>()
|
||||
|
||||
// 配置本身就出不来分的(compare=none + basis=result/aggregate):与服务端一样停在这里,
|
||||
// 一分不算、名次也不给,页面按 error 显示原因——绝不再降级算出一份服务端拿不到的分。
|
||||
for (const channel of channels) {
|
||||
const error = compareRequiredError(event, channel)
|
||||
if (error !== null) {
|
||||
for (const row of rows) ranks.set(row.entryId, null)
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error }
|
||||
}
|
||||
}
|
||||
|
||||
const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>()
|
||||
for (const row of rows) {
|
||||
@@ -388,7 +560,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
for (const channel of channels) {
|
||||
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
|
||||
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
|
||||
const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null)
|
||||
const ranked = scoreChannel(
|
||||
rows,
|
||||
channel,
|
||||
profile ?? EMPTY_PROFILE,
|
||||
event,
|
||||
context.category,
|
||||
scoreEnabled && profile !== null,
|
||||
teamRanks,
|
||||
)
|
||||
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
|
||||
const countToTeam = normalizeCountToTeam(channel.countToTeam)
|
||||
for (const row of ranked) {
|
||||
@@ -424,6 +604,9 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
if (!countToTeam) continue
|
||||
for (const part of parts) {
|
||||
teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points)
|
||||
// 不挂在报名项上的那一份单独留一份账:它已经随发布 / 保存团体名次进过榜单,
|
||||
// 预览要扣已发布的分时得能把它扣掉(见 EventScoreResult.unitPoints)。
|
||||
if (row.entryId <= 0) unitPoints.set(part.teamId, (unitPoints.get(part.teamId) ?? 0) + part.points)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,5 +614,5 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
for (const row of rows) {
|
||||
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
|
||||
}
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints }
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null }
|
||||
}
|
||||
@@ -11,22 +11,25 @@ import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../../api/scoring'
|
||||
import type { BaseDataTabKey } from './tabs'
|
||||
import { newChannelRow, type ChannelRow } from './channelRows'
|
||||
import { newChannelRow, compareConflictMessage, type ChannelRow } from './channelRows'
|
||||
|
||||
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
|
||||
|
||||
const data = useBaseData()
|
||||
const toast = useToastStore()
|
||||
|
||||
// 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts),两处措辞漂移会让操作员
|
||||
// 对不上「说的是哪条通道」。
|
||||
const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
|
||||
{ value: 'athlete', label: '个人名次' },
|
||||
{ value: 'team', label: '团体名次' },
|
||||
{ value: 'athlete', label: CHANNEL_SCOPE_LABELS.athlete },
|
||||
{ value: 'team', label: CHANNEL_SCOPE_LABELS.team },
|
||||
]
|
||||
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
|
||||
{ value: 'result', label: '按成绩自动排名' },
|
||||
{ value: 'manual', label: '手动录名次' },
|
||||
{ value: 'aggregate', label: '按队内平均' },
|
||||
{ value: 'result', label: CHANNEL_BASIS_LABELS.result },
|
||||
{ value: 'manual', label: CHANNEL_BASIS_LABELS.manual },
|
||||
{ value: 'aggregate', label: CHANNEL_BASIS_LABELS.aggregate },
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,7 @@ const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint:
|
||||
|
||||
interface RowError {
|
||||
scope?: string
|
||||
basis?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
@@ -128,6 +132,11 @@ function validate(): boolean {
|
||||
if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) {
|
||||
rowError.profile = '必须选择一个积分模板'
|
||||
}
|
||||
// compare=none 的小项排不出名次(名次全靠人工录入):basis=result / aggregate 保存下去,
|
||||
// 到发布时才会被服务端拒绝(409 STATE_INVALID),那时成绩已经录了一半。规则包导入早有这条
|
||||
// 守卫(rules/diff.go checkCompareCompatible),管理端手工配也得在保存前拦下。
|
||||
const conflict = compareConflictMessage(selectedEvent.value, row.basis)
|
||||
if (conflict !== null) rowError.basis = conflict
|
||||
if (Object.keys(rowError).length > 0) next[row.key] = rowError
|
||||
})
|
||||
errors.value = next
|
||||
@@ -258,12 +267,13 @@ async function save(): Promise<void> {
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
|
||||
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.basis }">
|
||||
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
|
||||
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
|
||||
</span>
|
||||
<span v-if="errors[row.key]?.basis" class="row-error">{{ errors[row.key]?.basis }}</span>
|
||||
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
emptyTeamPick,
|
||||
registeredAthleteIds,
|
||||
registeredTeamIds,
|
||||
registeredTeamSquads,
|
||||
selectableTeamIds,
|
||||
selectionAllowsSubmit,
|
||||
setPickedTeams,
|
||||
setTeamPickSquad,
|
||||
teamSelectionAllowsSubmit,
|
||||
toggleMemberPick,
|
||||
toggleTeamPick,
|
||||
@@ -116,6 +118,8 @@ const filteredAthletes = computed(() => {
|
||||
const checkedAthletes = computed(() => teamAthletes.value.filter((athlete) => checked.value.includes(athlete.id)))
|
||||
|
||||
const registeredTeams = computed(() => registeredTeamIds(entries.value, currentCategory.value?.id))
|
||||
/** 各单位已报名的队名:徽标上写出来,操作员才知道第二支队该填哪个名字。 */
|
||||
const registeredSquads = computed(() => registeredTeamSquads(entries.value, currentCategory.value?.id))
|
||||
const registeredAthletes = computed(() =>
|
||||
registeredAthleteIds(entries.value, currentCategory.value?.id, currentTeam.value?.id),
|
||||
)
|
||||
@@ -184,6 +188,23 @@ function memberCount(teamId: number): number {
|
||||
return teamPick.value.memberIds[teamId]?.length ?? 0
|
||||
}
|
||||
|
||||
/** 某个单位输入框里的队名原文(没填过 = 空串 = 不区分队伍)。 */
|
||||
function squadTextOf(teamId: number): string {
|
||||
return teamPick.value.squads?.[teamId] ?? ''
|
||||
}
|
||||
|
||||
function onSquadInput(teamId: number, event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
teamPick.value = setTeamPickSquad(teamPick.value, teamId, input.value)
|
||||
}
|
||||
|
||||
/** 「已报名」徽标的文案:报过队名的写出来(`已报名(一队、二队)`),没队名的老报名只写「已报名」。 */
|
||||
function registeredSquadLabel(teamId: number): string {
|
||||
const squads = registeredSquads.value.get(teamId) ?? []
|
||||
return squads.length === 0 ? '已报名' : `已报名(${squads.join('、')})`
|
||||
}
|
||||
|
||||
function isExpanded(teamId: number): boolean {
|
||||
return expandedTeams.value.includes(teamId)
|
||||
}
|
||||
@@ -560,6 +581,8 @@ async function submitTeam(): Promise<void> {
|
||||
团体 / 对抗项目按单位报名:从已配置的参赛单位里勾选参加本组别的单位(默认一个都不勾,
|
||||
部分单位不参加就不勾它)。每个单位生成一条报名,队员名单可选——展开某个单位即可登记队员,
|
||||
一个队员都不勾则只报单位。「全选」只勾启用中且未报名的单位,已停用单位要单独勾。
|
||||
同一个单位要派多支队(一队 / 二队):勾上它,在旁边的「队伍」格里填队名,
|
||||
每个队名各是一条报名、各占各的名次;不填就与过去完全一样。
|
||||
</p>
|
||||
|
||||
<div class="team-list">
|
||||
@@ -580,8 +603,24 @@ async function submitTeam(): Promise<void> {
|
||||
<span class="picker-name">{{ team.name }}{{ team.active ? '' : '(已停用)' }}</span>
|
||||
<span class="bd-note bd-mono">{{ team.code }}</span>
|
||||
</label>
|
||||
<!--
|
||||
队伍:勾上单位后才出现。留空 = 不区分队伍(老数据与单队单位的默认,
|
||||
提交出来的报名行与改造前一模一样);填「一队 / 二队」就是给这个单位再报一支队,
|
||||
同一个单位在同一组别就能有两支队伍各占一个名次。
|
||||
-->
|
||||
<input
|
||||
v-if="isTeamPicked(team.id)"
|
||||
class="mx-cell-input team-squad-input"
|
||||
:value="squadTextOf(team.id)"
|
||||
:disabled="!data.canWrite.value"
|
||||
placeholder="队伍(留空=不区分)"
|
||||
title="同一个单位在一个组别可以派多个队:填「一队 / 二队」后每支队各留一条报名、各占各的名次;留空就是过去的老样子。"
|
||||
@input="onSquadInput(team.id, $event)"
|
||||
/>
|
||||
<div class="team-actions">
|
||||
<span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">已报名</span>
|
||||
<span v-if="registeredTeams.has(team.id)" class="mx-badge mx-badge-neutral">
|
||||
{{ registeredSquadLabel(team.id) }}
|
||||
</span>
|
||||
<span v-if="memberCount(team.id) > 0" class="mx-badge mx-badge-info">
|
||||
队员 {{ memberCount(team.id) }} 人
|
||||
</span>
|
||||
@@ -618,9 +657,10 @@ async function submitTeam(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<p class="bd-note">
|
||||
每个单位生成一条报名(同组别 + 单位只留一条),队员名单按列表顺序写入。
|
||||
每个单位生成一条报名(同组别 + 单位 + 队伍只留一条),队员名单按列表顺序写入。
|
||||
已报名的单位也勾得上:再次提交会用本次登记的队员覆盖原名单,一个队员都不勾就会把原名单清空,
|
||||
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位。
|
||||
所以「全选」会跳过已报名的单位,避免批量提交时误清名单;需要改名单时单独勾这一个单位,
|
||||
队名要与要改的那支队写成一模一样的字,写成别的队名会新增一支队。
|
||||
</p>
|
||||
<p class="bd-note">队员名单只存姓名,不会带动运动员档案;证件号、号码布与种子等字段仍通过下方报名表导入填写。</p>
|
||||
</template>
|
||||
@@ -714,6 +754,12 @@ async function submitTeam(): Promise<void> {
|
||||
.team-check {
|
||||
min-width: 0;
|
||||
}
|
||||
/* 队伍输入框:只在勾上单位后出现,固定一档宽度,不挤掉右侧的徽标与「登记队员」按钮。
|
||||
输入框里是普通文本(不是数字),可以直接当字符串用。 */
|
||||
.team-squad-input {
|
||||
flex: none;
|
||||
width: 150px;
|
||||
}
|
||||
.team-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AggregateMode, ChannelBasis, ChannelScope, EventType } from '@/shared'
|
||||
import type { AggregateMode, ChannelBasis, ChannelScope, CompareMode, EventType } from '@/shared'
|
||||
import { DEFAULT_AGGREGATE_MODE } from '@/shared'
|
||||
|
||||
/** 计分通道编辑器里的一行:新建行或已存库通道的回填(已存库的行见 ChannelsSection.vue 的 toRow)。 */
|
||||
@@ -51,3 +51,26 @@ export function newChannelRow(key: number, eventType: EventType, profileId: stri
|
||||
countToTeamMedals: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「compare=none 的小项不得配 basis=result / basis=aggregate 通道」的界面守卫,返回人话原因(合法时 null)。
|
||||
*
|
||||
* 这两种通道都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算,服务端
|
||||
* domain.ScoreEvent 会直接返回 ErrCompareRequired:以前这条配置能存下去,一直到发布会 409
|
||||
* (STATE_INVALID),那时成绩已经录了一半。规则包导入侧早有同样的守卫
|
||||
* (server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧见
|
||||
* repo_events.go 的 replaceChannels;这里补的是手工在管理端点「保存全部」的那条路。
|
||||
*
|
||||
* 抽成纯函数放在这里:校验规则要能被测试钉住,页面(ChannelsSection.vue)只负责显示。
|
||||
*/
|
||||
export function compareConflictMessage(
|
||||
event: { name: string; compare: CompareMode } | null,
|
||||
basis: ChannelBasis,
|
||||
): string | null {
|
||||
if (event === null || event.compare !== 'none') return null
|
||||
if (basis !== 'result' && basis !== 'aggregate') return null
|
||||
return (
|
||||
`小项「${event.name}」是「不比较」(compare=none),排不出名次:` +
|
||||
'这条通道要按成绩排名,服务端算不出分、发布会失败。请改成「手动录名次」,或先给小项设置比较方向。'
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
|
||||
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
|
||||
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
|
||||
* (纯团体行是 (组别, 单位, 队伍),见迁移 0025)与行级报错回显都与文件导入完全一致,
|
||||
* 不必为选择器新增服务端接口。
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -12,11 +13,22 @@ import type {
|
||||
ImportConflictDecision,
|
||||
ImportConflictField,
|
||||
} from '@/shared'
|
||||
import { teamSquadLabel } from '@/shared'
|
||||
|
||||
/**
|
||||
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
|
||||
* 这一列就是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
|
||||
*
|
||||
* 声明在列清单之前:它要出现在清单里(列在最后),而清单是模块加载期求值的。
|
||||
*/
|
||||
export const SQUAD_COLUMN = 'squad'
|
||||
|
||||
/**
|
||||
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
|
||||
*
|
||||
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
|
||||
* 队伍(squad)同样可选、同样在最后:留空 = 不区分队伍,老数据与单队单位都是它,
|
||||
* 报名行为与升级前完全一致(服务端那一列也是 optional,缺列不报错)。
|
||||
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
|
||||
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
|
||||
*/
|
||||
@@ -33,13 +45,14 @@ export const REQUIRED_COLUMNS = [
|
||||
'heat',
|
||||
'status',
|
||||
'member_names',
|
||||
SQUAD_COLUMN,
|
||||
] as const
|
||||
|
||||
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
|
||||
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
|
||||
|
||||
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
|
||||
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat', SQUAD_COLUMN] as const
|
||||
|
||||
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
|
||||
const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS
|
||||
@@ -69,6 +82,7 @@ export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
|
||||
heat: ['批次', '批次号'],
|
||||
status: ['状态', '报名状态'],
|
||||
member_names: ['队员名单'],
|
||||
squad: ['队伍', '队名', '队伍名'],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,11 +191,11 @@ export function atSignNameError(kind: string, name: string): string | null {
|
||||
export const TEMPLATE_SHEET_MOCK_HINT =
|
||||
'示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板'
|
||||
|
||||
/** 模板里的示例行:两种语言共用,只有表头不同。 */
|
||||
/** 模板里的示例行:两种语言共用,只有表头不同。队名列留空(示例行本来就该被填表人删掉)。 */
|
||||
const TEMPLATE_ROWS: readonly string[][] = [
|
||||
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', ''],
|
||||
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', ''],
|
||||
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队'],
|
||||
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', '', ''],
|
||||
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', '', ''],
|
||||
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队', ''],
|
||||
]
|
||||
|
||||
/** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */
|
||||
@@ -201,14 +215,24 @@ export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number):
|
||||
return mode === 'team' || chosenCount > 0
|
||||
}
|
||||
|
||||
/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */
|
||||
/**
|
||||
* 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。
|
||||
*
|
||||
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
|
||||
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
|
||||
*/
|
||||
export function entryRowLabel(row: {
|
||||
athleteName?: string | null
|
||||
memberNames?: string[] | null
|
||||
teamShort?: string | null
|
||||
teamName?: string | null
|
||||
squad?: string | null
|
||||
}): string {
|
||||
return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || ''
|
||||
return (
|
||||
row.athleteName ||
|
||||
row.memberNames?.[0] ||
|
||||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
|
||||
)
|
||||
}
|
||||
|
||||
export interface SelectionInput {
|
||||
@@ -227,7 +251,8 @@ export interface SelectionInput {
|
||||
* 生成与导入表头一致的二维数组。
|
||||
*
|
||||
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
|
||||
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。
|
||||
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名;
|
||||
* 队名列留空——个人报名不带队伍(服务端也只对纯团体报名写它)。
|
||||
*/
|
||||
export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
const status = input.status ?? 'registered'
|
||||
@@ -248,6 +273,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] {
|
||||
'',
|
||||
status,
|
||||
'',
|
||||
// 队伍留空:个人报名按人走,没有「这个单位的第几支队」这回事。
|
||||
'',
|
||||
])
|
||||
}
|
||||
return rows
|
||||
@@ -266,6 +293,11 @@ export interface TeamSelection {
|
||||
teamCode: string
|
||||
/** 队员姓名;空数组表示只报单位,名单之后可以再补。 */
|
||||
memberNames: string[]
|
||||
/**
|
||||
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
|
||||
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
|
||||
*/
|
||||
squad?: string
|
||||
}
|
||||
|
||||
export interface TeamSelectionInput {
|
||||
@@ -277,10 +309,12 @@ export interface TeamSelectionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多单位团体报名:每个单位一行,队员姓名进 member_names。
|
||||
* 多单位团体报名:每个单位一行,队员姓名进 member_names,队伍进最后一列。
|
||||
*
|
||||
* 服务端对 athlete_name 为空的行只按 (组别, 单位, athlete_id IS NULL) upsert,
|
||||
* 所以同组别 + 单位仍然只留一条报名,重复提交是更新(队员留空时即清空)队员名单而不是新增。
|
||||
* 服务端对 athlete_name 为空的行按 (组别, 单位, 队伍) upsert,
|
||||
* 所以同组别 + 同单位 + 同队伍仍然只留一条报名(重复提交是更新队员名单而不是新增),
|
||||
* 而同一个单位换个队名再提交就是**第二条报名** —— 一个单位派两个队正是这样报的。
|
||||
* 队伍留空 = 不区分队伍,与服务端那一列(optional)同口径。
|
||||
*/
|
||||
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
const status = input.status ?? 'registered'
|
||||
@@ -300,6 +334,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
'',
|
||||
status,
|
||||
team.memberNames.join('/'),
|
||||
// 队名两侧空白去掉再写:服务端读表时就 TrimSpace,这里先归一,免得「一队 」与「一队」成了两个队。
|
||||
(team.squad ?? '').trim(),
|
||||
])
|
||||
}
|
||||
return rows
|
||||
@@ -316,6 +352,13 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
|
||||
export interface TeamPickState {
|
||||
teamIds: number[]
|
||||
memberIds: Record<number, number[]>
|
||||
/**
|
||||
* 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。
|
||||
*
|
||||
* 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它,
|
||||
* 产出的报名行也就与改造前一模一样。
|
||||
*/
|
||||
squads?: Record<number, string>
|
||||
}
|
||||
|
||||
export function emptyTeamPick(): TeamPickState {
|
||||
@@ -348,11 +391,21 @@ export function toggleMemberPick(
|
||||
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
|
||||
}
|
||||
|
||||
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单保留。 */
|
||||
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单与队名保留。 */
|
||||
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
|
||||
return { ...state, teamIds: [...teamIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下某个单位填的队名(输入框原文)。
|
||||
*
|
||||
* 存原文不存处理过的值:界面上「空」与「填了一队」是两件事(前者 = 不区分队伍),
|
||||
* 归一留给产出报名行的那一步(buildTeamSelections / buildTeamSelectionRows)。
|
||||
*/
|
||||
export function setTeamPickSquad(state: TeamPickState, teamId: number, squad: string): TeamPickState {
|
||||
return { ...state, squads: { ...state.squads, [teamId]: squad } }
|
||||
}
|
||||
|
||||
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
|
||||
export function selectableTeamIds(
|
||||
teams: readonly { id: number; active: boolean }[],
|
||||
@@ -366,6 +419,7 @@ export function selectableTeamIds(
|
||||
*
|
||||
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
|
||||
* 保证同一份勾选每次生成的 member_names 完全一致。
|
||||
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
|
||||
*/
|
||||
export function buildTeamSelections(
|
||||
state: TeamPickState,
|
||||
@@ -380,7 +434,8 @@ export function buildTeamSelections(
|
||||
const memberNames = athletes
|
||||
.filter((athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id))
|
||||
.map((athlete) => athlete.name)
|
||||
selections.push({ teamCode: team.code, memberNames })
|
||||
const squad = (state.squads?.[team.id] ?? '').trim()
|
||||
selections.push(squad === '' ? { teamCode: team.code, memberNames } : { teamCode: team.code, memberNames, squad })
|
||||
}
|
||||
return selections
|
||||
}
|
||||
@@ -403,6 +458,30 @@ export function registeredTeamIds(
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* 组别下各单位**已报名的队名**(去重、按报名表出现顺序)。
|
||||
*
|
||||
* 「已报名」这一个徽标在有了队伍之后不够用了:同一个单位可以再报第二支队,
|
||||
* 操作员得先看见它已经报过哪些队,才不会把「二队」又写成「一队」(那样是更新原来那条,不是新增)。
|
||||
* 空串(不区分队伍的老报名)不进表,那一行的徽标仍只说「已报名」,与升级前一样。
|
||||
*/
|
||||
export function registeredTeamSquads(
|
||||
entries: readonly { categoryId: number; teamId: number; squad?: string | null }[],
|
||||
categoryId: number | undefined,
|
||||
): Map<number, string[]> {
|
||||
const out = new Map<number, string[]>()
|
||||
if (categoryId === undefined) return out
|
||||
for (const entry of entries) {
|
||||
if (entry.categoryId !== categoryId) continue
|
||||
const squad = (entry.squad ?? '').trim()
|
||||
if (squad === '') continue
|
||||
const list = out.get(entry.teamId)
|
||||
if (list === undefined) out.set(entry.teamId, [squad])
|
||||
else if (!list.includes(squad)) list.push(squad)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
|
||||
export function registeredAthleteIds(
|
||||
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
hasAggregateChannel,
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeSquad,
|
||||
normalizeTeamShares,
|
||||
parseResultValue,
|
||||
shareText,
|
||||
teamSquadLabel,
|
||||
type AvgMode,
|
||||
type DraftResultInput,
|
||||
type EntryStatus,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type Event,
|
||||
type ResultState,
|
||||
type ResultStatus,
|
||||
type TeamRankEntry,
|
||||
type TeamRanksView,
|
||||
type TeamShareView,
|
||||
} from '@/shared'
|
||||
@@ -36,6 +39,8 @@ import {
|
||||
import { entryProgress } from './entryProgress'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
mergeJointTeamRankRows,
|
||||
mergedAwayRankNames,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
@@ -57,6 +62,12 @@ import {
|
||||
submitEntryTeamShares,
|
||||
type ShareDraftRow,
|
||||
} from './entryShares'
|
||||
import {
|
||||
entrySquadEditable,
|
||||
squadDraftDirty,
|
||||
squadDraftOf,
|
||||
submitEntrySquad,
|
||||
} from './entrySquad'
|
||||
import {
|
||||
entryBatchOutcome,
|
||||
entryBatchSizeError,
|
||||
@@ -110,6 +121,11 @@ interface EditableRow {
|
||||
athleteId: number | null
|
||||
teamName: string
|
||||
teamShort: string
|
||||
/**
|
||||
* 队名(空串 = 不区分队伍):同一个单位在同一组别可以派多个队,两条报名各占一个名次。
|
||||
* 只对团体行有意义(个人项目按人报名,服务端根本不给 squad)。
|
||||
*/
|
||||
squad: string
|
||||
/**
|
||||
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
|
||||
*
|
||||
@@ -264,7 +280,20 @@ const teamRanksSaving = ref(false)
|
||||
/** 当前组别的团体名次行(表格直接绑它)。 */
|
||||
const teamRankRows = computed<TeamRankRow[]>(() => {
|
||||
const categoryId = selectedCategoryId.value
|
||||
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
|
||||
if (categoryId === null) return []
|
||||
// 联队(一条报名挂多个单位)在本组报名时,成员单位合并成主单位那一行:名次只录一处,
|
||||
// 成员单位不再各占一行,也就没有地方给它们顺手录第二份名次(重复计分)。
|
||||
// 报名行就在录入页手里(categoryRows 的 teamShares),不为它多开一个接口。
|
||||
return mergeJointTeamRankRows(teamRanksByCategory.value.get(categoryId) ?? [], categoryRows.value)
|
||||
})
|
||||
/**
|
||||
* 被并进联队行、不再单独占行的成员单位原来录过的名次:保存(整组替换)后会被一起清掉,
|
||||
* 界面上先点名说清——这是操作员库里本来就有的数,不能悄悄改。
|
||||
*/
|
||||
const teamRankMergedAwayNames = computed(() => {
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (categoryId === null) return []
|
||||
return mergedAwayRankNames(teamRanksByCategory.value.get(categoryId) ?? [], teamRankRows.value)
|
||||
})
|
||||
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
|
||||
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
|
||||
@@ -385,7 +414,14 @@ function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function scoreWith(rows: EditableRow[]) {
|
||||
/**
|
||||
* 一次算分。
|
||||
*
|
||||
* teamRanks 必须显式给:这就是 channel_manual_ranks(录入页底部「团体名次」卡片里的名次)。
|
||||
* 以前预览从不喂它,「按单位录名次」的通道就永远算不出分——服务端算对了(六个单位合计 40 分),
|
||||
* 预览却每个单位都显示 0,操作员据此以为没分。
|
||||
*/
|
||||
function scoreWith(rows: EditableRow[], teamRanks: readonly TeamRankEntry[]) {
|
||||
const event = selectedEvent.value
|
||||
if (event === null) return null
|
||||
return computeEventScore(scorableOf(rows), {
|
||||
@@ -394,11 +430,27 @@ function scoreWith(rows: EditableRow[]) {
|
||||
channels: channels.value,
|
||||
profiles: data.profiles,
|
||||
weight: weight.value,
|
||||
teamRanks,
|
||||
})
|
||||
}
|
||||
|
||||
/** 预览用的团体名次:卡片里**当前填的**那一份(含未保存的编辑,与成绩的未保存编辑同口径)。 */
|
||||
const previewTeamRanks = computed<TeamRankEntry[]>(() => teamRanksPayload(teamRankRows.value).ranks)
|
||||
|
||||
/**
|
||||
* 榜单上已经在算的那一份团体名次(服务端存的值)。
|
||||
*
|
||||
* 「已发布的分」基线要用它:卡片里改了名次还没点「保存团体名次」时,榜单并没有跟着变,
|
||||
* 拿改后的名次去扣已发布的分,扣出来的差值就不是本次发布的真实影响。
|
||||
*/
|
||||
const savedTeamRanks = computed<TeamRankEntry[]>(() => {
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (categoryId === null) return []
|
||||
return embeddedTeamRanksFor(categoryId)?.ranks ?? []
|
||||
})
|
||||
|
||||
/** 全量可见行(含未录行,但没成绩不参与排名)= 本次发布后服务端会算出的口径。 */
|
||||
const score = computed(() => scoreWith(visibleRows.value))
|
||||
const score = computed(() => scoreWith(visibleRows.value, previewTeamRanks.value))
|
||||
|
||||
/**
|
||||
* 只含「已发布」行的一次独立算分:这是该组此刻在榜上的口径。
|
||||
@@ -406,7 +458,12 @@ const score = computed(() => scoreWith(visibleRows.value))
|
||||
* 分批比赛时必须单独算:若拿全量口径里已发布行的分去扣,新一批会把老一批的名次挤下去,
|
||||
* 扣掉的却是老一批在新口径下的(可能更低的)分,差值就不等于本次发布对榜单的真实影响。
|
||||
*/
|
||||
const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) => row.resultState === 'published')))
|
||||
const publishedScore = computed(() =>
|
||||
scoreWith(
|
||||
visibleRows.value.filter((row) => row.resultState === 'published'),
|
||||
savedTeamRanks.value,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
|
||||
@@ -416,17 +473,25 @@ const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) =
|
||||
*/
|
||||
const publishedContribution = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
const rowTeamShares = publishedScore.value?.rowTeamShares
|
||||
const published = publishedScore.value
|
||||
for (const row of visibleRows.value) {
|
||||
if (row.resultState !== 'published') continue
|
||||
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
|
||||
for (const part of rowTeamShares?.get(row.entryId) ?? []) {
|
||||
for (const part of published?.rowTeamShares.get(row.entryId) ?? []) {
|
||||
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
|
||||
}
|
||||
}
|
||||
// 不挂在报名项上的那部分分(按单位录的团体名次、按队内平均的团体成绩)同样已经在榜上,
|
||||
// 不一起扣掉就会被预览当成本次发布的新增量报出去(发布确认框的「最大分差」跟着虚高)。
|
||||
for (const [teamId, points] of published?.unitPoints ?? []) {
|
||||
map.set(teamId, (map.get(teamId) ?? 0) + points)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/** 通道配置错误(compare=none 配了要按成绩排名的通道):服务端会拒绝发布,预览不报数字。 */
|
||||
const scoreConfigError = computed(() => score.value?.error ?? null)
|
||||
|
||||
const preview = computed<PreviewRow[]>(() => {
|
||||
const current = new Map(data.standings.map((row) => [row.teamId, row.totalPoints]))
|
||||
const next = score.value?.teamPoints ?? new Map<number, number>()
|
||||
@@ -742,6 +807,11 @@ const hiddenIssueCount = computed(() => Math.max(0, errors.value.length + warnin
|
||||
|
||||
/** 发布确认框正文:必须让操作员明白本次只会发布已填的部分,不能悄悄少发。 */
|
||||
const publishMessage = computed(() => {
|
||||
// 通道配置本身就出不来分时,「影响 0 个单位、最大分差 0 分」是句假话(服务端会 409),
|
||||
// 先在这里把原因说清,别让操作员对着一份算不出来的预览按发布。
|
||||
if (scoreConfigError.value !== null) {
|
||||
return `计分通道配置有问题,发布会失败:${scoreConfigError.value.message}`
|
||||
}
|
||||
const pending = pendingRows.value.length
|
||||
const missing = unrecordedRows.value.length
|
||||
const head =
|
||||
@@ -766,6 +836,32 @@ function rowLabel(row: EditableRow): string {
|
||||
return entryRowLabel(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张),
|
||||
* 队名为空时与改造前逐字一样。同一个单位的两个队只差队名,不写出来就分不出哪一行是谁。
|
||||
*/
|
||||
function teamCellText(row: EditableRow): string {
|
||||
return teamSquadLabel(row.teamShort || row.teamName, row.squad)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位格的悬停提示:点开的弹窗除了份额,对团体行还多一个「队伍」输入框,提示里得说出来,
|
||||
* 否则操作员根本不知道队名还能改。个人行没有这一项,文案与改造前逐字一样。
|
||||
*/
|
||||
function teamCellTitle(row: EditableRow): string {
|
||||
const name = teamSquadLabel(row.teamName, row.squad)
|
||||
return entrySquadEditable(row)
|
||||
? `${name}|点击设置成员单位、份额与队伍(同一个单位派多个队时用)`
|
||||
: `${name}|点击设置成员单位与份额(两个单位合并参赛时用)`
|
||||
}
|
||||
|
||||
function jointCellTitle(row: EditableRow): string {
|
||||
const name = teamSquadLabel(jointTeamLabel(row.teamShares), row.squad)
|
||||
return entrySquadEditable(row)
|
||||
? `${name}|点击修改成员单位、份额与队伍`
|
||||
: `${name}|点击修改成员单位与份额`
|
||||
}
|
||||
|
||||
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
|
||||
function issueLabel(row: EditableRow, index: number): string {
|
||||
const name = rowLabel(row)
|
||||
@@ -794,6 +890,8 @@ function toRow(entry: EntryWithResult): EditableRow {
|
||||
athleteId: entry.athleteId,
|
||||
teamName,
|
||||
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
|
||||
// 老服务端不下发 squad 时按空串(= 不区分队伍),与升级前的显示逐字一致。
|
||||
squad: normalizeSquad(entry.squad),
|
||||
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
|
||||
teamShares: normalizeTeamShares(entry.teamShares, {
|
||||
teamId: entry.teamId,
|
||||
@@ -839,6 +937,34 @@ function pointsOf(row: EditableRow): number {
|
||||
return round(score.value?.rowPoints.get(row.entryId) ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 本组的计分是不是「全按单位录名次、行上没有分」。
|
||||
*
|
||||
* 「积分(自动)」这一列算的是**行**的积分(按成绩 / 个人名次),而按单位录的名次产生的分
|
||||
* 挂在单位上、不挂到任何一行报名:这一列在这个项目里永远是 0。现场操作员正是被这个 0
|
||||
* 误导,以为「没分」。所以这种情况不显示 0,直接说「见下方团体名次」。
|
||||
*
|
||||
* 两条判据缺一不可:
|
||||
* 1. 计分通道全是 scope=team + basis=manual;
|
||||
* 2. 没有任何行是「带名次的团体行」——纯团体项目(篮球等)走 results.rank 兜底,
|
||||
* 那种行上的分是真的,列照旧显示。
|
||||
*/
|
||||
const pointsColumnUnrelated = computed(() => {
|
||||
const list = channels.value
|
||||
if (list.length === 0) return false
|
||||
if (!list.every((channel) => channel.scope === 'team' && channel.basis === 'manual')) return false
|
||||
return !visibleRows.value.some((row) => (row.athleteId ?? 0) === 0 && (row.rank ?? 0) > 0)
|
||||
})
|
||||
|
||||
/** 表头与单元格共用的口径说明:这一列算的是什么、为什么这里没有它的事。 */
|
||||
const POINTS_COLUMN_TITLE = '「积分(自动)」是每一条报名行自己的分:按成绩自动排名或个人名次算出来的。'
|
||||
|
||||
const pointsColumnHint = computed(() =>
|
||||
pointsColumnUnrelated.value
|
||||
? `${POINTS_COLUMN_TITLE}本组按单位录名次计分,分记在单位上、不挂到任何一行,所以这一列没有可显示的分——请见下方「团体名次」与右侧「实时积分预览」。`
|
||||
: POINTS_COLUMN_TITLE,
|
||||
)
|
||||
|
||||
function issueFor(entryId: number): Issue | undefined {
|
||||
return issues.value.find((issue) => issue.entryId === entryId)
|
||||
}
|
||||
@@ -1008,6 +1134,10 @@ async function onSupplementSubmitted(eventId: number, categoryId: number | null)
|
||||
*
|
||||
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
|
||||
* 发出去(服务端会照着做,只是白重算一遍本组)。
|
||||
*
|
||||
* 团体行还多一个「队伍」输入框(迁移 0025):队名同样是这条报名的归属配置,与份额共用
|
||||
* 这一个弹窗与这一个保存按钮。改队名走的是另一个接口(只改 squad,不碰份额),所以
|
||||
* 保存时按「哪一块改了发哪一个请求」拆开,没改的那一块一个请求都不发。
|
||||
*/
|
||||
interface ShareEditor {
|
||||
entryId: number
|
||||
@@ -1016,8 +1146,16 @@ interface ShareEditor {
|
||||
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
|
||||
primaryTeamId: number
|
||||
primaryName: string
|
||||
/** 表格「单位」列用的单位名(短名优先),队名预览要拼在它后面,口径与 teamCellText 一致。 */
|
||||
teamCellBase: string
|
||||
original: ShareDraftRow[]
|
||||
draft: ShareDraftRow[]
|
||||
/** 这条报名是不是纯团体报名:只有它才出现「队伍」输入框(个人报名服务端也会 400)。 */
|
||||
teamEntry: boolean
|
||||
/** 队名输入框的原文;空串 = 不区分队伍。 */
|
||||
squad: string
|
||||
/** 打开时的队名:判断改没改,与份额的 original 同理。 */
|
||||
originalSquad: string
|
||||
saving: boolean
|
||||
error: string | null
|
||||
}
|
||||
@@ -1034,6 +1172,18 @@ const shareChanged = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
|
||||
})
|
||||
/** 队名改了没有:与份额一样按**归一后**的值比,多打一个空格不算改动。 */
|
||||
const squadChanged = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? false : squadDraftDirty(editor.squad, editor.originalSquad)
|
||||
})
|
||||
/** 这个弹窗里有没有任何改动(份额或队名):决定保存按钮亮不亮、「关闭」是不是「放弃修改」。 */
|
||||
const shareEditorDirty = computed(() => shareChanged.value || squadChanged.value)
|
||||
/** 队名会拼进单位列(`第一分公司青年队`),改之前先让操作员看见改完长什么样。 */
|
||||
const squadPreviewText = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? '' : teamSquadLabel(editor.teamCellBase, editor.squad)
|
||||
})
|
||||
const shareView = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? [] : shareDraftView(editor.draft)
|
||||
@@ -1050,9 +1200,20 @@ const canSaveShares = computed(
|
||||
canWrite.value &&
|
||||
shareEditor.value !== null &&
|
||||
!shareEditor.value.saving &&
|
||||
shareChanged.value &&
|
||||
shareEditorDirty.value &&
|
||||
shareIssues.value.length === 0,
|
||||
)
|
||||
/**
|
||||
* 保存按钮上的字:这一下到底会保存什么。改了什么就说什么,别让只改了队名的人
|
||||
* 面对一个「保存成员单位」按钮猜自己那一下会不会把份额也一起重发。
|
||||
*/
|
||||
const shareSaveLabel = computed(() => {
|
||||
const shares = shareChanged.value
|
||||
const squad = squadChanged.value
|
||||
if (shares && squad) return '保存成员单位与队伍'
|
||||
if (squad) return '保存队伍'
|
||||
return '保存成员单位'
|
||||
})
|
||||
|
||||
function openShareEditor(row: EditableRow): void {
|
||||
pendingShareTeamId.value = ''
|
||||
@@ -1061,8 +1222,13 @@ function openShareEditor(row: EditableRow): void {
|
||||
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
|
||||
primaryTeamId: row.teamId,
|
||||
primaryName: row.teamName,
|
||||
teamCellBase: row.teamShort || row.teamName,
|
||||
original: shareDraftOf(row),
|
||||
draft: shareDraftOf(row),
|
||||
// 个人报名连这个输入框都不渲染:队名对它没有意义,服务端也会 400。
|
||||
teamEntry: entrySquadEditable(row),
|
||||
squad: squadDraftOf(row),
|
||||
originalSquad: squadDraftOf(row),
|
||||
saving: false,
|
||||
error: null,
|
||||
}
|
||||
@@ -1095,12 +1261,24 @@ function editShareWeight(teamId: number, event: InputEvent): void {
|
||||
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
|
||||
}
|
||||
|
||||
/** 队名输入框:存**原文**,归一留给「改没改」与提交那一步(输入到一半的队名不该被 trim 掉)。 */
|
||||
function editShareSquad(event: InputEvent): void {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null) return
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
editor.squad = input.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存份额:整组替换一条报名的成员单位。
|
||||
* 保存这个弹窗里的改动:份额(整组替换)与队名(只改 squad)是两个接口。
|
||||
*
|
||||
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉,
|
||||
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。
|
||||
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。
|
||||
* 按「哪一块改了发哪一个」拆开——只改队名的人不该顺便把没动过的份额整组替换一遍,
|
||||
* 服务端会照着做,只是白重算本组一次;两块都改了才各发一个。
|
||||
*
|
||||
* 就地更新这一行的份额 / 队名并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑
|
||||
* 一并覆盖掉,而这两项只影响分记在谁头上、单位列怎么写,不影响任何一行的成绩与名次
|
||||
* (那些由本地预览算)。服务端的 400(单位重复 / 权重为负 / 队名重复…)原样展示在弹窗里,不吞。
|
||||
*/
|
||||
async function saveShareEditor(): Promise<void> {
|
||||
const editor = shareEditor.value
|
||||
@@ -1112,26 +1290,45 @@ async function saveShareEditor(): Promise<void> {
|
||||
}
|
||||
editor.saving = true
|
||||
editor.error = null
|
||||
const sharesDirty = shareChanged.value
|
||||
const squadDirty = squadChanged.value
|
||||
const payload = shareDraftPayload(editor.draft)
|
||||
const row = allRows.value.find((item) => item.entryId === editor.entryId)
|
||||
try {
|
||||
const outcome = await submitEntryTeamShares(editor.entryId, payload)
|
||||
const shares = outcome.teamShares ?? payload
|
||||
const row = allRows.value.find((item) => item.entryId === editor.entryId)
|
||||
if (row !== undefined) {
|
||||
const fallbackName = row.teamName
|
||||
row.teamShares = normalizeTeamShares(shares, {
|
||||
teamId: row.teamId,
|
||||
weight: 1,
|
||||
teamCode: row.teamShares[0]?.teamCode ?? '',
|
||||
teamName: fallbackName,
|
||||
})
|
||||
if (sharesDirty) {
|
||||
const outcome = await submitEntryTeamShares(editor.entryId, payload)
|
||||
const shares = outcome.teamShares ?? payload
|
||||
if (row !== undefined) {
|
||||
row.teamShares = normalizeTeamShares(shares, {
|
||||
teamId: row.teamId,
|
||||
weight: 1,
|
||||
teamCode: row.teamShares[0]?.teamCode ?? '',
|
||||
teamName: row.teamName,
|
||||
})
|
||||
}
|
||||
// 份额这一份已经落库:把初值跟着推上去,队名那一步万一失败,再点保存不会把份额重发一遍。
|
||||
editor.original = editor.draft
|
||||
}
|
||||
if (squadDirty) {
|
||||
const outcome = await submitEntrySquad(editor.entryId, editor.squad)
|
||||
// 服务端回显优先(它 trim 过);响应里没有这个字段时按手里的草稿,界面照改不误。
|
||||
const saved = outcome.squad ?? editor.squad
|
||||
if (row !== undefined) row.squad = saved
|
||||
editor.squad = saved
|
||||
editor.originalSquad = saved
|
||||
}
|
||||
closeShareEditor()
|
||||
void data.refreshStandings()
|
||||
toast.success(
|
||||
'成员单位已保存',
|
||||
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位',
|
||||
)
|
||||
if (row !== undefined) {
|
||||
const title =
|
||||
sharesDirty && squadDirty ? '成员单位与队伍已保存' : sharesDirty ? '成员单位已保存' : '队伍已保存'
|
||||
// 只改了队名时,提示里把改完的单位列原文念一遍——队名到底拼成了什么,一眼就能核对。
|
||||
let detail = `单位列已改成「${teamCellText(row)}」`
|
||||
if (sharesDirty) {
|
||||
detail = isJointTeam(row.teamShares) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位'
|
||||
}
|
||||
toast.success(title, detail)
|
||||
}
|
||||
} catch (caught) {
|
||||
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
|
||||
} finally {
|
||||
@@ -1560,7 +1757,8 @@ function batchRows(): EntryBatchRow[] {
|
||||
entryId: row.entryId,
|
||||
name: rowLabel(row),
|
||||
bib: row.bib,
|
||||
team: row.teamShort || row.teamName,
|
||||
// 单位带上队名:同一个单位的两个队都在这张表里时,确认框上要能分清删的是哪一条。
|
||||
team: teamCellText(row),
|
||||
categoryId: row.categoryId,
|
||||
entryStatus: row.entryStatus,
|
||||
}))
|
||||
@@ -2269,7 +2467,14 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<th>成绩输入</th>
|
||||
<th class="center">名次</th>
|
||||
<th>状态</th>
|
||||
<th class="center">积分(自动)</th>
|
||||
<th class="center" :title="pointsColumnHint">
|
||||
积分(自动)
|
||||
<!--
|
||||
按单位录名次的项目里行上没有分(分挂在单位上):表头直接说清这一列算什么,
|
||||
并指向真正有分的「团体名次」,别让那个 0 被读成「这个项目没分」。
|
||||
-->
|
||||
<span v-if="pointsColumnUnrelated" class="th-sub">见下方团体名次</span>
|
||||
</th>
|
||||
<th>备注</th>
|
||||
<th class="center action-col">操作</th>
|
||||
</tr>
|
||||
@@ -2328,19 +2533,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<span
|
||||
v-if="!isJointTeam(row.teamShares)"
|
||||
class="team-cell is-editable"
|
||||
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`"
|
||||
:title="teamCellTitle(row)"
|
||||
@click="openShareEditor(row)"
|
||||
>{{ row.teamShort || row.teamName }}</span>
|
||||
>{{ teamCellText(row) }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="team-cell is-editable is-joint"
|
||||
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`"
|
||||
:title="jointCellTitle(row)"
|
||||
@click="openShareEditor(row)"
|
||||
>
|
||||
<span class="mx-badge mx-badge-info">联队</span>
|
||||
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
|
||||
{{ shareText(member) }}
|
||||
</span>
|
||||
<!-- 联队这一格是按份额逐行列的,队名单独一行写;单单位那一格才是直接拼在单位名后面。 -->
|
||||
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||
@@ -2437,8 +2644,9 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="center">
|
||||
<span class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
|
||||
<td class="center" :title="pointsColumnHint">
|
||||
<span v-if="pointsColumnUnrelated" class="points-cell mono unrelated">—</span>
|
||||
<span v-else class="points-cell mono" :class="{ zero: pointsOf(row) === 0 }">
|
||||
{{ formatPoints(pointsOf(row)) }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -2501,21 +2709,39 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<aside class="mx-card preview-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">实时积分预览</span>
|
||||
<span class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
|
||||
<span v-if="scoreConfigError === null" class="mx-badge mx-badge-info">{{ affectedTeams }} 个单位受影响</span>
|
||||
<span v-else class="mx-badge mx-badge-error">配置有问题</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="preview-hint">
|
||||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||||
</p>
|
||||
<ul class="preview-list">
|
||||
<li v-for="row in preview" :key="row.teamId" class="preview-item">
|
||||
<span class="preview-name">{{ row.name }}</span>
|
||||
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
|
||||
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
|
||||
</span>
|
||||
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!--
|
||||
配置本身就出不来分(compare=none 却配了要按成绩排名的通道)时,这里不显示任何数字:
|
||||
服务端算不出名次、发布会 409,给一组「0 分」只会让操作员以为这套配置还能用。
|
||||
-->
|
||||
<div v-if="scoreConfigError !== null" class="preview-config-error">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>{{ scoreConfigError.message }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<p class="preview-hint">
|
||||
若现在发布,各单位积分变化(最大分差 <strong>{{ formatPoints(maxDelta) }}</strong> 分)
|
||||
</p>
|
||||
<!--
|
||||
团体名次是独立保存的(有自己的「保存团体名次」按钮):没保存时预览按卡片里
|
||||
填的名次算,但发布会用的是服务端已存的那一份,这里必须点破,别让人以为已经生效。
|
||||
-->
|
||||
<p v-if="teamRankDirty" class="preview-hint preview-rank-dirty">
|
||||
团体名次有未保存的改动:预览按卡片里填的名次算,点「保存团体名次」后才会写入并重算榜单。
|
||||
</p>
|
||||
<ul class="preview-list">
|
||||
<li v-for="row in preview" :key="row.teamId" class="preview-item">
|
||||
<span class="preview-name">{{ row.name }}</span>
|
||||
<span class="preview-delta mono" :class="row.delta > 0 ? 'up' : row.delta < 0 ? 'down' : ''">
|
||||
{{ row.delta > 0 ? '+' : '' }}{{ formatPoints(row.delta) }}
|
||||
</span>
|
||||
<span class="preview-total mono">{{ formatPoints(row.next) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -2553,11 +2779,29 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<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">
|
||||
<li
|
||||
v-for="row in teamRankRows"
|
||||
:key="row.teamId"
|
||||
class="team-rank-item"
|
||||
:class="{ 'is-joint': row.members.length > 0 }"
|
||||
>
|
||||
<span class="team-rank-code mono">{{ row.code }}</span>
|
||||
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
|
||||
<!--
|
||||
联队行:成员单位与份额并排列出(与大屏、成绩表里那一格同一读法),
|
||||
让「这一场其实是 A+B 联队」一眼看得见,也点明名次录在主单位这一行上。
|
||||
-->
|
||||
<span v-if="row.members.length > 0" class="mx-badge mx-badge-info">联队</span>
|
||||
<span
|
||||
v-if="row.members.length > 0"
|
||||
class="team-rank-members"
|
||||
title="这一行是联队:名次录在主单位上,按份额拆给各成员单位;成员单位不再单独占一行"
|
||||
>
|
||||
<span v-for="member in row.members" :key="member.teamId" class="joint-member">{{ shareText(member) }}</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="row.input"
|
||||
class="mx-cell-input rank-input mono"
|
||||
@@ -2580,6 +2824,14 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<span>{{ message }}(只提示,不阻止保存)</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!--
|
||||
被并进联队行的成员单位原来单独录过的名次:保存时会随整组替换一起清掉
|
||||
(那正是要堵的重复计分),但这是库里本来就有的数,得说出来再清。
|
||||
-->
|
||||
<p v-if="teamRankMergedAwayNames.length > 0" class="team-ranks-hint team-rank-merged-note">
|
||||
{{ teamRankMergedAwayNames.join('、') }} 已并入联队那一行,不再单独录入;
|
||||
原来单独录过的名次会在保存后清除(同一个名次不再重复计分)。
|
||||
</p>
|
||||
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
|
||||
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
|
||||
</div>
|
||||
@@ -2740,6 +2992,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
当前比例:<strong>{{ shareRatioText }}</strong>
|
||||
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
|
||||
</p>
|
||||
<!--
|
||||
队伍(迁移 0025):同一个单位在同一组别可以派多个队,队名就写在这里——报完才发现
|
||||
「二队」写成「一队」的,不用删了重报(重报会把已经录进去的成绩一起丢掉)。
|
||||
|
||||
只对纯团体报名出现:个人报名按人报,服务端也会 400,别让操作员对着一个注定失败的
|
||||
输入框白点一次保存。留空 = 不区分队伍(老数据与单队单位的默认值),单位列的文案与
|
||||
保存前后的行为都跟过去逐字一致。
|
||||
-->
|
||||
<div v-if="shareEditor.teamEntry" class="share-squad">
|
||||
<span class="share-squad-label">队伍</span>
|
||||
<input
|
||||
class="mx-cell-input squad-input"
|
||||
type="text"
|
||||
placeholder="队伍(留空=不区分)"
|
||||
:value="shareEditor.squad"
|
||||
:disabled="!canWrite || shareEditor.saving"
|
||||
title="同一个单位在同一组别可以派多个队(羽毛球双打的一队 / 二队):填上队名就能区分,留空=不区分队伍"
|
||||
@input="editShareSquad"
|
||||
/>
|
||||
<span class="share-squad-preview">单位列:<strong>{{ squadPreviewText }}</strong></span>
|
||||
</div>
|
||||
<ul v-if="shareIssues.length > 0" class="issue-list">
|
||||
<li v-for="(message, index) in shareIssues" :key="index" class="error">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
@@ -2750,17 +3023,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
{{ shareEditor.error }}
|
||||
</div>
|
||||
<p class="bd-note">
|
||||
保存后本组的名次与积分会当场重算,大屏榜单跟着刷新。没改动的话保存按钮是灰的,
|
||||
点「关闭」不会发出任何请求。
|
||||
保存成员单位后本组的名次与积分会当场重算,大屏榜单跟着刷新;队名是自由文本,
|
||||
改了当场反映到单位列与大屏。没改动的话保存按钮是灰的,点「关闭」不会发出任何请求。
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-modal-foot">
|
||||
<button class="mx-btn mx-btn-secondary" type="button" @click="closeShareEditor">
|
||||
{{ shareChanged ? '放弃修改' : '关闭' }}
|
||||
{{ shareEditorDirty ? '放弃修改' : '关闭' }}
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
|
||||
<MxIcon name="check" :size="14" />
|
||||
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }}
|
||||
{{ shareEditor.saving ? '保存中…' : shareSaveLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3209,6 +3482,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
/* 队伍:一整行的「标签 + 输入框 + 单位列预览」,与上面的成员单位列表隔开一段。 */
|
||||
.share-squad {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 10px 0 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.share-squad-label {
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.squad-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
/* 预览是给眼睛核对的,单位名长的时候宁可省略号,也不要把输入框挤没。 */
|
||||
.share-squad-preview {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-squad-preview strong {
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.share-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3292,6 +3597,19 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-rank-name {
|
||||
min-width: 72px;
|
||||
}
|
||||
/* 联队行:成员单位与份额并排列出(名字写全、比例看得见),与成绩表里那一格同一读法。
|
||||
行本身就带边框,免得「A+B联队」和旁边那些普通单位行看上去一模一样。 */
|
||||
.team-rank-item.is-joint {
|
||||
border: 1px solid var(--mx-primary-glow);
|
||||
}
|
||||
.team-rank-members {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.team-rank-merged-note {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.team-rank-empty {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
@@ -3411,6 +3729,34 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
color: var(--mx-t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 「按单位录名次」的项目里行上没有分:不给数字,给一句「见下方团体名次」,免得看着像没分。 */
|
||||
.points-cell.unrelated {
|
||||
color: var(--mx-t3);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 表头里的一句副标:「积分(自动)」这一列在本组没有可显示的分。 */
|
||||
.th-sub {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.preview-config-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s3);
|
||||
color: var(--mx-red);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.preview-rank-dirty {
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.preview-card {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
formatPoints,
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeSquad,
|
||||
normalizeTeamShares,
|
||||
teamSquadLabel,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
type EventCategory,
|
||||
@@ -29,6 +31,8 @@ interface DraftRow {
|
||||
teamId: number
|
||||
teamName: string
|
||||
teamShort: string
|
||||
/** 队名(空串 = 不区分队伍):同一单位的两个队各占一个名次,复核时要分得清是哪一条。 */
|
||||
squad: string
|
||||
/** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */
|
||||
teamShares: TeamShareView[]
|
||||
athleteName: string
|
||||
@@ -145,6 +149,14 @@ function teamLabel(teamId: number, fallback: string): string {
|
||||
return team?.shortName ?? team?.name ?? fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 复核表「单位」列的单位名:填了队名就拼在后面(`第一分公司二队`,不加括号——大屏列宽紧张),
|
||||
* 队名为空时与改造前逐字一样。
|
||||
*/
|
||||
function teamCellText(row: DraftRow): string {
|
||||
return teamSquadLabel(row.teamShort || row.teamName, row.squad)
|
||||
}
|
||||
|
||||
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
|
||||
function isFallback(error: unknown): boolean {
|
||||
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
|
||||
@@ -159,7 +171,10 @@ function unitOf(type: Event['resultType']): string {
|
||||
}
|
||||
|
||||
function rowLabel(row: DraftRow, index: number): string {
|
||||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||||
// 团体行带上队名:同一单位的两个队只差这几个字,否则两行的报错文案一模一样。
|
||||
// teamShort 为空时维持原来的 #id 占位(不拿全称顶上),老数据的文案一个字都不变。
|
||||
const team = row.teamShort === '' ? '' : teamSquadLabel(row.teamShort, row.squad)
|
||||
const name = row.athleteName || row.memberNames?.join('、') || team || `#${row.entryId}`
|
||||
return `第 ${index + 1} 行 ${name}`
|
||||
}
|
||||
|
||||
@@ -267,6 +282,8 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
teamId: entry.teamId,
|
||||
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
|
||||
teamShort: entry.shortName ?? '',
|
||||
// 老服务端不下发 squad 时按空串(= 不区分队伍),显示与升级前逐字一致。
|
||||
squad: normalizeSquad(entry.squad),
|
||||
teamShares: normalizeTeamShares(entry.teamShares, {
|
||||
teamId: entry.teamId,
|
||||
weight: 1,
|
||||
@@ -632,14 +649,16 @@ watch(
|
||||
<span
|
||||
v-if="isJointTeam(row.teamShares)"
|
||||
class="team-cell is-joint"
|
||||
:title="jointTeamLabel(row.teamShares)"
|
||||
:title="teamSquadLabel(jointTeamLabel(row.teamShares), row.squad)"
|
||||
>
|
||||
<span class="mx-badge mx-badge-info">联队</span>
|
||||
<span v-for="member in row.teamShares" :key="member.teamId" class="joint-member">
|
||||
{{ member.teamName }} {{ member.percent }}%
|
||||
</span>
|
||||
<span v-if="row.squad !== ''" class="joint-member">队伍 {{ row.squad }}</span>
|
||||
</span>
|
||||
<span v-else class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
<!-- 单单位:填了队名就拼在单位名后面(不加括号),没填时与改造前逐字一样。 -->
|
||||
<span v-else class="team-cell">{{ teamCellText(row) }}</span>
|
||||
</td>
|
||||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||||
<td class="mono">{{ row.resultText || '—' }}</td>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ApiError } from '@/shared'
|
||||
import type { ApiErrorCode, Entry } from '@/shared'
|
||||
import { normalizeSquad } from '@/shared'
|
||||
|
||||
/**
|
||||
* 「队伍」(squad)改名:`PUT /api/entries/{id}/squad`(见迁移 0025)。
|
||||
*
|
||||
* 队名过去是报名时一次写死的(导入表 / 补报名),报完就再也改不了:同一个单位把「一队」
|
||||
* 写成「青年队」只能删了重报,而重报会把已经录进去的成绩一起丢掉。这个写请求把队名变成
|
||||
* 一件随时可改的归属配置,与成员单位、份额放在同一个弹窗里——那本来就是「这条报名归谁」的地方。
|
||||
*
|
||||
* 网络层直接 fetch 而不是走 adminApi:与 entryShares.ts / entryBatch.ts 同一套理由(请求构造与收口
|
||||
* 都在这一个模块里,能直接单测),CSRF 与 X-Request-Id 的补法也必须与 shared/api.ts 的写请求一致,
|
||||
* 否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
|
||||
*/
|
||||
|
||||
export function entrySquadPath(entryId: number): string {
|
||||
return `/api/entries/${entryId}/squad`
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条报名的队名能不能改:只对**纯团体报名**(没有运动员的那种)放开。
|
||||
*
|
||||
* 与服务端同口径——迁移 0025 的唯一索引 (组别, 单位, 队名) 只建在 `athlete_id IS NULL` 上:
|
||||
* 个人项目按人报名,一个人在同一组别只能有一条,队名对它没有意义,服务端也会回 400。
|
||||
* 界面上先拦住,别让操作员对着一个注定失败的输入框白点一次保存。
|
||||
*/
|
||||
export function entrySquadEditable(row: { athleteId: number | null }): boolean {
|
||||
return row.athleteId === null
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开弹窗时输入框的初值:这条报名当前的队名。
|
||||
*
|
||||
* 空串(老数据 / 单队单位)= 不区分队伍,也是这里不填时的值——与升级前的行为逐字一致。
|
||||
*/
|
||||
export function squadDraftOf(row: { squad?: string | null }): string {
|
||||
return normalizeSquad(row.squad)
|
||||
}
|
||||
|
||||
/**
|
||||
* 队名有没有改动。
|
||||
*
|
||||
* 比的是**归一后**的值:首尾空白服务端会 trim 掉,用户多打一个空格再删掉不算改动——
|
||||
* 算了改动会让保存按钮亮起来,点一下发一个什么都没改的请求出去。
|
||||
*/
|
||||
export function squadDraftDirty(draft: string, original: string): boolean {
|
||||
return normalizeSquad(draft) !== normalizeSquad(original)
|
||||
}
|
||||
|
||||
const KNOWN_CODES: readonly string[] = [
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_FAILED',
|
||||
'FORBIDDEN',
|
||||
'VALIDATION_FAILED',
|
||||
'CONFLICT',
|
||||
'STATE_INVALID',
|
||||
'RATE_LIMITED',
|
||||
'INTERNAL',
|
||||
'NETWORK',
|
||||
'TIMEOUT',
|
||||
'PARSE_FAILED',
|
||||
]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
|
||||
const safe: ApiErrorCode =
|
||||
typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
|
||||
return new ApiError(safe, message, status, detail)
|
||||
}
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
function defaultRequestId(): string {
|
||||
const cryptoApi: Crypto | undefined = globalThis.crypto
|
||||
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
|
||||
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
interface EntrySquadResult {
|
||||
ok: boolean
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface EntrySquadOutcome {
|
||||
entry: Entry | null
|
||||
/**
|
||||
* 服务端回显的队名(已归一,空串 = 不区分队伍——用户清空了队名也是合法值)。
|
||||
*
|
||||
* 响应里没有这个字段(老服务端)时为 null,调用方退回手里那份草稿。
|
||||
*/
|
||||
squad: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 改名(空串 = 不区分队伍)。写操作不降级到示例数据:示例数据模式下的「保存成功」
|
||||
* 会让操作员以为队名已经改好了,页面必须先看 data.isMock 再决定是否调用这里。
|
||||
*
|
||||
* 400 时服务端回的是**给人看的中文**(其中「队名与同组别同单位的另一条报名重复」那条会说清
|
||||
* 是哪个单位哪个队),这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
|
||||
*/
|
||||
export async function submitEntrySquad(
|
||||
entryId: number,
|
||||
squad: string,
|
||||
requestId?: string,
|
||||
): Promise<EntrySquadOutcome> {
|
||||
// 一次调用只生成一个幂等键:header 供服务端 cached 层去重,body.requestId 是同一把钥匙。
|
||||
// 两处各生成一个就永远命中不了缓存,超时重试会把同一次改动落两遍。
|
||||
const key = requestId ?? defaultRequestId()
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'X-Request-Id': key,
|
||||
}
|
||||
const token = readCsrfToken()
|
||||
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(entrySquadPath(entryId), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
// 队名发归一后的值(首尾空白服务端也会 trim,先去掉就不会因为一个空格被当成改动)。
|
||||
body: JSON.stringify({ squad: normalizeSquad(squad), requestId: key }),
|
||||
})
|
||||
} catch (caught) {
|
||||
throw apiError('NETWORK', '无法连接服务端,队名没有改成功', 0, caught)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch (caught) {
|
||||
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
|
||||
}
|
||||
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
|
||||
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
|
||||
}
|
||||
const payload = body as unknown as EntrySquadResult
|
||||
if (payload.ok !== true) {
|
||||
const error = isRecord(payload.error) ? payload.error : {}
|
||||
const message =
|
||||
typeof error['message'] === 'string' ? error['message'] : `保存队名失败(HTTP ${response.status})`
|
||||
throw apiError(error['code'], message, response.status, error['detail'])
|
||||
}
|
||||
const data = isRecord(payload.data) ? (payload.data as unknown as Entry) : null
|
||||
return {
|
||||
entry: data,
|
||||
squad: data !== null && typeof data.squad === 'string' ? normalizeSquad(data.squad) : null,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared'
|
||||
import { isJointTeam, jointTeamLabel } from '@/shared'
|
||||
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView, TeamShareView } from '@/shared'
|
||||
|
||||
/**
|
||||
* 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。
|
||||
@@ -7,6 +8,9 @@ import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared
|
||||
* `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。
|
||||
* 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」,
|
||||
* 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。
|
||||
*
|
||||
* 联队(一条报名挂多个单位,devdocs/19)也在这里收口:该组别有联队报名时,成员单位
|
||||
* 合并成主单位那一行(见 mergeJointTeamRankRows),否则每个单位各占一行。
|
||||
*/
|
||||
|
||||
export interface TeamRankRow {
|
||||
@@ -23,6 +27,13 @@ export interface TeamRankRow {
|
||||
input: string | number
|
||||
/** 载入时的原文:用来判断这一行有没有被改过。 */
|
||||
original: string
|
||||
/**
|
||||
* 联队行的成员单位(顺序与报名份额一致);普通单位行是空数组。
|
||||
*
|
||||
* 非空即表示「这一行代表多个单位」,名次写到 `teamId`(联队主单位)上,成员单位不再
|
||||
* 单独占一行。空数组的普通行渲染与今天一字不差,页面据此分支。
|
||||
*/
|
||||
members: TeamShareView[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +90,8 @@ export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[
|
||||
.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 }
|
||||
// 这里先按「一个单位一行」铺开,联队合并在 mergeJointTeamRankRows 里做(它还要看报名)。
|
||||
return { ...team, input, original: input, members: [] }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,11 +101,124 @@ export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean {
|
||||
return rows.some((row) => String(row.input) !== row.original)
|
||||
}
|
||||
|
||||
// ── 联队(devdocs/19):一条报名挂多个单位时,把成员单位合并成一行 ─────────────────
|
||||
|
||||
/**
|
||||
* 联队判定要读的报名字段。
|
||||
*
|
||||
* 录入页表格里的行(EditableRow)结构上就有这两个,所以页面直接把该组别的报名行
|
||||
* 传进来即可——份额早就在表格数据里,不必为它多开一个接口。
|
||||
*/
|
||||
export interface TeamRankEntryRef {
|
||||
/** 报名的主单位(entries.team_id):联队的名次录在这一行。 */
|
||||
teamId: number
|
||||
/** 归属份额(读侧恒为至少一条):多于一条就是「一条报名挂多个单位」的联队报名。 */
|
||||
teamShares: readonly TeamShareView[]
|
||||
}
|
||||
|
||||
/** 联队行的编码:成员编码用 + 连起来(与导入 / 导出写 `A+B` 同一写法)。 */
|
||||
function jointTeamCode(members: readonly TeamShareView[]): string {
|
||||
return members
|
||||
.map((member) => member.teamCode.trim())
|
||||
.filter((code) => code !== '')
|
||||
.join('+')
|
||||
}
|
||||
|
||||
/** 把主单位那一行改写成联队行:名字走 jointTeamLabel(与大屏 / 导出同一份实现)。 */
|
||||
function jointRankRow(row: TeamRankRow, members: readonly TeamShareView[]): TeamRankRow {
|
||||
const code = jointTeamCode(members)
|
||||
const line: TeamRankRow = {
|
||||
...row,
|
||||
code: code === '' ? row.code : code,
|
||||
// 联队名已经把成员单位写全,简称反而是少一家,所以这一行的名字一律走全名那一份。
|
||||
name: jointTeamLabel(members),
|
||||
shortName: null,
|
||||
members: [...members],
|
||||
}
|
||||
// input 必须**写回主单位原来那一行**,不能在副本上停着:v-model 直接绑它,未保存判断
|
||||
// (teamRanksDirty)与保存载荷(teamRanksPayload)读的也都是原行。副本上的输入只活在
|
||||
// 这一个对象里,行一重建(切组别、改份额、保存后回填)就没了,保存按钮也不会亮——
|
||||
// 现场看起来就是「填了名次却存不下去」。
|
||||
Object.defineProperty(line, 'input', {
|
||||
get: () => row.input,
|
||||
set: (value: string | number) => {
|
||||
row.input = value
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「联队报名」的成员单位合并成一行。
|
||||
*
|
||||
* 引擎的口径是**联队的名次录在主单位那一行**(`domain.collectTeamManualCandidates` 拿主单位
|
||||
* 那条名次按份额拆给各成员单位)。界面上把成员单位各列一行不只是显示问题:操作员顺手给
|
||||
* 成员单位也录一个名次,那个单位会再拿一份分(重复计分)。合并成一行正好把这个坑堵死——
|
||||
* 成员单位不再有属于自己的输入框,也就没有地方录第二份名次。
|
||||
*
|
||||
* 只在**该组别真有联队报名**时才动列表:没有联队时按原数组返回(连对象都不换一个),
|
||||
* 于是没有联队的组别与今天逐字段一致。
|
||||
*
|
||||
* 成员单位自己的行在两种情况下照旧保留,免得静默吃掉一条报名:
|
||||
* 1. 它以主单位身份另有报名——自己也是这个组别里的一条参赛单位,独立于联队那一条
|
||||
* (它同时还是另一个联队的主单位时也走这一条,那一行就是那个联队的名次);
|
||||
* 2. 主单位那一行根本不在列表里(单位停用 / 假删除会被接口过滤掉):这时宁可不合并,
|
||||
* 也不让成员单位凭空消失,否则这个组别的团体名次就没地方录了。
|
||||
*/
|
||||
export function mergeJointTeamRankRows(
|
||||
rows: readonly TeamRankRow[],
|
||||
entries: readonly TeamRankEntryRef[],
|
||||
): TeamRankRow[] {
|
||||
const groups = new Map<number, TeamShareView[]>()
|
||||
for (const entry of entries) {
|
||||
if (!isJointTeam(entry.teamShares)) continue
|
||||
// 同一主单位出现两条联队报名时只认第一条:引擎遇到份额不一致的两条会退回独占,
|
||||
// 界面这边少合并一个顶多是不显示联队名,不会把分拆错。
|
||||
if (groups.has(entry.teamId)) continue
|
||||
groups.set(entry.teamId, [...entry.teamShares])
|
||||
}
|
||||
if (groups.size === 0) return [...rows]
|
||||
|
||||
const byTeamId = new Set(rows.map((row) => row.teamId))
|
||||
const ownEntry = new Set(entries.map((entry) => entry.teamId))
|
||||
const swallowed = new Set<number>()
|
||||
for (const [primary, members] of groups) {
|
||||
if (!byTeamId.has(primary)) continue
|
||||
for (const member of members) {
|
||||
if (member.teamId === primary || ownEntry.has(member.teamId)) continue
|
||||
swallowed.add(member.teamId)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
.filter((row) => !swallowed.has(row.teamId))
|
||||
.map((row) => {
|
||||
const members = groups.get(row.teamId)
|
||||
return members === undefined ? row : jointRankRow(row, members)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 被并进联队行、不再单独占行的成员单位里**原来录过名次**的那些名字。
|
||||
*
|
||||
* 它们的名次会在下一次保存(整组替换)时随载荷一起被清掉——这正是要堵的重复计分,
|
||||
* 但那是操作员的库里本来就有的数,界面上得说出来,不能悄悄改。
|
||||
*/
|
||||
export function mergedAwayRankNames(base: readonly TeamRankRow[], shown: readonly TeamRankRow[]): string[] {
|
||||
const kept = new Set(shown.map((row) => row.teamId))
|
||||
return base
|
||||
.filter((row) => !kept.has(row.teamId) && parseTeamRankInput(row.input) >= 1)
|
||||
.map((row) => row.shortName || row.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存载荷:整组回传。
|
||||
*
|
||||
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现,
|
||||
* 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。
|
||||
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以**页面上看到的每一行**都要出现,
|
||||
* 没填的显式给 0(清空)——漏掉一行等于把它原来的名次留在库里,页面看着却像已经清掉了。
|
||||
* 行由 mergeJointTeamRankRows 给:联队只出主单位这一行,被并掉的成员单位**故意**不在载荷里,
|
||||
* 它们原来那份名次会随之清掉(那正是要堵的重复计分)。
|
||||
*/
|
||||
export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput {
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
|
||||
import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../api/scoring'
|
||||
|
||||
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
@@ -69,7 +70,11 @@ test('录入页只在存在 aggregate 通道时显示口径选择,并整体回
|
||||
})
|
||||
|
||||
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/)
|
||||
// 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts 的 CHANNEL_*_LABELS),
|
||||
// 所以这里同时钉住两件事:页面用的是这份常量,常量就是现场认识的那句话。
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: CHANNEL_BASIS_LABELS\.aggregate \}/)
|
||||
assert.equal(CHANNEL_BASIS_LABELS.aggregate, '按队内平均')
|
||||
assert.equal(CHANNEL_SCOPE_LABELS.team, '团体名次')
|
||||
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
|
||||
assert.match(CHANNELS_SOURCE, /计入单位总分/)
|
||||
assert.match(CHANNELS_SOURCE, /只记在个人头上/)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { compareConflictMessage } from '../components/baseData/channelRows'
|
||||
|
||||
/**
|
||||
* 管理端保存计分通道前的守卫:compare=none 的小项不得配 basis=result / aggregate。
|
||||
*
|
||||
* 规则包导入侧早有这条(server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧
|
||||
* 见 server/internal/httpapi/repo_events.go——手工在管理端配一直没人拦,一直配到发布才 409。
|
||||
*/
|
||||
|
||||
const MANUAL_EVENT = { name: '台球', compare: 'none' as const }
|
||||
|
||||
test('compare=none 配 basis=result / aggregate:给出人话原因并点名小项', () => {
|
||||
for (const basis of ['result', 'aggregate'] as const) {
|
||||
const message = compareConflictMessage(MANUAL_EVENT, basis)
|
||||
assert.ok(message, `${basis} 必须被拦下`)
|
||||
assert.match(message, /台球/)
|
||||
assert.match(message, /不比较/)
|
||||
assert.match(message, /手动录名次/)
|
||||
}
|
||||
})
|
||||
|
||||
test('compare=none 配 basis=manual:放行', () => {
|
||||
assert.equal(compareConflictMessage(MANUAL_EVENT, 'manual'), null)
|
||||
})
|
||||
|
||||
test('有比较方向的小项:三种 basis 一律放行', () => {
|
||||
for (const compare of ['asc', 'desc'] as const) {
|
||||
for (const basis of ['result', 'manual', 'aggregate'] as const) {
|
||||
assert.equal(compareConflictMessage({ name: '100 米', compare }, basis), null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('还没选小项时不拦(页面显示的是空态)', () => {
|
||||
assert.equal(compareConflictMessage(null, 'result'), null)
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MANDATORY_COLUMNS,
|
||||
OPTIONAL_COLUMNS,
|
||||
REQUIRED_COLUMNS,
|
||||
SQUAD_COLUMN,
|
||||
buildSelectionRows,
|
||||
buildTeamSelectionRows,
|
||||
buildTemplateCsv,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
missingColumnMessage,
|
||||
registeredAthleteIds,
|
||||
registeredTeamIds,
|
||||
registeredTeamSquads,
|
||||
selectableTeamIds,
|
||||
selectionAllowsSubmit,
|
||||
setPickedTeams,
|
||||
setTeamPickSquad,
|
||||
teamSelectionAllowsSubmit,
|
||||
templateFileName,
|
||||
templateHeader,
|
||||
@@ -37,24 +40,26 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
||||
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
||||
})
|
||||
|
||||
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => {
|
||||
test('表头含证件号 / 性别 / 年龄 / 批次 / 队伍可选列,必填列不含这五列', () => {
|
||||
assert.deepEqual(
|
||||
[...REQUIRED_COLUMNS],
|
||||
[
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names', 'squad',
|
||||
],
|
||||
)
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
|
||||
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat'])
|
||||
assert.equal(REQUIRED_COLUMNS.at(-1), SQUAD_COLUMN, '队伍列在最后:老表格一列都不会错位')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat', 'squad'])
|
||||
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
|
||||
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
|
||||
assert.equal(MANDATORY_COLUMNS.includes('squad'), false, '队伍列可缺省,缺省即不区分队伍')
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 13 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
@@ -68,13 +73,14 @@ test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 名运动员')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', ''])
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排);
|
||||
// 队伍列留空 = 个人报名没有「第几支队」这回事。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -86,8 +92,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -100,7 +106,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
@@ -111,7 +117,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
|
||||
@@ -124,7 +130,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
|
||||
athletes: [],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
|
||||
@@ -147,13 +153,14 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 个单位')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
for (const row of rows.slice(1)) {
|
||||
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
|
||||
assert.equal(row[12], '', '不填队名时队伍列留空 = 不区分队伍')
|
||||
}
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', ''])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟', ''])
|
||||
})
|
||||
|
||||
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
|
||||
@@ -165,7 +172,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
|
||||
status: 'scratched',
|
||||
teams: [{ teamCode: 'F1', memberNames: [] }],
|
||||
})
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '', ''])
|
||||
})
|
||||
|
||||
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
|
||||
@@ -283,8 +290,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
|
||||
const lines = toCsv(rows).split('\n')
|
||||
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
|
||||
assert.equal(lines.length, 3, '表头 + 两个单位')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强",', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,', '只报单位时 member_names 留空')
|
||||
})
|
||||
|
||||
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
|
||||
@@ -303,6 +310,135 @@ test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只
|
||||
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
|
||||
})
|
||||
|
||||
// ── 队伍(迁移 0025):同一个单位在一个组别可以派多个队 ────────────────────────
|
||||
|
||||
test('不填队名时报名行为与改造前完全一致:队伍列留空、其余列一个字都没变', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [
|
||||
{ teamCode: 'F1', memberNames: ['王芳'], squad: '' },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '表头就是导入表本身(队伍列在最后、可空)')
|
||||
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳', ''])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
assert.equal(SQUAD_COLUMN, 'squad', '列名是服务端 importColumns 里的规范名')
|
||||
assert.equal(lookupImportColumn('队伍'), SQUAD_COLUMN, '中文表头「队伍」也认')
|
||||
assert.equal(lookupImportColumn('队名'), SQUAD_COLUMN)
|
||||
})
|
||||
|
||||
test('填了队名:队名进最后一列,其它列一个字不动', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [
|
||||
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'], squad: '一队' },
|
||||
// 同一个单位换个队名 = 这个单位的第二支队(服务端按 组别 + 单位 + 队伍 upsert)。
|
||||
{ teamCode: 'F1', memberNames: [], squad: '二队' },
|
||||
// 没填队名的单位照样一行,队名格留空。
|
||||
{ teamCode: 'F2', memberNames: ['张伟'] },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '一队'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '', '二队'])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '张伟', ''])
|
||||
for (const line of toCsv(rows).split('\n')) {
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length)
|
||||
}
|
||||
})
|
||||
|
||||
test('队名两侧空白去掉再上表:服务端存的就是去空白后的文本', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [{ teamCode: 'F1', memberNames: [], squad: ' 二队 ' }],
|
||||
})
|
||||
assert.equal(rows[1]?.[REQUIRED_COLUMNS.indexOf(SQUAD_COLUMN)], '二队')
|
||||
})
|
||||
|
||||
test('setTeamPickSquad 只改这一个单位的队名,逐个字输入都保留', () => {
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = setTeamPickSquad(state, 1, '一')
|
||||
state = setTeamPickSquad(state, 1, '一队')
|
||||
assert.equal(state.squads?.[1], '一队')
|
||||
assert.equal(state.squads?.[2], undefined, '另一个单位没填')
|
||||
assert.deepEqual(state.teamIds, [1, 2], '填队名不动勾选')
|
||||
// 清空输入框 = 改回不区分队伍,与从没填过一样。
|
||||
assert.equal(setTeamPickSquad(state, 1, '').squads?.[1], '')
|
||||
})
|
||||
|
||||
test('buildTeamSelections:填了队名的单位带上 squad,没填的与改造前逐字段相同', () => {
|
||||
const teams = [
|
||||
{ id: 1, code: 'F1' },
|
||||
{ id: 2, code: 'F2' },
|
||||
]
|
||||
const athletes = [{ id: 11, teamId: 1, name: '张伟', active: true }]
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = toggleMemberPick(state, 1, 11, true)
|
||||
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
|
||||
{ teamCode: 'F1', memberNames: ['张伟'] },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
])
|
||||
|
||||
const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
|
||||
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
|
||||
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
])
|
||||
})
|
||||
|
||||
test('勾选状态 → CSV 端到端:一个单位两个队就是两行,队名在最后一列', () => {
|
||||
const teams = [
|
||||
{ id: 1, code: 'F1' },
|
||||
{ id: 2, code: 'F2' },
|
||||
]
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = setTeamPickSquad(state, 1, '一队')
|
||||
state = setTeamPickSquad(state, 2, '青年队')
|
||||
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: buildTeamSelections(state, teams, []),
|
||||
})
|
||||
const lines = toCsv(rows).split('\n')
|
||||
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,,一队')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,青年队')
|
||||
})
|
||||
|
||||
test('registeredTeamSquads:按组别列出各单位已报名的队名,老报名(空串)不进表', () => {
|
||||
const entries = [
|
||||
{ categoryId: 7, teamId: 1, squad: '一队' },
|
||||
{ categoryId: 7, teamId: 1, squad: '二队' },
|
||||
{ categoryId: 7, teamId: 1, squad: '一队' },
|
||||
{ categoryId: 7, teamId: 2, squad: '' },
|
||||
{ categoryId: 7, teamId: 3 },
|
||||
{ categoryId: 8, teamId: 4, squad: '一队' },
|
||||
]
|
||||
const squads = registeredTeamSquads(entries, 7)
|
||||
assert.deepEqual(squads.get(1), ['一队', '二队'], '按报名表出现顺序、去重')
|
||||
assert.equal(squads.has(2), false, '不区分队伍的老报名不进表,徽标仍只说「已报名」')
|
||||
assert.equal(squads.has(3), false, '缺字段(老服务端)同样当空串')
|
||||
assert.equal(squads.has(4), false, '别的组别不算')
|
||||
assert.deepEqual([...registeredTeamSquads(entries, undefined)], [])
|
||||
})
|
||||
|
||||
test('entryRowLabel 团体行带上队名:同一单位的两个队在报错文案里也分得开', () => {
|
||||
assert.equal(entryRowLabel({ teamShort: '一司', squad: '二队' }), '一司二队')
|
||||
assert.equal(entryRowLabel({ teamShort: '一司', teamName: '第一分公司', squad: '' }), '一司', '没队名时不变')
|
||||
assert.equal(entryRowLabel({ teamName: '第一分公司', squad: '青年队' }), '第一分公司青年队')
|
||||
assert.equal(entryRowLabel({ athleteName: '张伟', teamShort: '一司', squad: '二队' }), '张伟', '个人行只写姓名')
|
||||
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
|
||||
})
|
||||
|
||||
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
|
||||
// 否则防呆校验的文案会变成「:缺少成绩」。
|
||||
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
|
||||
@@ -321,8 +457,9 @@ test('两种语言的模板表头逐列对应:列数、顺序一致,只有
|
||||
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
|
||||
assert.equal(zh.length, en.length, '两种模板列数必须一样')
|
||||
assert.deepEqual(zh, [
|
||||
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
|
||||
])
|
||||
assert.equal(zh.at(-1), '队伍', '队伍列也列在模板最后,与服务端 importColumns 同序')
|
||||
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
|
||||
assert.deepEqual(
|
||||
zh,
|
||||
@@ -340,7 +477,7 @@ test('模板 CSV:只有表头语言不同,示例行与列数完全相同', (
|
||||
assert.equal(en[0], templateHeader('en').join(','))
|
||||
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
|
||||
for (const line of zh) {
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列')
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 13 列')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -357,6 +494,7 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
|
||||
assert.equal(lookupImportColumn('项目编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('小项编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('队员名单'), 'member_names')
|
||||
assert.equal(lookupImportColumn('队伍'), 'squad', '队伍列的中文表头')
|
||||
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
|
||||
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
|
||||
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
|
||||
@@ -368,13 +506,21 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
|
||||
|
||||
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
|
||||
const index = importColumnIndex([
|
||||
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
|
||||
])
|
||||
assert.equal(index.get('event_code'), 0)
|
||||
assert.equal(index.get('category'), 1)
|
||||
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
|
||||
assert.equal(index.get('squad'), 12, '队伍列在最后')
|
||||
assert.equal(index.size, REQUIRED_COLUMNS.length)
|
||||
|
||||
// 老表格(12 列、没有队伍列)照样认得:队伍是可选列,缺列 = 不区分队伍(老流程一个字不用改)。
|
||||
const legacy = importColumnIndex([
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
])
|
||||
assert.equal(legacy.size, REQUIRED_COLUMNS.length - 1)
|
||||
assert.equal(legacy.has('squad'), false)
|
||||
|
||||
for (const header of [
|
||||
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { ApiError, teamSquadLabel } from '@/shared'
|
||||
import {
|
||||
entrySquadEditable,
|
||||
entrySquadPath,
|
||||
squadDraftDirty,
|
||||
squadDraftOf,
|
||||
submitEntrySquad,
|
||||
} from '../pages/entrySquad'
|
||||
|
||||
/**
|
||||
* 管理端的队伍展示、录入与改名(迁移 0025)。
|
||||
*
|
||||
* SFC 挂不进 `node --test`,所以这里用源码契约钉住接线;口径本身(拼名字、空串不变)在
|
||||
* shared/tests/squad.test.ts 与大屏 joint-teams.test.ts 里逐条测。要守住的是四件事:
|
||||
* 1. 录入页 / 复核页的「单位」列显示 **单位名 + 队名**(不加括号);
|
||||
* 2. 队名为空时那几个表达式与改造前逐字相同(老数据与单队单位一个像素都不变);
|
||||
* 3. 队名是在补报名(团体勾选)里填的,**勾上单位之后**才出现那个输入框,
|
||||
* 不勾(常规流程)时界面上根本看不到它;
|
||||
* 4. 已经报好的队名在「成员单位与份额」弹窗里能改(PUT /api/entries/{id}/squad),
|
||||
* 但只对纯团体报名出现,改哪个才发哪个请求,服务端的中文 400 原样上屏。
|
||||
*/
|
||||
|
||||
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
|
||||
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('录入页:单位列显示「单位名 + 队名」,队名来自报名行', () => {
|
||||
assert.match(
|
||||
ENTRY,
|
||||
/function teamCellText\(row: EditableRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
|
||||
'拼名字只有 shared/squad.ts 一份实现',
|
||||
)
|
||||
assert.match(ENTRY, /\{\{ teamCellText\(row\) \}\}/, '单位格用带队名的文案')
|
||||
assert.doesNotMatch(ENTRY, /\{\{ row\.teamShort \|\| row\.teamName \}\}/, '老写法不能再留在单位格上')
|
||||
assert.match(ENTRY, /squad: normalizeSquad\(entry\.squad\)/, '老服务端不下发 squad 时按空串')
|
||||
})
|
||||
|
||||
test('录入页:批量确认框里的单位也带上队名(同一个单位的两个队要分得清删的是哪条)', () => {
|
||||
assert.match(ENTRY, /team: teamCellText\(row\),/)
|
||||
})
|
||||
|
||||
test('录入页:联队那一格仍按份额逐行列,队名单独一行写出来', () => {
|
||||
assert.match(ENTRY, /teamSquadLabel\(jointTeamLabel\(row\.teamShares\), row\.squad\)/, '联队的悬停文案带队名')
|
||||
assert.match(ENTRY, /v-if="row\.squad !== ''"[\s\S]{0,80}队伍 \{\{ row\.squad \}\}/)
|
||||
})
|
||||
|
||||
test('复核页:单位列同样显示「单位名 + 队名」', () => {
|
||||
assert.match(
|
||||
REVIEW,
|
||||
/function teamCellText\(row: DraftRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
|
||||
)
|
||||
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
|
||||
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
|
||||
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
|
||||
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
|
||||
})
|
||||
|
||||
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
|
||||
assert.match(PICKER, /v-if="isTeamPicked\(team\.id\)"/, '输入框跟着勾选出现')
|
||||
assert.match(PICKER, /:value="squadTextOf\(team\.id\)"/, '回显输入框原文')
|
||||
assert.match(PICKER, /@input="onSquadInput\(team\.id, \$event\)"/)
|
||||
assert.match(PICKER, /teamPick\.value = setTeamPickSquad\(teamPick\.value, teamId, input\.value\)/)
|
||||
assert.match(PICKER, /placeholder="队伍(留空=不区分)"/, '留空 = 不区分队伍,要写出来')
|
||||
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
|
||||
assert.doesNotMatch(PICKER, /team-squad-input[\s\S]{0,200}type="number"/)
|
||||
})
|
||||
|
||||
test('补报名:已报名徽标写清已报过哪些队名,避免把二队又写成一队', () => {
|
||||
assert.match(PICKER, /registeredTeamSquads\(entries\.value, currentCategory\.value\?\.id\)/)
|
||||
assert.match(PICKER, /registeredSquadLabel\(team\.id\)/)
|
||||
})
|
||||
|
||||
// ── 改队名:PUT /api/entries/{id}/squad(入口在「成员单位与份额」弹窗里) ──────────────
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
credentials?: string
|
||||
}
|
||||
|
||||
let lastRequest: Captured = {}
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
credentials: init?.credentials,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
function withCsrfCookie(cookie: string, run: () => Promise<void>): Promise<void> {
|
||||
const original = (globalThis as { document?: unknown }).document
|
||||
;(globalThis as { document?: unknown }).document = { cookie }
|
||||
return run().finally(() => {
|
||||
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||||
else (globalThis as { document?: unknown }).document = original
|
||||
})
|
||||
}
|
||||
|
||||
test('改队名只对纯团体报名开放:个人报名(有运动员)连输入框都不出现', () => {
|
||||
assert.equal(entrySquadEditable({ athleteId: null }), true, '纯团体行才能改队名')
|
||||
assert.equal(entrySquadEditable({ athleteId: 42 }), false, '个人报名按人报,服务端也会 400')
|
||||
// 弹窗里的那一块跟着这个判断走:个人报名整块不渲染,不会让人白点一次保存。
|
||||
assert.match(ENTRY, /v-if="shareEditor\.teamEntry" class="share-squad"/)
|
||||
assert.match(ENTRY, /placeholder="队伍(留空=不区分)"/)
|
||||
assert.match(ENTRY, /teamEntry: entrySquadEditable\(row\),/)
|
||||
assert.match(ENTRY, /squad: squadDraftOf\(row\),/)
|
||||
assert.match(ENTRY, /originalSquad: squadDraftOf\(row\),/)
|
||||
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
|
||||
assert.doesNotMatch(ENTRY, /squad-input[\s\S]{0,300}type="number"/)
|
||||
})
|
||||
|
||||
test('队名留空 = 不区分队伍:老数据读出来是空串,写回去也是空串(不是不发这个字段)', async () => {
|
||||
assert.equal(squadDraftOf({ squad: undefined }), '', '老服务端不下发 squad')
|
||||
assert.equal(squadDraftOf({ squad: null }), '')
|
||||
assert.equal(squadDraftOf({ squad: ' ' }), '', '只有空白等于没填')
|
||||
assert.equal(squadDraftOf({ squad: ' 青年队 ' }), '青年队')
|
||||
// 留空时单位列的文案与改造前逐字一样(队名一个字符都不多)。
|
||||
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
|
||||
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '' } }))
|
||||
const outcome = await submitEntrySquad(7, '')
|
||||
const body = lastRequest.body as { squad?: unknown }
|
||||
assert.equal(body.squad, '', '空串是合法值:字段要在,不能被省略成 undefined')
|
||||
assert.equal(outcome.squad, '', '服务端回显空串时本地也按不区分队伍写回')
|
||||
})
|
||||
|
||||
test('队名没改动时不发请求:比的是归一后的值,多一个空格不算改', () => {
|
||||
assert.equal(squadDraftDirty('', ''), false, '两边都留空 = 没动过')
|
||||
assert.equal(squadDraftDirty('一队', '一队'), false)
|
||||
assert.equal(squadDraftDirty(' 一队 ', '一队'), false, '首尾空白服务端也会 trim,不该算成一次改动')
|
||||
assert.equal(squadDraftDirty(' ', ''), false)
|
||||
assert.equal(squadDraftDirty('一队', ''), true)
|
||||
assert.equal(squadDraftDirty('', '一队'), true, '清空队名也是一次改动(改回不区分队伍)')
|
||||
assert.equal(squadDraftDirty('二队', '一队'), true)
|
||||
|
||||
// 保存按钮只在「份额或队名任一改了」时才亮(没改动就点不动,与既有做法一致)。
|
||||
assert.match(ENTRY, /const shareEditorDirty = computed\(\(\) => shareChanged\.value \|\| squadChanged\.value\)/)
|
||||
assert.match(ENTRY, /if \(editor === null \|\| !canSaveShares\.value\) return/)
|
||||
assert.match(ENTRY, /shareEditorDirty\.value &&\s*shareIssues\.value\.length === 0/)
|
||||
// 改哪一块发哪一个:只改队名时不会顺手把没动过的份额整组重发一遍。
|
||||
assert.match(ENTRY, /const sharesDirty = shareChanged\.value\s*const squadDirty = squadChanged\.value/)
|
||||
assert.match(ENTRY, /if \(sharesDirty\) \{[\s\S]{0,400}submitEntryTeamShares\(/)
|
||||
assert.match(ENTRY, /if \(squadDirty\) \{[\s\S]{0,400}submitEntrySquad\(/)
|
||||
})
|
||||
|
||||
test('保存走 PUT /api/entries/{id}/squad,队名 trim 后发出,带 CSRF 与同一个幂等键', async () => {
|
||||
await withCsrfCookie('a=1; csrf_token=tok-9; b=2', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '青年队' } }))
|
||||
|
||||
const outcome = await submitEntrySquad(7, ' 青年队 ')
|
||||
|
||||
assert.equal(lastRequest.url, entrySquadPath(7))
|
||||
assert.equal(lastRequest.url, '/api/entries/7/squad')
|
||||
assert.equal(lastRequest.method, 'PUT')
|
||||
assert.equal(lastRequest.credentials, 'include')
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
|
||||
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
|
||||
assert.deepEqual(lastRequest.body, {
|
||||
squad: '青年队',
|
||||
requestId: lastRequest.headers?.['X-Request-Id'],
|
||||
})
|
||||
assert.equal(outcome.entry?.id, 7)
|
||||
assert.equal(outcome.squad, '青年队')
|
||||
})
|
||||
})
|
||||
|
||||
test('幂等键由调用方给定时 header 与 body 用同一把(超时重试不会落两遍)', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, squad: '二队' } }))
|
||||
await submitEntrySquad(7, '二队', 'req-fixed-1')
|
||||
assert.equal(lastRequest.headers?.['X-Request-Id'], 'req-fixed-1')
|
||||
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'req-fixed-1')
|
||||
})
|
||||
|
||||
test('回显里没有 squad 字段(老服务端)时交给调用方退回草稿,不把队名抹成空', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1 } }))
|
||||
const outcome = await submitEntrySquad(7, '青年队')
|
||||
assert.equal(outcome.squad, null, 'null 而不是空串:空串是「用户清空了队名」这个合法值')
|
||||
assert.match(ENTRY, /const saved = outcome\.squad \?\? editor\.squad/)
|
||||
})
|
||||
|
||||
test('保存后当场改本地那一行:单位列文案从「第一分公司」变成「第一分公司青年队」', () => {
|
||||
assert.match(ENTRY, /if \(row !== undefined\) row\.squad = saved/)
|
||||
assert.match(ENTRY, /editor\.squad = saved\s*editor\.originalSquad = saved/, '保存过的值成为新的初值')
|
||||
// 单位列的文案只认这一份实现:row.squad 一改,表格与大屏同时跟着变。
|
||||
assert.match(ENTRY, /return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/)
|
||||
// 输入框旁边先念一遍改完长什么样(空串时就是单位名本身)。
|
||||
assert.match(ENTRY, /return editor === null \? '' : teamSquadLabel\(editor\.teamCellBase, editor\.squad\)/)
|
||||
assert.match(ENTRY, /teamCellBase: row\.teamShort \|\| row\.teamName,/)
|
||||
assert.equal(teamSquadLabel('第一分公司', '青年队'), '第一分公司青年队')
|
||||
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
|
||||
})
|
||||
|
||||
test('服务端 400 的中文原文原样抛出(尤其「队名重复」那条,界面直接展示这句话)', async () => {
|
||||
const message = '组别「男子双打」下单位「第一分公司」已经有「一队」了:同一个单位在同一组别里队名不能重复'
|
||||
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message } }, 400))
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(7, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||
assert.equal(error.message, message, '一个字都不能被前端改写')
|
||||
return true
|
||||
},
|
||||
)
|
||||
// 弹窗直接把这句话放到错误条里,不换成自己的措辞。
|
||||
assert.match(ENTRY, /editor\.error = caught instanceof Error \? caught\.message/)
|
||||
assert.match(ENTRY, /\{\{ shareEditor\.error \}\}/)
|
||||
})
|
||||
|
||||
test('报名不存在 / 网络不可达都说清「队名没有改成功」,不让人以为已经落库', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '报名不存在或已被移出' } }, 404),
|
||||
)
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(999, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.message, '报名不存在或已被移出')
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(7, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'NETWORK')
|
||||
assert.match(error.message, /队名没有改成功/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
|
||||
/**
|
||||
* 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端
|
||||
* domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。
|
||||
*
|
||||
* 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks),
|
||||
* 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的
|
||||
* rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。
|
||||
*/
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 1,
|
||||
sportId: 1,
|
||||
code: 'BALL',
|
||||
name: '台球',
|
||||
eventType: 'team',
|
||||
resultMode: 'manual',
|
||||
resultType: 'none',
|
||||
compare: 'none',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
|
||||
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 1, scope: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
|
||||
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
|
||||
}
|
||||
|
||||
/** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */
|
||||
const TEAM_PROFILE = makeProfile({
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 10 },
|
||||
{ rank: 3, points: 8 },
|
||||
{ rank: 4, points: 4 },
|
||||
{ rank: 5, points: 2 },
|
||||
{ rank: 6, points: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const SIX_UNITS: TeamRankEntry[] = [
|
||||
{ teamId: 100, rank: 1 },
|
||||
{ teamId: 101, rank: 2 },
|
||||
{ teamId: 102, rank: 3 },
|
||||
{ teamId: 103, rank: 4 },
|
||||
{ teamId: 104, rank: 5 },
|
||||
{ teamId: 105, rank: 6 },
|
||||
]
|
||||
|
||||
function close(actual: number, expected: number): void {
|
||||
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
|
||||
}
|
||||
|
||||
test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => {
|
||||
// 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks,
|
||||
// 预览与引擎必须给出同一组数字。
|
||||
// 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。
|
||||
const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({
|
||||
entryId: index + 1,
|
||||
teamId,
|
||||
athleteId: index + 1,
|
||||
resultValue: null,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
}))
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
|
||||
)
|
||||
const expected = [15, 10, 8, 4, 2, 1]
|
||||
let total = 0
|
||||
for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) {
|
||||
const points = result.teamPoints.get(teamId) ?? 0
|
||||
close(points, expected[index] ?? 0)
|
||||
total += points
|
||||
}
|
||||
close(total, 40)
|
||||
})
|
||||
|
||||
test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => {
|
||||
// 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行;
|
||||
// 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。
|
||||
const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({
|
||||
entryId: index + 1,
|
||||
teamId: 100 + index,
|
||||
athleteId: index + 1,
|
||||
resultValue: null,
|
||||
rank,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
}))
|
||||
const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
|
||||
assert.equal(result.teamPoints.size, 0)
|
||||
assert.equal(result.rowPoints.size, 0)
|
||||
})
|
||||
|
||||
test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => {
|
||||
// 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
]
|
||||
const fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
|
||||
close(fromRows.teamPoints.get(100) ?? 0, 15)
|
||||
close(fromRows.teamPoints.get(101) ?? 0, 10)
|
||||
assert.equal(fromRows.ranks.get(1), 1)
|
||||
|
||||
// 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。
|
||||
const overridden = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
channels: [makeChannel()],
|
||||
profiles: [TEAM_PROFILE],
|
||||
teamRanks: [
|
||||
{ teamId: 100, rank: 2 },
|
||||
{ teamId: 101, rank: 1 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
close(overridden.teamPoints.get(100) ?? 0, 10)
|
||||
close(overridden.teamPoints.get(101) ?? 0, 15)
|
||||
assert.equal(overridden.ranks.get(1), 2)
|
||||
})
|
||||
|
||||
test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => {
|
||||
// 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表
|
||||
// → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] })
|
||||
const result = computeEventScore(
|
||||
[
|
||||
{
|
||||
entryId: 1,
|
||||
teamId: 100,
|
||||
athleteId: 0,
|
||||
resultValue: null,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
shares: [
|
||||
{ teamId: 100, weight: 1 },
|
||||
{ teamId: 200, weight: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }),
|
||||
)
|
||||
close(result.teamPoints.get(100) ?? 0, 7.5)
|
||||
close(result.teamPoints.get(200) ?? 0, 7.5)
|
||||
close(result.rowPoints.get(1) ?? 0, 15)
|
||||
})
|
||||
|
||||
test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => {
|
||||
// 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时
|
||||
// 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。
|
||||
const profile = makeProfile({
|
||||
lastPlaceZero: true,
|
||||
rules: [
|
||||
{ rank: 1, points: 10 },
|
||||
{ rank: 2, points: 6 },
|
||||
{ rank: 3, points: 3 },
|
||||
],
|
||||
})
|
||||
const result = computeEventScore(
|
||||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
|
||||
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 10)
|
||||
close(result.rowPoints.get(2) ?? 0, 10)
|
||||
close(result.teamPoints.get(100) ?? 0, 10)
|
||||
close(result.teamPoints.get(101) ?? 0, 10)
|
||||
})
|
||||
|
||||
test('lastPlaceZero 仍要抹掉真正的末位那一组', () => {
|
||||
const profile = makeProfile({
|
||||
lastPlaceZero: true,
|
||||
rules: [
|
||||
{ rank: 1, points: 10 },
|
||||
{ rank: 2, points: 6 },
|
||||
{ rank: 3, points: 3 },
|
||||
],
|
||||
})
|
||||
const result = computeEventScore(
|
||||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
|
||||
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 10)
|
||||
close(result.rowPoints.get(2) ?? 0, 6)
|
||||
close(result.rowPoints.get(3) ?? 0, 0)
|
||||
})
|
||||
|
||||
test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => {
|
||||
const profile = makeProfile({
|
||||
rules: [
|
||||
{ rank: 1, points: 10 },
|
||||
{ rank: 2, points: 6 },
|
||||
],
|
||||
})
|
||||
const result = computeEventScore(
|
||||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual', resultMode: 'manual' }),
|
||||
channels: [makeChannel({ scope: 'athlete', basis: 'result' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错')
|
||||
assert.match(result.error.message, /比较方向/)
|
||||
assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名')
|
||||
// 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。
|
||||
assert.equal(result.teamPoints.size, 0)
|
||||
assert.equal(result.rowPoints.size, 0)
|
||||
})
|
||||
|
||||
test('compare=none 配 basis=aggregate:同样报错', () => {
|
||||
const result = computeEventScore(
|
||||
[makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count' }),
|
||||
channels: [makeChannel({ basis: 'aggregate' })],
|
||||
profiles: [TEAM_PROFILE],
|
||||
}),
|
||||
)
|
||||
assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired')
|
||||
assert.match(result.error.message, /比较方向/)
|
||||
})
|
||||
|
||||
test('compare=none 配 basis=manual:照常算分,不报错', () => {
|
||||
const result = computeEventScore(
|
||||
[],
|
||||
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
|
||||
)
|
||||
assert.equal(result.error, null)
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
})
|
||||
|
||||
function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow {
|
||||
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
|
||||
}
|
||||
|
||||
// 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言:
|
||||
// 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => {
|
||||
assert.match(
|
||||
ENTRY_SOURCE,
|
||||
/const previewTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/,
|
||||
)
|
||||
assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
|
||||
assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => \{/)
|
||||
assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/)
|
||||
})
|
||||
|
||||
test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => {
|
||||
assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/)
|
||||
assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/)
|
||||
})
|
||||
@@ -87,7 +87,10 @@ test('lastPlaceZero 让末位整组 0 分', () => {
|
||||
close(result.rowPoints.get(3) ?? 0, 0)
|
||||
})
|
||||
|
||||
test('末位并列整组都按最后一名处理', () => {
|
||||
test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => {
|
||||
// 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」):
|
||||
// lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3,
|
||||
// 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。
|
||||
const profile = makeProfile({
|
||||
lastPlaceZero: true,
|
||||
rules: [
|
||||
@@ -101,8 +104,8 @@ test('末位并列整组都按最后一名处理', () => {
|
||||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 9)
|
||||
close(result.rowPoints.get(2) ?? 0, 0)
|
||||
close(result.rowPoints.get(3) ?? 0, 0)
|
||||
close(result.rowPoints.get(2) ?? 0, 6)
|
||||
close(result.rowPoints.get(3) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('scored=false 只排名不计分', () => {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { ScoreChannel, Team, TeamRanksView } from '@/shared'
|
||||
import { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
mergeJointTeamRankRows,
|
||||
mergedAwayRankNames,
|
||||
parseTeamRankInput,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
teamRanksDirty,
|
||||
teamRanksPayload,
|
||||
type TeamRankEntryRef,
|
||||
} from '../pages/teamRanks'
|
||||
import { mockDb } from '../api/mock'
|
||||
|
||||
@@ -48,6 +51,130 @@ function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ──────
|
||||
|
||||
/** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */
|
||||
interface Member {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const JG: Member = { id: 1, code: 'JG', name: '机关本部' }
|
||||
const F1: Member = { id: 2, code: 'F1', name: '第一分公司' }
|
||||
const F3: Member = { id: 3, code: 'F3', name: '第三分公司' }
|
||||
|
||||
/** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */
|
||||
function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef {
|
||||
return {
|
||||
teamId,
|
||||
teamShares: normalizeTeamShares(
|
||||
members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行')
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[0]?.shortName, null)
|
||||
assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法')
|
||||
assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2])
|
||||
})
|
||||
|
||||
test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => {
|
||||
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
assert.equal(merged.length, 1)
|
||||
assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行')
|
||||
assert.equal(teamRanksDirty(merged), false)
|
||||
// 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。
|
||||
assert.deepEqual(teamRanksPayload(merged), {
|
||||
scope: 'team',
|
||||
basis: 'manual',
|
||||
ranks: [{ teamId: 1, rank: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
merged[0]!.input = 4
|
||||
assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行')
|
||||
assert.equal(teamRanksDirty(merged), true)
|
||||
assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }])
|
||||
})
|
||||
|
||||
test('组别里没有联队报名时,行与现在一字不差', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
|
||||
// 单单位报名(份额只有一条)不是联队:连对象都不该换一个。
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows)
|
||||
assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0])
|
||||
// 没有报名时同理。
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, []), rows)
|
||||
})
|
||||
|
||||
test('成员单位自己也有报名时两条都列出来,且分得清', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条')
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[1]?.name, '第一分公司')
|
||||
assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队')
|
||||
assert.equal(merged[1], rows[1], '普通行原样透传')
|
||||
})
|
||||
|
||||
test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => {
|
||||
const rows = teamRankRowsOf(
|
||||
makeView({
|
||||
teams: [
|
||||
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
|
||||
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
|
||||
{ teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1, 2])
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[1]?.name, '第一分公司+第三分公司联队')
|
||||
})
|
||||
|
||||
test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => {
|
||||
const rows = teamRankRowsOf(
|
||||
makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }),
|
||||
[],
|
||||
)
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows)
|
||||
})
|
||||
|
||||
test('参赛单位数按合并后的行数算', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
merged[0]!.input = '2'
|
||||
|
||||
assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/)
|
||||
assert.deepEqual([...teamRankFlaggedTeams(merged)], [1])
|
||||
})
|
||||
|
||||
test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => {
|
||||
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
|
||||
assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司'])
|
||||
|
||||
// 没录过名次的成员单位不点名。
|
||||
const clean = teamRankRowsOf(makeView(), [])
|
||||
assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), [])
|
||||
})
|
||||
|
||||
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
|
||||
assert.equal(hasTeamManualChannel([makeChannel()]), true)
|
||||
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
|
||||
@@ -219,3 +346,14 @@ test('录入页按契约接线:区块可见性、随行数据优先、保存
|
||||
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
|
||||
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
|
||||
})
|
||||
|
||||
test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => {
|
||||
// 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。
|
||||
assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/)
|
||||
// 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。
|
||||
assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队</)
|
||||
assert.match(ENTRY_SOURCE, /shareText\(member\)/)
|
||||
// 被并掉的成员单位原来录过的名次要点名,别静默清掉。
|
||||
assert.match(ENTRY_SOURCE, /mergedAwayRankNames\(/)
|
||||
assert.match(ENTRY_SOURCE, /teamRankMergedAwayNames/)
|
||||
})
|
||||
Reference in new issue
Block a user