feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。
## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`
## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
`entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
(`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。
## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
(份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
**表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。
## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
**普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
logo 数留位);底栏同理。
## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
`aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。
## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
This commit is contained in:
1 parent
b631418a85
commit
830a4dbdb7
61 files changed
+5649
-121
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam, splitSharePoints } from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
ChannelBasis,
|
||||
@@ -30,6 +30,13 @@ export interface ScorableRow {
|
||||
rank: number | null
|
||||
status: ResultStatus
|
||||
isTie: boolean
|
||||
/**
|
||||
* 归属份额(联队,devdocs/19);普通报名不传 = 主单位独占 100%。
|
||||
*
|
||||
* 引擎侧按份额把分拆给各成员单位(scoring.go 的 ledgerFromRanked),录入页的实时预览要同口径,
|
||||
* 否则联队那条会显示成「A +15」,而发布后 A、B 各 +7.5。
|
||||
*/
|
||||
shares?: readonly { teamId: number; weight: number }[]
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9
|
||||
@@ -300,17 +307,50 @@ function rankOnlyChannel(event: Event): ScoreChannel {
|
||||
}
|
||||
}
|
||||
|
||||
/** 一行折算到某个单位的贡献(联队时一行会有多条)。 */
|
||||
export interface RowTeamShare {
|
||||
teamId: number
|
||||
points: number
|
||||
}
|
||||
|
||||
export interface EventScoreResult {
|
||||
/** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
|
||||
rowPoints: Map<number, number>
|
||||
/** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
|
||||
rowTeamPoints: Map<number, number>
|
||||
/**
|
||||
* 上一行贡献按份额拆到各成员单位的结果,普通报名只有一条(主单位)。
|
||||
*
|
||||
* 「已发布的分要从单位总分里扣掉」时不能按 `row.teamId` 累加 rowTeamPoints:
|
||||
* 联队那条的整份分会全记在主单位头上,扣出来的差值在 B 单位那边是错的。
|
||||
*/
|
||||
rowTeamShares: Map<number, RowTeamShare[]>
|
||||
/** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
|
||||
ranks: Map<number, number | null>
|
||||
/** 按单位聚合的得分增量,用于预览面板。 */
|
||||
teamPoints: Map<number, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。
|
||||
*
|
||||
* 聚合名次(entryId = 0,主体是单位或运动员)与没有配份额的行一律「主单位独占」:
|
||||
* 引擎那边聚合并联队不拆份额(名次不对应报名项,读取侧回查不到份额),预览必须同口径,
|
||||
* 否则会出现「预览按份额拆、发布后整份记给主单位」。
|
||||
*/
|
||||
function rowTeamShares(
|
||||
entryId: number,
|
||||
teamId: number,
|
||||
shares: readonly { teamId: number; weight: number }[] | undefined,
|
||||
contribution: number,
|
||||
): RowTeamShare[] {
|
||||
if (entryId <= 0 || shares === undefined || shares.length < 2) {
|
||||
return [{ teamId, points: contribution }]
|
||||
}
|
||||
const parts = splitSharePoints(contribution, shares)
|
||||
return shares.map((share, index) => ({ teamId: share.teamId, points: parts[index] ?? 0 }))
|
||||
}
|
||||
|
||||
export interface ScoreContext {
|
||||
event: Event
|
||||
category: EventCategory | null
|
||||
@@ -335,10 +375,16 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
|
||||
const rowPoints = new Map<number, number>()
|
||||
const rowTeamPoints = new Map<number, number>()
|
||||
const rowShares = new Map<number, RowTeamShare[]>()
|
||||
const ranks = new Map<number, number | null>()
|
||||
const rankScope = new Map<number, ChannelScope>()
|
||||
const teamPoints = new Map<number, number>()
|
||||
|
||||
const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>()
|
||||
for (const row of rows) {
|
||||
if (row.shares !== undefined && row.shares.length > 0) sharesByEntry.set(row.entryId, row.shares)
|
||||
}
|
||||
|
||||
for (const channel of channels) {
|
||||
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
|
||||
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
|
||||
@@ -346,11 +392,27 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
|
||||
const countToTeam = normalizeCountToTeam(channel.countToTeam)
|
||||
for (const row of ranked) {
|
||||
// 个人通道的分要按个人→单位系数折算,团体通道的分本来就是单位的分。
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
const parts = countToTeam
|
||||
? rowTeamShares(row.entryId, row.teamId, sharesByEntry.get(row.entryId), teamContribution)
|
||||
: []
|
||||
if (row.entryId > 0) {
|
||||
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
|
||||
if (countToTeam) {
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
|
||||
rowTeamPoints.set(
|
||||
row.entryId,
|
||||
(rowTeamPoints.get(row.entryId) ?? 0) + teamContribution,
|
||||
)
|
||||
// 多通道叠加时按单位合并,而不是按下标对齐:渠道顺序一变,按下标就会串位。
|
||||
const merged = new Map((rowShares.get(row.entryId) ?? []).map((part) => [part.teamId, part.points]))
|
||||
for (const part of parts) {
|
||||
merged.set(part.teamId, (merged.get(part.teamId) ?? 0) + part.points)
|
||||
}
|
||||
rowShares.set(
|
||||
row.entryId,
|
||||
[...merged].map(([teamId, points]) => ({ teamId, points })),
|
||||
)
|
||||
}
|
||||
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
|
||||
const current = rankScope.get(row.entryId)
|
||||
@@ -360,13 +422,14 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
|
||||
}
|
||||
}
|
||||
if (!countToTeam) continue
|
||||
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
|
||||
teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
|
||||
for (const part of parts) {
|
||||
teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
|
||||
}
|
||||
return { rowPoints, rowTeamPoints, ranks, teamPoints }
|
||||
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { MAX_JOINT_TEAM_MEMBERS } from '@/shared'
|
||||
import type { ConflictAction, EntryImportResult, ImportConflict, SameNameNewPerson } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import EntryPicker from './EntryPicker.vue'
|
||||
@@ -7,6 +8,7 @@ import { useBaseData } from './useBaseData'
|
||||
import { entryImportSizeError } from './importLimits'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import {
|
||||
COLUMN_ALIASES,
|
||||
buildTemplateCsv,
|
||||
conflictDiffs,
|
||||
conflictLabel,
|
||||
@@ -173,6 +175,14 @@ async function runImport(): Promise<void> {
|
||||
const HEADER_EN = templateHeader('en').join(' / ')
|
||||
const HEADER_ZH = templateHeader('zh').join(' / ')
|
||||
|
||||
/**
|
||||
* 联队说明里的两个词:单位编码列的中文写法、成员上限。
|
||||
*
|
||||
* 两个都不写死:列名取自与服务端同源的别名表,上限取自共享层(与服务端 maxJointTeamMembers 同一个数),
|
||||
* 别名或上限改了说明跟着变,不会出现「说明写 5、服务端拦在第 3 个」这种对不上。
|
||||
*/
|
||||
const UNIT_CODE_LABEL = COLUMN_ALIASES.team_code[0]
|
||||
|
||||
function downloadTemplate(language: TemplateLanguage): void {
|
||||
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
|
||||
const blob = new Blob([`\uFEFF${buildTemplateCsv(language)}\n`], { type: 'text/csv;charset=utf-8' })
|
||||
@@ -281,6 +291,12 @@ function saveBlob(blob: Blob, filename: string): void {
|
||||
id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
<br />
|
||||
<strong>联队(两个单位合并参赛)</strong>:把
|
||||
<strong>{{ UNIT_CODE_LABEL }}</strong> 那一格写成 <strong>A+B</strong> —— 加号左边的是<strong>主单位</strong>,
|
||||
分数与奖牌按份额记给两个单位(默认均分,奖牌各记一枚),全角「+」与两侧空格都认。
|
||||
最多 {{ MAX_JOINT_TEAM_MEMBERS }} 个单位;<code>A+</code>、<code>+B</code>、<code>A+A</code> 这些写法会被拦下,
|
||||
并逐行告诉你是哪一格写错了。要改成别的份额(例如 2:1)到「成绩录入」页点该行的<strong>单位那一格</strong>调整。
|
||||
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
|
||||
两者都没命中才按「姓名 + 单位」找——单位内恰好一人且身份字段对得上(或有一边没填)就命中并把证件号 / 号码布回填进档案;
|
||||
恰好一人但两边的证件号(或号码布)对不上,按「同名的另一个人」新建档案(结果里会单独列出,不报错);
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
eventInputOf,
|
||||
formatPoints,
|
||||
hasAggregateChannel,
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeTeamShares,
|
||||
parseResultValue,
|
||||
shareText,
|
||||
type AvgMode,
|
||||
type DraftResultInput,
|
||||
type EntryStatus,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
type ResultState,
|
||||
type ResultStatus,
|
||||
type TeamRanksView,
|
||||
type TeamShareView,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -39,6 +44,19 @@ import {
|
||||
type TeamRankRow,
|
||||
} from './teamRanks'
|
||||
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||||
import {
|
||||
addShareDraftTeam,
|
||||
removeShareDraftTeam,
|
||||
selectableShareTeams,
|
||||
setShareDraftWeight,
|
||||
shareDraftDirty,
|
||||
shareDraftIssues,
|
||||
shareDraftOf,
|
||||
shareDraftPayload,
|
||||
shareDraftView,
|
||||
submitEntryTeamShares,
|
||||
type ShareDraftRow,
|
||||
} from './entryShares'
|
||||
import {
|
||||
entryBatchOutcome,
|
||||
entryBatchSizeError,
|
||||
@@ -92,6 +110,13 @@ interface EditableRow {
|
||||
athleteId: number | null
|
||||
teamName: string
|
||||
teamShort: string
|
||||
/**
|
||||
* 归属份额(读侧恒为至少一条):单单位就是主单位独占 100%,联队是多条。
|
||||
*
|
||||
* 表格的「单位」列与实时积分预览都读它——联队那条的分要拆给各成员单位,
|
||||
* 只看 teamId 会把整份分记在主单位头上。
|
||||
*/
|
||||
teamShares: TeamShareView[]
|
||||
athleteName: string
|
||||
memberNames: string[] | null
|
||||
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
|
||||
@@ -352,6 +377,8 @@ function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
||||
rank: row.rank,
|
||||
status: row.status,
|
||||
isTie: row.isTie,
|
||||
// 联队份额随行喂给预览算分:单单位时它就是主单位独占,与升级前同口径。
|
||||
shares: row.teamShares.map((share) => ({ teamId: share.teamId, weight: share.weight })),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -386,10 +413,13 @@ const publishedScore = computed(() => scoreWith(visibleRows.value.filter((row) =
|
||||
*/
|
||||
const publishedContribution = computed(() => {
|
||||
const map = new Map<number, number>()
|
||||
const rowTeamPoints = publishedScore.value?.rowTeamPoints
|
||||
const rowTeamShares = publishedScore.value?.rowTeamShares
|
||||
for (const row of visibleRows.value) {
|
||||
if (row.resultState !== 'published') continue
|
||||
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
|
||||
// 按份额逐份累加到单位:联队那条的分不是整份记在主单位头上的。
|
||||
for (const part of rowTeamShares?.get(row.entryId) ?? []) {
|
||||
map.set(part.teamId, (map.get(part.teamId) ?? 0) + part.points)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
@@ -752,14 +782,22 @@ function snapshotOf(entry: EntryWithResult): string {
|
||||
|
||||
function toRow(entry: EntryWithResult): EditableRow {
|
||||
const result = entry.result
|
||||
const teamName = entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位'
|
||||
const row: EditableRow = {
|
||||
entryId: entry.id,
|
||||
categoryId: entry.categoryId,
|
||||
bib: entry.bib,
|
||||
teamId: entry.teamId,
|
||||
athleteId: entry.athleteId,
|
||||
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
|
||||
teamName,
|
||||
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
|
||||
// 老服务端不下发 teamShares 时按主单位独占补一条(与升级前逐字段一致)。
|
||||
teamShares: normalizeTeamShares(entry.teamShares, {
|
||||
teamId: entry.teamId,
|
||||
weight: 1,
|
||||
teamCode: entry.teamCode ?? data.teamById(entry.teamId)?.code ?? '',
|
||||
teamName,
|
||||
}),
|
||||
athleteName: entry.athleteName ?? '',
|
||||
memberNames: entry.memberNames,
|
||||
heat: entry.heat,
|
||||
@@ -956,6 +994,148 @@ async function onSupplementSubmitted(eventId: number, categoryId: number | null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 成员单位与份额(联队) ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 份额编辑器(devdocs/19)。
|
||||
*
|
||||
* 打开时把这条报名**当前**的成员单位摆出来:单单位报名看到的就是孤零零一个主单位、
|
||||
* 权重留空,什么都不改直接关掉等于没动过——常规报名流程一点没被碰到。
|
||||
* 需要时在弹窗里加单位、填权重(留空 = 均分),保存后服务端当场重算本组并推榜。
|
||||
*
|
||||
* `original` 留着是为了判断「改了没有」:没改就禁用保存按钮,避免把一次没改动的整组替换
|
||||
* 发出去(服务端会照着做,只是白重算一遍本组)。
|
||||
*/
|
||||
interface ShareEditor {
|
||||
entryId: number
|
||||
/** 展示用的报名身份:运动员 / 队 + 号码布,让操作员确认改的是哪一条。 */
|
||||
label: string
|
||||
/** 这条报名的主单位(entries.team_id):不能从份额里去掉。 */
|
||||
primaryTeamId: number
|
||||
primaryName: string
|
||||
original: ShareDraftRow[]
|
||||
draft: ShareDraftRow[]
|
||||
saving: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const shareEditor = ref<ShareEditor | null>(null)
|
||||
const pendingShareTeamId = ref('')
|
||||
|
||||
const shareIssues = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null) return []
|
||||
return shareDraftIssues(editor.draft, editor.primaryTeamId, editor.primaryName)
|
||||
})
|
||||
const shareChanged = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? false : shareDraftDirty(editor.draft, editor.original)
|
||||
})
|
||||
const shareView = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? [] : shareDraftView(editor.draft)
|
||||
})
|
||||
/** 比例预览:`第一分公司 67% · 第二分公司 33%`,保存前就看得到分怎么拆。 */
|
||||
const shareRatioText = computed(() => shareView.value.map(shareText).join(' · '))
|
||||
/** 还能加进来的单位:不在成员里的都行(已停用的可以,假删除的不要)。 */
|
||||
const shareCandidates = computed(() => {
|
||||
const editor = shareEditor.value
|
||||
return editor === null ? [] : selectableShareTeams(data.teams, editor.draft)
|
||||
})
|
||||
const canSaveShares = computed(
|
||||
() =>
|
||||
canWrite.value &&
|
||||
shareEditor.value !== null &&
|
||||
!shareEditor.value.saving &&
|
||||
shareChanged.value &&
|
||||
shareIssues.value.length === 0,
|
||||
)
|
||||
|
||||
function openShareEditor(row: EditableRow): void {
|
||||
pendingShareTeamId.value = ''
|
||||
shareEditor.value = {
|
||||
entryId: row.entryId,
|
||||
label: `${athleteCellName(row)}${inlineBibLabel(row.bib) === '' ? '' : ` · 号码 ${inlineBibLabel(row.bib)}`}`,
|
||||
primaryTeamId: row.teamId,
|
||||
primaryName: row.teamName,
|
||||
original: shareDraftOf(row),
|
||||
draft: shareDraftOf(row),
|
||||
saving: false,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function closeShareEditor(): void {
|
||||
shareEditor.value = null
|
||||
pendingShareTeamId.value = ''
|
||||
}
|
||||
|
||||
function addShareMember(): void {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null) return
|
||||
const team = data.teams.find((item) => String(item.id) === pendingShareTeamId.value)
|
||||
if (team === undefined) return
|
||||
editor.draft = addShareDraftTeam(editor.draft, team)
|
||||
}
|
||||
|
||||
function dropShareMember(teamId: number): void {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null || teamId === editor.primaryTeamId) return
|
||||
editor.draft = removeShareDraftTeam(editor.draft, teamId)
|
||||
}
|
||||
|
||||
function editShareWeight(teamId: number, event: InputEvent): void {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null) return
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
editor.draft = setShareDraftWeight(editor.draft, teamId, input.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存份额:整组替换一条报名的成员单位。
|
||||
*
|
||||
* 就地更新这一行的份额并刷新积分榜,**不整表重拉**:重拉会把表格里没保存的成绩编辑一并覆盖掉,
|
||||
* 而份额改动只影响分记在谁头上,不影响任何一行的成绩与名次(那些由本地预览算)。
|
||||
* 服务端的 400(单位重复 / 权重为负 / 缺主单位…)原样展示在弹窗里,不吞。
|
||||
*/
|
||||
async function saveShareEditor(): Promise<void> {
|
||||
const editor = shareEditor.value
|
||||
if (editor === null || !canSaveShares.value) return
|
||||
if (data.isMock) {
|
||||
const notice = sampleDataBlockedNotice('保存成员单位')
|
||||
toast.error(notice.title, notice.detail)
|
||||
return
|
||||
}
|
||||
editor.saving = true
|
||||
editor.error = null
|
||||
const payload = shareDraftPayload(editor.draft)
|
||||
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,
|
||||
})
|
||||
}
|
||||
closeShareEditor()
|
||||
void data.refreshStandings()
|
||||
toast.success(
|
||||
'成员单位已保存',
|
||||
isJointTeam(row?.teamShares ?? []) ? '本组已重算,大屏榜单会跟着刷新' : '已改回单个单位',
|
||||
)
|
||||
} catch (caught) {
|
||||
editor.error = caught instanceof Error ? caught.message : '保存成员单位失败'
|
||||
} finally {
|
||||
editor.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
|
||||
|
||||
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
|
||||
@@ -2137,7 +2317,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<span :class="row.heat === null ? 'heat-none' : 'heat-tag'">{{ heatLabel(row.heat) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
<!--
|
||||
单位格兼作「成员单位与份额」的入口:点一下打开联队弹窗(默认就是当前这一个
|
||||
单位,什么都不改等于没动过)。普通报名的文案与改造前逐字一样,联队才多一行
|
||||
成员名 + 份额比例——两个单位各占多少分,在表格上就要看得见。
|
||||
-->
|
||||
<span
|
||||
v-if="!isJointTeam(row.teamShares)"
|
||||
class="team-cell is-editable"
|
||||
:title="`${row.teamName}|点击设置成员单位与份额(两个单位合并参赛时用)`"
|
||||
@click="openShareEditor(row)"
|
||||
>{{ row.teamShort || row.teamName }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="team-cell is-editable is-joint"
|
||||
:title="`${jointTeamLabel(row.teamShares)}|点击修改成员单位与份额`"
|
||||
@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>
|
||||
</td>
|
||||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||||
@@ -2454,6 +2655,114 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
成员单位与份额(联队,devdocs/19):外壳照页面里既有的弹窗写法自己套一层。
|
||||
默认摆出来的就是这条报名当前的份额——单单位报名打开后只有一行主单位、权重留空,
|
||||
不点「添加」「保存」的话一个请求都不会发出去,常规报名流程不受影响。
|
||||
-->
|
||||
<div v-if="shareEditor !== null" class="mx-modal-overlay open" @click.self="closeShareEditor">
|
||||
<div class="mx-modal" role="dialog" aria-modal="true">
|
||||
<div class="mx-modal-head">
|
||||
<span class="mx-modal-title">成员单位与份额</span>
|
||||
<button class="mx-modal-close" type="button" @click="closeShareEditor">
|
||||
<MxIcon name="close" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-modal-body">
|
||||
<p class="share-hint">
|
||||
一条报名可以挂<strong>多个单位</strong>(两个单位因人数不足合并参赛时用):这条成绩的积分按份额
|
||||
拆给各成员单位,奖牌<strong>各记一枚</strong>;分数平均拆、接受小数(团体第 1 名 15 分 → 两个单位各 7.5 分)。
|
||||
份额是<strong>相对权重</strong>:都留空就是均分(各 50%),填 2 与 1 就是 67% 与 33%。
|
||||
</p>
|
||||
<p class="bd-note">
|
||||
正在修改:{{ shareEditor.label }};主单位「{{ shareEditor.primaryName }}」不能去掉。
|
||||
</p>
|
||||
<!-- 访客 / 断网只读:改不了就说明白原因,别让人对着灰按钮猜。 -->
|
||||
<div v-if="!canWrite" class="share-readonly">
|
||||
<MxIcon name="lock" :size="14" />
|
||||
<span>{{ auth.readOnly ? '当前为访客只读视图,成员单位只能查看。' : '服务端未连接,暂时不能修改成员单位。' }}</span>
|
||||
</div>
|
||||
<ul class="share-list">
|
||||
<li v-for="member in shareEditor.draft" :key="member.teamId" class="share-item">
|
||||
<span class="share-code mono">{{ member.teamCode || '—' }}</span>
|
||||
<span class="share-name">{{ member.teamName }}</span>
|
||||
<span v-if="member.teamId === shareEditor.primaryTeamId" class="mx-badge mx-badge-neutral">主单位</span>
|
||||
<input
|
||||
class="mx-cell-input share-weight mono"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
placeholder="留空=1"
|
||||
:value="member.weightText"
|
||||
:disabled="!canWrite || shareEditor.saving"
|
||||
title="份额是相对权重:留空或填 0 都按 1 算(均分)"
|
||||
@input="editShareWeight(member.teamId, $event)"
|
||||
/>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="member.teamId === shareEditor.primaryTeamId || shareEditor.saving"
|
||||
:title="
|
||||
member.teamId === shareEditor.primaryTeamId
|
||||
? '主单位不能去掉:报名挂在它名下,换主单位要重新建报名'
|
||||
: '把这个单位从这个联队里去掉'
|
||||
"
|
||||
@click="dropShareMember(member.teamId)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="share-add">
|
||||
<select
|
||||
v-model="pendingShareTeamId"
|
||||
class="mx-cell-input bd-select"
|
||||
:disabled="!canWrite || shareEditor.saving || shareCandidates.length === 0"
|
||||
>
|
||||
<option value="">添加成员单位…</option>
|
||||
<option v-for="team in shareCandidates" :key="team.id" :value="String(team.id)">
|
||||
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="pendingShareTeamId === '' || shareEditor.saving"
|
||||
@click="addShareMember"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<p class="share-ratio">
|
||||
当前比例:<strong>{{ shareRatioText }}</strong>
|
||||
<span v-if="shareView.length > 1" class="mx-badge mx-badge-info">{{ jointTeamLabel(shareView) }}</span>
|
||||
</p>
|
||||
<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" />
|
||||
<span>{{ message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="shareEditor.error !== null" class="mx-badge mx-badge-error share-error">
|
||||
{{ 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 ? '放弃修改' : '关闭' }}
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!canSaveShares" @click="saveShareEditor">
|
||||
<MxIcon name="check" :size="14" />
|
||||
{{ shareEditor.saving ? '保存中…' : '保存成员单位' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="unpublishDialog"
|
||||
tone="danger"
|
||||
@@ -2822,6 +3131,95 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 单位格兼作「成员单位与份额」的入口:悬停给出手型与底色,点开的是同一个弹窗。
|
||||
文案本身不动——单单位报名的这一格与改造前逐字一样。 */
|
||||
.team-cell.is-editable {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: var(--mx-r-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.team-cell.is-editable:hover {
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
/* 联队:单位名 + 各自份额逐条列出(名字写全,比例看得见),不再压成一行。 */
|
||||
.team-cell.is-joint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.joint-member {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
/* ── 成员单位与份额弹窗 ── */
|
||||
.share-hint {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.share-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.share-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.share-code {
|
||||
min-width: 44px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.share-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-weight {
|
||||
width: 96px;
|
||||
text-align: center;
|
||||
}
|
||||
.share-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.share-ratio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.share-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.share-error {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/*
|
||||
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
|
||||
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
|
||||
|
||||
@@ -3,10 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ApiError,
|
||||
formatPoints,
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeTeamShares,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
type EventCategory,
|
||||
type ResultStatus,
|
||||
type TeamShareView,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -25,6 +29,8 @@ interface DraftRow {
|
||||
teamId: number
|
||||
teamName: string
|
||||
teamShort: string
|
||||
/** 归属份额(联队):复核时也要看得出这一条其实是几个单位合并报的(devdocs/19)。 */
|
||||
teamShares: TeamShareView[]
|
||||
athleteName: string
|
||||
memberNames: string[] | null
|
||||
resultText: string
|
||||
@@ -211,6 +217,13 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
rank: entry.result?.rank ?? null,
|
||||
status: entry.result?.status ?? 'ok',
|
||||
isTie: entry.result?.isTie ?? false,
|
||||
// 联队份额:预览的分要按份额拆到各成员单位(单单位时就是主单位独占)。
|
||||
shares: normalizeTeamShares(entry.teamShares, {
|
||||
teamId: entry.teamId,
|
||||
weight: 1,
|
||||
teamCode: entry.teamCode,
|
||||
teamName: entry.teamName,
|
||||
}).map((share) => ({ teamId: share.teamId, weight: share.weight })),
|
||||
}))
|
||||
const context: ScoreContext = {
|
||||
event,
|
||||
@@ -230,14 +243,14 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
)
|
||||
|
||||
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
|
||||
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
|
||||
// rowTeamShares 已按通道 scope 折算好个人→单位系数、并按份额拆到各成员单位,
|
||||
// 与服务端流水口径一致;按 teamId 累加 rowTeamPoints 会把联队的整份分记在主单位头上。
|
||||
const published = new Map<number, number>()
|
||||
for (const entry of scoped) {
|
||||
if (entry.result?.state !== 'published') continue
|
||||
published.set(
|
||||
entry.teamId,
|
||||
(published.get(entry.teamId) ?? 0) + round(publishedScore.rowTeamPoints.get(entry.id) ?? 0),
|
||||
)
|
||||
for (const part of publishedScore.rowTeamShares.get(entry.id) ?? []) {
|
||||
published.set(part.teamId, (published.get(part.teamId) ?? 0) + round(part.points))
|
||||
}
|
||||
}
|
||||
|
||||
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
|
||||
@@ -254,6 +267,12 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
|
||||
teamId: entry.teamId,
|
||||
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
|
||||
teamShort: entry.shortName ?? '',
|
||||
teamShares: normalizeTeamShares(entry.teamShares, {
|
||||
teamId: entry.teamId,
|
||||
weight: 1,
|
||||
teamCode: entry.teamCode,
|
||||
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
|
||||
}),
|
||||
athleteName: entry.athleteName ?? '',
|
||||
memberNames: entry.memberNames,
|
||||
resultText: entry.result?.resultText ?? '',
|
||||
@@ -609,7 +628,18 @@ watch(
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
<!-- 联队:成员单位名都写全(复核时要知道这一条其实是几个单位合并报的)。 -->
|
||||
<span
|
||||
v-if="isJointTeam(row.teamShares)"
|
||||
class="team-cell is-joint"
|
||||
:title="jointTeamLabel(row.teamShares)"
|
||||
>
|
||||
<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>
|
||||
<span v-else class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
</td>
|
||||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||||
<td class="mono">{{ row.resultText || '—' }}</td>
|
||||
@@ -788,6 +818,17 @@ watch(
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 联队:成员单位名 + 各自份额逐条列出,不压成一行。 */
|
||||
.team-cell.is-joint {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.joint-member {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { ApiError } from '@/shared'
|
||||
import type { ApiErrorCode, Entry, EntryTeamShare, Team } from '@/shared'
|
||||
import { MAX_JOINT_TEAM_MEMBERS, normalizeTeamShares, shareWeightOf } from '@/shared'
|
||||
import type { TeamShareView } from '@/shared'
|
||||
|
||||
export { MAX_JOINT_TEAM_MEMBERS }
|
||||
|
||||
/**
|
||||
* 「成员单位与份额」(联队)的编辑模型与写请求,见 devdocs/19。
|
||||
*
|
||||
* 网络层直接 fetch 而不是走 adminApi:与 entryBatch.ts 同一套理由(请求构造与校验收在一个模块里,
|
||||
* 能直接单测),CSRF 与 X-Request-Id 的补法也必须与 shared/api.ts 的写请求保持一致,
|
||||
* 否则写操作会被中间件 403 挡回——现场只会看到「保存不了」。
|
||||
*
|
||||
* 编辑模型本身只有一件事:**默认一个单位**。打开编辑器时把这条报名当前的份额原样摆出来,
|
||||
* 单单位报名看到的就是它现在的样子(一个单位、权重留空),不加人不改权重就与现在完全一样。
|
||||
*/
|
||||
|
||||
export function entryTeamSharesPath(entryId: number): string {
|
||||
return `/api/entries/${entryId}/team-shares`
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器里的一行:一个成员单位 + 权重输入框的原文。
|
||||
*
|
||||
* 权重存**文本**而不是数字:输入框里「空」是有意义的(不填 = 按 1 算 = 均分),
|
||||
* 存成数字会被 Number('') = 0 抹掉这个区别,用户就看不到自己到底填没填。
|
||||
*/
|
||||
export interface ShareDraftRow {
|
||||
teamId: number
|
||||
teamCode: string
|
||||
teamName: string
|
||||
weightText: string
|
||||
}
|
||||
|
||||
/** 报名行里够拼出编辑器初值的那几列。 */
|
||||
export interface ShareSourceLike {
|
||||
teamId: number
|
||||
teamCode?: string
|
||||
teamName?: string
|
||||
teamShares?: readonly EntryTeamShare[]
|
||||
}
|
||||
|
||||
function codeOf(team: Pick<Team, 'code'>): string {
|
||||
return team.code.trim()
|
||||
}
|
||||
|
||||
/** 权重的文本形式:1(缺省 / 均分)留空,其余按原值显示,省得均分时每行都写一个 1。 */
|
||||
function weightTextOf(weight: number): string {
|
||||
return weight === 1 ? '' : String(weight)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开编辑器时的初值:这条报名当前的份额。
|
||||
*
|
||||
* 读侧保证「至少一条」,老服务端没有 `teamShares` 时按主单位独占补一条——
|
||||
* 于是单单位报名打开后就是一行「主单位 · 权重留空」,什么都不改直接关掉等于没动过。
|
||||
*/
|
||||
export function shareDraftOf(source: ShareSourceLike): ShareDraftRow[] {
|
||||
return normalizeTeamShares(source.teamShares, {
|
||||
teamId: source.teamId,
|
||||
weight: 1,
|
||||
teamCode: source.teamCode,
|
||||
teamName: source.teamName,
|
||||
}).map((share) => ({
|
||||
teamId: share.teamId,
|
||||
teamCode: share.teamCode,
|
||||
teamName: share.teamName,
|
||||
weightText: weightTextOf(share.weight),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 权重输入框原文的解析结果:`value` 已按「空 / 0 / 非数 = 1」归一。 */
|
||||
export interface ShareWeightParse {
|
||||
value: number
|
||||
/** 填了负数(服务端也会 400)。 */
|
||||
negative: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析权重输入框:留空、0、非数字一律按 1(均分);负数单独标出来给界面报错。
|
||||
*
|
||||
* 与服务端 entryShareWeight / shareWeight 同口径:份额是相对权重,「不填」就是 1。
|
||||
*/
|
||||
export function parseShareWeightText(text: string): ShareWeightParse {
|
||||
const raw = text.trim()
|
||||
if (raw === '') return { value: 1, negative: false }
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) return { value: 1, negative: false }
|
||||
if (value < 0) return { value: 1, negative: true }
|
||||
return { value: shareWeightOf(value), negative: false }
|
||||
}
|
||||
|
||||
/** 编辑器 → 写侧载荷(teamId + weight,+ 显式关闭的 medal)。 */
|
||||
export function shareDraftPayload(draft: readonly ShareDraftRow[]): EntryTeamShare[] {
|
||||
return draft.map((row) => ({ teamId: row.teamId, weight: parseShareWeightText(row.weightText).value }))
|
||||
}
|
||||
|
||||
/** 编辑器 → 展示视图(份额百分比、联队名),与只读渲染共用同一份口径。 */
|
||||
export function shareDraftView(draft: readonly ShareDraftRow[]): TeamShareView[] {
|
||||
return normalizeTeamShares(
|
||||
draft.map((row) => ({
|
||||
teamId: row.teamId,
|
||||
weight: parseShareWeightText(row.weightText).value,
|
||||
teamCode: row.teamCode,
|
||||
teamName: row.teamName,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器当前的校验问题(能直接上屏的中文)。
|
||||
*
|
||||
* 这些规则服务端都会再判一次、并用它自己的话回 400;前端先判一遍只是为了让操作员
|
||||
* **在点上保存之前**就看到哪里不对,而不是发一个注定失败的请求。
|
||||
*/
|
||||
export function shareDraftIssues(
|
||||
draft: readonly ShareDraftRow[],
|
||||
primaryTeamId: number,
|
||||
primaryName: string,
|
||||
): string[] {
|
||||
const issues: string[] = []
|
||||
if (draft.length === 0) {
|
||||
issues.push('至少要有一个成员单位:联队也是从一条报名开始的')
|
||||
return issues
|
||||
}
|
||||
if (draft.length > MAX_JOINT_TEAM_MEMBERS) {
|
||||
issues.push(`一个联队最多 ${MAX_JOINT_TEAM_MEMBERS} 个单位,当前有 ${draft.length} 个`)
|
||||
}
|
||||
if (!draft.some((row) => row.teamId === primaryTeamId)) {
|
||||
issues.push(`主单位「${primaryName}」不能去掉:报名挂在它名下,换主单位要重新建报名`)
|
||||
}
|
||||
const seen = new Set<number>()
|
||||
for (const row of draft) {
|
||||
if (seen.has(row.teamId)) issues.push(`单位「${row.teamName}」出现了两次:同一个单位只能占一份`)
|
||||
seen.add(row.teamId)
|
||||
if (parseShareWeightText(row.weightText).negative) {
|
||||
issues.push(`单位「${row.teamName}」的权重是负数:留空或填 0 都按 1 算(均分)`)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
/** 编辑器里能加进联队的单位:不在成员里的都可以(含已停用单位,服务端只拦假删除的)。 */
|
||||
export function selectableShareTeams(
|
||||
teams: readonly Team[],
|
||||
draft: readonly ShareDraftRow[],
|
||||
): Team[] {
|
||||
const picked = new Set(draft.map((row) => row.teamId))
|
||||
return teams.filter((team) => team.deletedAt == null && !picked.has(team.id))
|
||||
}
|
||||
|
||||
export function addShareDraftTeam(draft: readonly ShareDraftRow[], team: Team): ShareDraftRow[] {
|
||||
if (draft.some((row) => row.teamId === team.id)) return [...draft]
|
||||
return [
|
||||
...draft,
|
||||
{ teamId: team.id, teamCode: codeOf(team), teamName: team.name, weightText: '' },
|
||||
]
|
||||
}
|
||||
|
||||
/** 移除一个成员单位;主单位由调用方拦下(界面上它的按钮是禁用的)。 */
|
||||
export function removeShareDraftTeam(draft: readonly ShareDraftRow[], teamId: number): ShareDraftRow[] {
|
||||
return draft.filter((row) => row.teamId !== teamId)
|
||||
}
|
||||
|
||||
export function setShareDraftWeight(
|
||||
draft: readonly ShareDraftRow[],
|
||||
teamId: number,
|
||||
weightText: string,
|
||||
): ShareDraftRow[] {
|
||||
return draft.map((row) => (row.teamId === teamId ? { ...row, weightText } : row))
|
||||
}
|
||||
|
||||
/** 编辑器有没有改动:单位集合与各自权重一致就算没动,直接关掉等于没发生过。 */
|
||||
export function shareDraftDirty(
|
||||
draft: readonly ShareDraftRow[],
|
||||
original: readonly ShareDraftRow[],
|
||||
): boolean {
|
||||
if (draft.length !== original.length) return true
|
||||
for (let i = 0; i < draft.length; i++) {
|
||||
const a = draft[i]
|
||||
const b = original[i]
|
||||
if (a === undefined || b === undefined) return true
|
||||
if (a.teamId !== b.teamId) return true
|
||||
if (parseShareWeightText(a.weightText).value !== parseShareWeightText(b.weightText).value) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 EntryTeamSharesResult {
|
||||
ok: boolean
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface EntryTeamSharesOutcome {
|
||||
entry: Entry | null
|
||||
/** 服务端回显的份额;老服务端 / 响应里没有时为空,调用方退回本地草稿。 */
|
||||
teamShares: EntryTeamShare[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交份额(整组替换)。写操作不降级到示例数据:示例数据模式下的「保存成功」
|
||||
* 会让操作员以为分已经改好了,页面必须先看 data.isMock 再决定是否调用这里。
|
||||
*
|
||||
* 校验失败(400)时服务端回的是**给人看的中文**(单位重复 / 权重为负 / 缺主单位…),
|
||||
* 这里原样抛出去,界面直接展示那句话——前端不自己改写服务端的措辞。
|
||||
*/
|
||||
export async function submitEntryTeamShares(
|
||||
entryId: number,
|
||||
teamShares: EntryTeamShare[],
|
||||
requestId?: string,
|
||||
): Promise<EntryTeamSharesOutcome> {
|
||||
// 一次调用只生成一个幂等键: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(entryTeamSharesPath(entryId), {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ teamShares, 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 EntryTeamSharesResult
|
||||
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
|
||||
const shares = data !== null && Array.isArray(data.teamShares) ? data.teamShares : null
|
||||
return { entry: data, teamShares: shares }
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ApiError, MAX_JOINT_TEAM_MEMBERS } from '@/shared'
|
||||
import type { EntryTeamShare, Team } from '@/shared'
|
||||
import {
|
||||
addShareDraftTeam,
|
||||
entryTeamSharesPath,
|
||||
parseShareWeightText,
|
||||
removeShareDraftTeam,
|
||||
selectableShareTeams,
|
||||
setShareDraftWeight,
|
||||
shareDraftDirty,
|
||||
shareDraftIssues,
|
||||
shareDraftOf,
|
||||
shareDraftPayload,
|
||||
shareDraftView,
|
||||
submitEntryTeamShares,
|
||||
} from '../pages/entryShares'
|
||||
import type { ShareDraftRow } from '../pages/entryShares'
|
||||
|
||||
/**
|
||||
* 「成员单位与份额」(联队)的编辑模型与写请求契约(devdocs/19)。要钉住的是:
|
||||
* 1. **默认一个单位**:单单位报名打开编辑器就是孤零零一行主单位、权重留空,什么都不改
|
||||
* 等于没动过(保存按钮不会亮,请求也不会发)——常规报名流程一点没被碰到;
|
||||
* 2. 留空 / 0 的权重按 1 算(均分),2:1 显示成 67% 与 33%;
|
||||
* 3. 请求走 PUT /api/entries/{id}/team-shares,body 只带 teamId / weight,
|
||||
* 写请求必须有 CSRF 与同一个幂等键(header 与 body 一致);
|
||||
* 4. 服务端的中文 400 原样抛出,界面直接展示那句话。
|
||||
*/
|
||||
|
||||
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 team(id: number, over: Partial<Team> = {}): Team {
|
||||
return {
|
||||
id,
|
||||
code: `T${id}`,
|
||||
name: `第${id}分公司`,
|
||||
shortName: null,
|
||||
logoPath: null,
|
||||
color: null,
|
||||
sortOrder: id,
|
||||
active: true,
|
||||
deletedAt: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function entryOf(shares: EntryTeamShare[] | undefined) {
|
||||
return { teamId: 1, teamCode: 'A', teamName: '第一分公司', teamShares: shares }
|
||||
}
|
||||
|
||||
test('普通报名(单单位)打开编辑器:只有主单位一行、权重留空,且判定为「没改动」', () => {
|
||||
const draft = shareDraftOf(entryOf(undefined))
|
||||
assert.equal(draft.length, 1)
|
||||
assert.deepEqual(draft[0], { teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' })
|
||||
assert.equal(shareDraftDirty(draft, shareDraftOf(entryOf(undefined))), false)
|
||||
assert.deepEqual(shareDraftIssues(draft, 1, '第一分公司'), [])
|
||||
})
|
||||
|
||||
test('份额缺省权重 1,编辑器里留空(不写一个没有意义的 1)', () => {
|
||||
const draft = shareDraftOf(
|
||||
entryOf([
|
||||
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 2, teamCode: 'B', teamName: '第二分公司' },
|
||||
]),
|
||||
)
|
||||
assert.deepEqual(
|
||||
draft.map((row) => row.weightText),
|
||||
['', '2'],
|
||||
)
|
||||
})
|
||||
|
||||
test('留空 / 0 的权重按 1 算(均分);负数单独报错', () => {
|
||||
assert.deepEqual(parseShareWeightText(''), { value: 1, negative: false })
|
||||
assert.deepEqual(parseShareWeightText(' '), { value: 1, negative: false })
|
||||
assert.deepEqual(parseShareWeightText('0'), { value: 1, negative: false })
|
||||
assert.deepEqual(parseShareWeightText('abc'), { value: 1, negative: false })
|
||||
assert.deepEqual(parseShareWeightText('1.5'), { value: 1.5, negative: false })
|
||||
assert.deepEqual(parseShareWeightText('-2'), { value: 1, negative: true })
|
||||
})
|
||||
|
||||
test('2:1 显示成 67% 与 33%,名字写全', () => {
|
||||
const draft = shareDraftOf(
|
||||
entryOf([
|
||||
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
|
||||
]),
|
||||
)
|
||||
const view = shareDraftView(draft)
|
||||
assert.deepEqual(
|
||||
view.map((share) => `${share.teamName} ${share.percent}%`),
|
||||
['第一分公司 67%', '第二分公司 33%'],
|
||||
)
|
||||
})
|
||||
|
||||
test('加单位 / 改权重 / 去单位:改过就是脏,改回去又不脏', () => {
|
||||
const original = shareDraftOf(entryOf(undefined))
|
||||
let draft = addShareDraftTeam(original, team(2, { code: 'B', name: '第二分公司' }))
|
||||
assert.equal(draft.length, 2)
|
||||
assert.equal(shareDraftDirty(draft, original), true)
|
||||
draft = setShareDraftWeight(draft, 1, '2')
|
||||
assert.equal(shareDraftDirty(draft, original), true)
|
||||
// 权重改回缺省、单位去掉 → 与初值完全一致,不该再算改动。
|
||||
draft = setShareDraftWeight(draft, 1, '')
|
||||
draft = removeShareDraftTeam(draft, 2)
|
||||
assert.equal(shareDraftDirty(draft, original), false)
|
||||
})
|
||||
|
||||
test('同一个单位加不进来两次;已删(假删除)的单位不进候选', () => {
|
||||
const draft = shareDraftOf(entryOf(undefined))
|
||||
const again = addShareDraftTeam(draft, team(1))
|
||||
assert.equal(again.length, 1)
|
||||
const candidates = selectableShareTeams(
|
||||
[team(1), team(2), team(3, { deletedAt: '2026-09-01T00:00:00Z' })],
|
||||
draft,
|
||||
)
|
||||
assert.deepEqual(
|
||||
candidates.map((item) => item.id),
|
||||
[2],
|
||||
)
|
||||
})
|
||||
|
||||
test('校验:缺主单位 / 单位重复 / 权重为负 / 超过成员上限都要说出来', () => {
|
||||
const missingPrimary: ShareDraftRow[] = []
|
||||
const issues = shareDraftIssues(missingPrimary, 1, '第一分公司')
|
||||
assert.equal(issues.length, 1)
|
||||
assert.match(issues[0] ?? '', /至少要有一个成员单位/)
|
||||
|
||||
const withoutPrimary = shareDraftOf(
|
||||
entryOf([
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
{ teamId: 3, weight: 1, teamName: '第三分公司' },
|
||||
]),
|
||||
)
|
||||
assert.match(shareDraftIssues(withoutPrimary, 1, '第一分公司').join(';'), /主单位「第一分公司」不能去掉/)
|
||||
|
||||
const duplicated = [
|
||||
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
|
||||
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
|
||||
]
|
||||
assert.match(shareDraftIssues(duplicated, 1, '第一分公司').join(';'), /出现了两次/)
|
||||
|
||||
const negative = [
|
||||
{ teamId: 1, teamCode: 'A', teamName: '第一分公司', weightText: '' },
|
||||
{ teamId: 2, teamCode: 'B', teamName: '第二分公司', weightText: '-1' },
|
||||
]
|
||||
assert.match(shareDraftIssues(negative, 1, '第一分公司').join(';'), /权重是负数/)
|
||||
|
||||
const tooMany = Array.from({ length: MAX_JOINT_TEAM_MEMBERS + 1 }, (_, index) => ({
|
||||
teamId: index + 1,
|
||||
teamCode: `T${index + 1}`,
|
||||
teamName: `第${index + 1}分公司`,
|
||||
weightText: '',
|
||||
}))
|
||||
assert.match(shareDraftIssues(tooMany, 1, '第一分公司').join(';'), /最多 5 个单位/)
|
||||
})
|
||||
|
||||
test('写侧载荷只带 teamId / weight(读侧的 teamCode / teamName 不上行)', () => {
|
||||
const draft = shareDraftOf(
|
||||
entryOf([
|
||||
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
|
||||
]),
|
||||
)
|
||||
assert.deepEqual(shareDraftPayload(draft), [
|
||||
{ teamId: 1, weight: 1 },
|
||||
{ teamId: 2, weight: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
test('保存走 PUT /api/entries/{id}/team-shares,带 CSRF 与同一个幂等键', async () => {
|
||||
const original = (globalThis as { document?: unknown }).document
|
||||
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
|
||||
try {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
id: 7,
|
||||
teamId: 1,
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const outcome = await submitEntryTeamShares(7, [
|
||||
{ teamId: 1, weight: 2 },
|
||||
{ teamId: 2, weight: 1 },
|
||||
])
|
||||
|
||||
assert.equal(lastRequest.url, entryTeamSharesPath(7))
|
||||
assert.equal(lastRequest.url, '/api/entries/7/team-shares')
|
||||
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')
|
||||
const headerId = lastRequest.headers?.['X-Request-Id']
|
||||
assert.equal(typeof headerId, 'string')
|
||||
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, headerId)
|
||||
assert.deepEqual((lastRequest.body as { teamShares: unknown }).teamShares, [
|
||||
{ teamId: 1, weight: 2 },
|
||||
{ teamId: 2, weight: 1 },
|
||||
])
|
||||
// 服务端回显的份额交给页面直接回填。
|
||||
assert.equal(outcome.teamShares?.length, 2)
|
||||
assert.equal(outcome.entry?.id, 7)
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||||
else (globalThis as { document?: unknown }).document = original
|
||||
}
|
||||
})
|
||||
|
||||
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse(
|
||||
{ ok: false, error: { code: 'VALIDATION_FAILED', message: 'teamShares 必须包含这条报名的主单位 1:主单位不能从份额里去掉(换主单位要重新建报名)' } },
|
||||
400,
|
||||
),
|
||||
)
|
||||
await assert.rejects(
|
||||
() => submitEntryTeamShares(7, [{ teamId: 2, weight: 1 }]),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||
assert.match(error.message, /必须包含这条报名的主单位/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('网络不可达时说清「没有改成功」,不能让操作员以为已经落库', async () => {
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
await assert.rejects(
|
||||
() => submitEntryTeamShares(7, [{ teamId: 1, weight: 1 }]),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'NETWORK')
|
||||
assert.match(error.message, /没有改成功/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
|
||||
/**
|
||||
* 联队份额在录入页实时预览里的口径(devdocs/19,逐条对齐服务端 domain/scoring.go)。
|
||||
*
|
||||
* 录入页的「实时积分预览」是照着引擎写的第二份实现,它必须与服务端同口径:
|
||||
* 联队那条报名拿到的 15 分要拆成 A、B 各 7.5,而不是整份记在主单位头上——
|
||||
* 否则操作员看到的是「预览说 A +15」,发布后却是「A、B 各 +7.5」。
|
||||
* 同时钉住:**没有份额(普通报名)时逐位不变**。
|
||||
*/
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 1,
|
||||
sportId: 1,
|
||||
code: 'BBALL',
|
||||
name: '篮球',
|
||||
eventType: 'team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'score',
|
||||
compare: 'desc',
|
||||
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: 'result',
|
||||
profileId: 1,
|
||||
sortOrder: 1,
|
||||
countToTeam: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
|
||||
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
|
||||
}
|
||||
|
||||
/** 团体第 1 名 15 分、第 2 名 9 分。 */
|
||||
const PROFILE = makeProfile({
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 9 },
|
||||
],
|
||||
})
|
||||
|
||||
function close(actual: number, expected: number): void {
|
||||
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
|
||||
}
|
||||
|
||||
test('普通报名(没有 shares)时逐位不变:分整份记在主单位头上', () => {
|
||||
const result = computeEventScore(
|
||||
[{ entryId: 1, teamId: 100, athleteId: null, resultValue: 90, rank: null, status: 'ok', isTie: false }],
|
||||
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 15)
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
assert.deepEqual(result.rowTeamShares.get(1), [{ teamId: 100, points: 15 }])
|
||||
})
|
||||
|
||||
test('联队 1:1:15 分拆成两个单位各 7.5,总分不变', () => {
|
||||
const result = computeEventScore(
|
||||
[
|
||||
{
|
||||
entryId: 1,
|
||||
teamId: 100,
|
||||
athleteId: null,
|
||||
resultValue: 90,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
shares: [
|
||||
{ teamId: 100, weight: 1 },
|
||||
{ teamId: 200, weight: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
|
||||
)
|
||||
// 表格里的「积分」列是这条报名拿到的分(还没拆),仍是 15。
|
||||
close(result.rowPoints.get(1) ?? 0, 15)
|
||||
close(result.teamPoints.get(100) ?? 0, 7.5)
|
||||
close(result.teamPoints.get(200) ?? 0, 7.5)
|
||||
assert.deepEqual(result.rowTeamShares.get(1), [
|
||||
{ teamId: 100, points: 7.5 },
|
||||
{ teamId: 200, points: 7.5 },
|
||||
])
|
||||
})
|
||||
|
||||
test('联队 2:1:拆成 10 与 5,两份之和正好等于原来的那一份分', () => {
|
||||
const result = computeEventScore(
|
||||
[
|
||||
{
|
||||
entryId: 1,
|
||||
teamId: 100,
|
||||
athleteId: null,
|
||||
resultValue: 90,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
shares: [
|
||||
{ teamId: 100, weight: 2 },
|
||||
{ teamId: 200, weight: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
makeContext({ channels: [makeChannel()], profiles: [PROFILE] }),
|
||||
)
|
||||
close(result.teamPoints.get(100) ?? 0, 10)
|
||||
close(result.teamPoints.get(200) ?? 0, 5)
|
||||
close((result.teamPoints.get(100) ?? 0) + (result.teamPoints.get(200) ?? 0), 15)
|
||||
})
|
||||
|
||||
test('个人通道的联队:分先按个人→单位系数折算,再按份额拆', () => {
|
||||
const result = computeEventScore(
|
||||
[
|
||||
{
|
||||
entryId: 1,
|
||||
teamId: 100,
|
||||
athleteId: 5,
|
||||
resultValue: 90,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
shares: [
|
||||
{ teamId: 100, weight: 1 },
|
||||
{ teamId: 200, weight: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
makeContext({ channels: [makeChannel({ scope: 'athlete' })], profiles: [PROFILE], weight: 0.5 }),
|
||||
)
|
||||
// 15 分 × 0.5(个人→单位系数)= 7.5,再两家对半:各 3.75。
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 7.5)
|
||||
close(result.teamPoints.get(100) ?? 0, 3.75)
|
||||
close(result.teamPoints.get(200) ?? 0, 3.75)
|
||||
})
|
||||
|
||||
test('聚合通道不拆份额:名次主体是单位,与服务端口径一致', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{
|
||||
entryId: 1,
|
||||
teamId: 100,
|
||||
athleteId: 5,
|
||||
resultValue: 90,
|
||||
rank: null,
|
||||
status: 'ok',
|
||||
isTie: false,
|
||||
shares: [
|
||||
{ teamId: 100, weight: 1 },
|
||||
{ teamId: 200, weight: 1 },
|
||||
],
|
||||
},
|
||||
{ entryId: 2, teamId: 200, athleteId: 6, resultValue: 80, rank: null, status: 'ok', isTie: false },
|
||||
]
|
||||
const result = computeEventScore(rows, makeContext({ channels: [makeChannel({ basis: 'aggregate' })], profiles: [PROFILE] }))
|
||||
// 名次按单位平均分排:100 → 90,200 → 80,所以 100 拿 15、200 拿 9,都整份记给主体单位。
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
close(result.teamPoints.get(200) ?? 0, 9)
|
||||
})
|
||||
Reference in new issue
Block a user