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)
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { medalBoardRows } from '@/shared'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { tickerRowText } from '../jointTeams'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import { DEFAULT_ROTATE_SECONDS } from '../guards'
|
||||
|
||||
@@ -11,7 +13,17 @@ interface TickerItem {
|
||||
label: string
|
||||
}
|
||||
|
||||
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
|
||||
const { standings, latestResults, announceText, detail, settings, jointTeamOf } = useScoreboard()
|
||||
|
||||
/**
|
||||
* 底栏「最新完成项目」一行里的名字:团体行是联队时写「A+B联队」。
|
||||
*
|
||||
* 底栏放不下两个 logo,但两个单位的名字必须都在——只写主单位会让现场以为
|
||||
* 这条成绩是它一家拿的(成员单位来自报名表,见 display/jointTeams.ts)。
|
||||
*/
|
||||
function chipName(row: EventResultRow): string {
|
||||
return tickerRowText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
|
||||
const topMedals = computed(() => medalBoardRows(standings.value).slice(0, 3))
|
||||
@@ -77,7 +89,7 @@ onUnmounted(() => window.clearInterval(timer))
|
||||
</div>
|
||||
<div v-for="row in latestGroup.rows.slice(0, 3)" :key="row.entryId" class="chip">
|
||||
<span class="chip-rank">{{ row.rank ?? '—' }}</span>
|
||||
<span class="chip-name">{{ row.athleteName ?? row.teamName }}</span>
|
||||
<span class="chip-name">{{ chipName(row) }}</span>
|
||||
<span class="chip-stat">{{ row.resultText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { rowTeamLogos, rowTeamText } from '../../jointTeams'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, options } = useScoreboard()
|
||||
const { latestResults, options, jointTeamOf } = useScoreboard()
|
||||
|
||||
// 数据口径:服务端只过滤 state='published'(不看项目 status),按发布时间倒序取前 N 个(06 第 5 节)。
|
||||
const groups = computed(() => latestResults.value.slice(0, options.value.resultsCount))
|
||||
|
||||
/**
|
||||
* 单位列的文案与 logo(devdocs/19):联队写「A+B联队」并画两个单位的 logo,
|
||||
* 普通报名与改造前逐字/逐图一致(`shortName ?? teamName` 与单个 TeamLogo)。
|
||||
*/
|
||||
function teamText(row: EventResultRow): string {
|
||||
return rowTeamText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
function teamLogos(row: EventResultRow): (string | null)[] {
|
||||
return rowTeamLogos(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,9 +42,10 @@ const groups = computed(() => latestResults.value.slice(0, options.value.results
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<!-- 联队两个单位的 logo 都要出现:teamLogos 普通报名时只有一个元素。 -->
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
|
||||
<span class="team-name">{{ teamText(row) }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { EventResultRow } from '@/shared'
|
||||
import { fitLiveRowFontPx, liveResultColumnPx, liveTeamColumnPx } from '../../fitFont'
|
||||
import {
|
||||
LIVE_REFRESH_INTERVAL_MS,
|
||||
@@ -10,12 +11,26 @@ import {
|
||||
pickLiveColumns,
|
||||
} from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { rowLogoCount, rowTeamLogos, rowTeamText } from '../../jointTeams'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents } = useScoreboard()
|
||||
const { latestResults, settings, events, liveFeed, liveStale, refreshLiveEvents, jointTeamOf } =
|
||||
useScoreboard()
|
||||
|
||||
/**
|
||||
* 联队(devdocs/19):成绩行里只有主单位,成员单位来自报名表(useScoreboard 会为屏上的小项读一次)。
|
||||
* 普通报名下这两件事与改造前完全一样——文案还是 `shortName ?? teamName`,logo 还是刚一张。
|
||||
*/
|
||||
function teamText(row: EventResultRow): string {
|
||||
return rowTeamText(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
function teamLogos(row: EventResultRow): (string | null)[] {
|
||||
return rowTeamLogos(row, jointTeamOf(row.entryId))
|
||||
}
|
||||
|
||||
const eventNames = computed(() => new Map(events.value.map((event) => [event.id, event.name])))
|
||||
|
||||
@@ -67,13 +82,26 @@ const layout = computed(() => liveBoardLayout(boards.value.length, rowCount.valu
|
||||
const resultTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||
)
|
||||
// 联队行的单位名更长(两个单位名都写全),列宽预算必须按**实际会写出来的字**算,
|
||||
// 否则联队一到屏上就被挤成省略号。
|
||||
const teamTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => row.shortName ?? row.teamName)),
|
||||
boards.value.flatMap((board) => board.rows.map((row) => teamText(row))),
|
||||
)
|
||||
// 有单位 logo 时单位名那一格还要多占一个 logo 宽(TeamLogo 是 1em + 0.28em 间距)。
|
||||
// 单位名后面还要放 logo(TeamLogo 是 1em + 0.28em 间距),一个不够就按最多的那一行算。
|
||||
const teamsWithLogo = computed(() =>
|
||||
boards.value.some((board) => board.rows.some((row) => row.logoPath !== null && row.logoPath !== '')),
|
||||
)
|
||||
const maxLogosPerRow = computed(() =>
|
||||
boards.value.reduce(
|
||||
(max, board) =>
|
||||
Math.max(
|
||||
max,
|
||||
...board.rows.map((row) => rowLogoCount(row, jointTeamOf(row.entryId))),
|
||||
0,
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
const rowFontPx = computed(() =>
|
||||
fitLiveRowFontPx(
|
||||
liveBlockRowCount(rowCount.value, layout.value),
|
||||
@@ -81,6 +109,7 @@ const rowFontPx = computed(() =>
|
||||
resultTexts.value,
|
||||
teamTexts.value,
|
||||
teamsWithLogo.value,
|
||||
maxLogosPerRow.value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -92,6 +121,7 @@ const boardStyle = computed(() => ({
|
||||
layout.value,
|
||||
rowFontPx.value,
|
||||
teamsWithLogo.value,
|
||||
maxLogosPerRow.value,
|
||||
)}px`,
|
||||
}))
|
||||
|
||||
@@ -141,9 +171,10 @@ onUnmounted(() => {
|
||||
>
|
||||
<span class="rk">{{ row.rank ?? '—' }}</span>
|
||||
<span class="nm">{{ displayName(row) }}</span>
|
||||
<!-- 联队两个单位的 logo 都画(teamLogos 普通报名时只有一个元素)。 -->
|
||||
<span class="team">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<TeamLogo v-for="(logo, index) in teamLogos(row)" :key="index" :path="logo" />
|
||||
<span class="team-name">{{ teamText(row) }}</span>
|
||||
</span>
|
||||
<span class="rs">{{ displayResult(row) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -193,18 +193,33 @@ function widestResultEm(results: readonly string[]): number {
|
||||
/** 单位列的字号比例:与 LivePage.vue 里 `.team { font-size: 0.78em }` 一致。 */
|
||||
const TEAM_FONT_EM = 0.78
|
||||
|
||||
/** 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。 */
|
||||
/**
|
||||
* 单位名前的 logo(1em)加间距(0.28em),按单位列自己的字号算。
|
||||
*
|
||||
* 联队一行要放**两个**单位的 logo(devdocs/19),所以这个宽度由调用方按「最多的那一行有几个 logo」
|
||||
* 乘出来传进来,写死一个 logo 的宽会让第二个 logo 把单位名挤成省略号。
|
||||
*/
|
||||
const TEAM_LOGO_EM = 1.28
|
||||
|
||||
/** 单位名列最多留几个 logo 的宽度:再多也只是白占宽度,8 个已经远超现场(联队上限 5)。 */
|
||||
const MAX_TEAM_LOGOS = 8
|
||||
|
||||
/** 运动员列至少要留的宽度(em):4 个汉字的名字(复姓、团体成员)不该被单位名挤没。 */
|
||||
const NAME_MIN_EM = 4
|
||||
|
||||
/** 单位列要留的 logo 宽度(em):`withLogo` 为假时是 0,否则按 logoCount 份算。 */
|
||||
function logoWidthEm(withLogo: boolean, logoCount = 1): number {
|
||||
if (!withLogo) return 0
|
||||
const count = Number.isFinite(logoCount) ? Math.floor(logoCount) : 1
|
||||
return TEAM_LOGO_EM * Math.min(MAX_TEAM_LOGOS, Math.max(1, count))
|
||||
}
|
||||
|
||||
/** 一屏所有单位名里最宽的那个(em),已经折成单位列自己的字号。 */
|
||||
function widestTeamEm(teams: readonly string[], withLogo: boolean): number {
|
||||
function widestTeamEm(teams: readonly string[], withLogo: boolean, logoCount = 1): number {
|
||||
let widest = 0
|
||||
for (const text of teams) widest = Math.max(widest, textEm(text))
|
||||
if (widest <= 0) return 0
|
||||
return (widest + (withLogo ? TEAM_LOGO_EM : 0)) * TEAM_FONT_EM
|
||||
return (widest + logoWidthEm(withLogo, logoCount)) * TEAM_FONT_EM
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,9 +235,10 @@ export function liveTeamColumnPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const row = LIVE_ROW_GEOMETRY[layout]
|
||||
const need = Math.ceil(widestTeamEm(teams, withLogo) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
const need = Math.ceil(widestTeamEm(teams, withLogo, logoCount) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
return Math.min(row.teamBudgetPx, Math.max(row.teamFloorPx, need))
|
||||
}
|
||||
|
||||
@@ -238,11 +254,12 @@ export function liveNameColumnPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const row = LIVE_ROW_GEOMETRY[layout]
|
||||
const free =
|
||||
row.rowWidthPx - row.rankPx - row.gapPx * 3 - liveResultColumnPx(results, layout, fontPx)
|
||||
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo)
|
||||
const team = liveTeamColumnPx(teams, layout, fontPx, withLogo, logoCount)
|
||||
if (!row.teamSharesWidth) return free - team
|
||||
const share = (free * row.teamFr) / (row.nameFr + row.teamFr)
|
||||
return free - Math.max(team, share)
|
||||
@@ -255,8 +272,9 @@ function liveRowFitsPx(
|
||||
layout: LiveLayout,
|
||||
fontPx: number,
|
||||
withLogo: boolean,
|
||||
logoCount: number,
|
||||
): boolean {
|
||||
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo)
|
||||
const name = liveNameColumnPx(results, teams, layout, fontPx, withLogo, logoCount)
|
||||
return name >= NAME_MIN_EM * fontPx
|
||||
}
|
||||
|
||||
@@ -279,6 +297,7 @@ export function fitLiveRowFontPx(
|
||||
results: readonly string[],
|
||||
teams: readonly string[] = [],
|
||||
teamsWithLogo = false,
|
||||
logoCount = 1,
|
||||
): number {
|
||||
const spec = LIVE_LAYOUT_SPECS[layout]
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, spec.ratio, spec.capPx)
|
||||
@@ -287,7 +306,7 @@ export function fitLiveRowFontPx(
|
||||
if (widest > 0) {
|
||||
font = Math.min(font, Math.floor((spec.resultBudgetPx - RESULT_COLUMN_PAD_PX) / widest))
|
||||
}
|
||||
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo)) {
|
||||
while (font > MIN_ROW_FONT_PX && !liveRowFitsPx(results, teams, layout, font, teamsWithLogo, logoCount)) {
|
||||
font--
|
||||
}
|
||||
return Math.max(MIN_ROW_FONT_PX, font)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { isJointTeam, jointTeamLabel, normalizeTeamShares } from '@/shared'
|
||||
import type { EntryTeamShare, Team, TeamShareView } from '@/shared'
|
||||
|
||||
/**
|
||||
* 大屏侧的联队渲染(devdocs/19)。
|
||||
*
|
||||
* 大屏拿到的成绩行(分项成绩 / 实时赛况 / 底栏)里只有**主单位**的 teamId 与名字,
|
||||
* 一条报名挂在几个单位下这件事只存在于报名表(`GET /api/events/{id}/entries` 的 teamShares)。
|
||||
* 所以这里做两件事:
|
||||
* 1. 把那个接口的读数收敛成 `entryId → 成员单位`(名字取自份额,logo 取自快照的单位表——
|
||||
* entries 接口不下发 logoPath);
|
||||
* 2. 把「一行该怎么念、放几个 logo」抽成纯函数,页面只负责把结果画出来。
|
||||
*
|
||||
* 单单位(普通报名)时这些函数一律退回调用方原来的表达式,屏上逐像素不变。
|
||||
*/
|
||||
|
||||
/** 大屏侧的一条份额:展示字段之外补上 logo(从快照单位表里取)。 */
|
||||
export interface DisplayTeamShare extends TeamShareView {
|
||||
logoPath: string | null
|
||||
}
|
||||
|
||||
/** 解析 entries 接口里一项报名的 teamShares;认不出来时返回空数组(= 没有配置)。 */
|
||||
export function readEntryTeamShares(source: unknown): EntryTeamShare[] {
|
||||
if (typeof source !== 'object' || source === null) return []
|
||||
const raw = (source as { teamShares?: unknown }).teamShares
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter((item): item is EntryTeamShare => typeof item === 'object' && item !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批报名 → `entryId → 成员单位`,**只收联队**(成员 ≥ 2)。
|
||||
*
|
||||
* 单单位报名不进表:调用方拿到的 null 就是「按原来那样渲染」,于是普通报名的成绩行
|
||||
* 与大屏改造前完全一致(连一个多余的节点都不多)。
|
||||
* logo 从快照单位表里按 teamId 取,缺单位表(老快照 / 缓存)时是 null,页面自然不画第二个 logo。
|
||||
*/
|
||||
export function entryShareIndex(
|
||||
entries: readonly unknown[],
|
||||
teams: readonly Team[] = [],
|
||||
): Map<number, DisplayTeamShare[]> {
|
||||
const byTeam = new Map<number, Team>(teams.map((team) => [team.id, team]))
|
||||
const out = new Map<number, DisplayTeamShare[]>()
|
||||
for (const entry of entries) {
|
||||
if (typeof entry !== 'object' || entry === null) continue
|
||||
const rec = entry as { id?: unknown; teamId?: unknown; teamCode?: unknown; teamName?: unknown }
|
||||
const entryId = typeof rec.id === 'number' ? rec.id : 0
|
||||
const primaryTeamId = typeof rec.teamId === 'number' ? rec.teamId : 0
|
||||
if (entryId <= 0) continue
|
||||
const members = normalizeTeamShares(readEntryTeamShares(entry), {
|
||||
teamId: primaryTeamId,
|
||||
weight: 1,
|
||||
teamCode: typeof rec.teamCode === 'string' ? rec.teamCode : '',
|
||||
teamName: typeof rec.teamName === 'string' ? rec.teamName : '',
|
||||
}).map((share) => ({
|
||||
...share,
|
||||
logoPath: byTeam.get(share.teamId)?.logoPath ?? null,
|
||||
}))
|
||||
if (!isJointTeam(members)) continue
|
||||
out.set(entryId, members)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 一行成绩(分项成绩页 / 实时赛况页)在单位列里的文案。 */
|
||||
export function rowTeamText(
|
||||
row: { shortName: string | null; teamName: string },
|
||||
members: readonly TeamShareView[] | null,
|
||||
): string {
|
||||
if (members === null) return row.shortName ?? row.teamName
|
||||
return jointTeamLabel(members)
|
||||
}
|
||||
|
||||
/**
|
||||
* 底栏「最新完成项目」一行里的名字:有运动员就写运动员,团体行才写单位。
|
||||
*
|
||||
* 团体行是联队时写「A+B联队」——底栏只有一行字,写不下两个 logo,
|
||||
* 但两个单位的名字必须都在,否则现场会以为只有一个单位参赛。
|
||||
*/
|
||||
export function tickerRowText(
|
||||
row: { athleteName: string | null; shortName: string | null; teamName: string },
|
||||
members: readonly TeamShareView[] | null,
|
||||
): string {
|
||||
if (row.athleteName !== null && row.athleteName.trim() !== '') return row.athleteName
|
||||
return rowTeamText(row, members)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位列要画的 logo:联队时是**行自己的 logo + 其余成员的 logo**,普通报名只有一个元素
|
||||
* (可能与改造前一样是 null,TeamLogo 不渲染任何东西)。
|
||||
*
|
||||
* 行自己的 logoPath 就是主单位的 logo(服务端的读侧口径),所以成员里跳过主单位那一条,
|
||||
* 免得同一个 logo 画两遍。
|
||||
*/
|
||||
export function rowTeamLogos(
|
||||
row: { teamId: number; logoPath: string | null },
|
||||
members: readonly DisplayTeamShare[] | null,
|
||||
): (string | null)[] {
|
||||
if (members === null) return [row.logoPath]
|
||||
const extra = members.filter((member) => member.teamId !== row.teamId)
|
||||
return [row.logoPath, ...extra.map((member) => member.logoPath)]
|
||||
}
|
||||
|
||||
/** 一屏里一行最多要画几个 logo:字号与单位列宽要按它留位置(见 fitFont 的 logoEm)。 */
|
||||
export function rowLogoCount(
|
||||
row: { teamId: number; logoPath: string | null },
|
||||
members: readonly DisplayTeamShare[] | null,
|
||||
): number {
|
||||
return rowTeamLogos(row, members).filter((path) => path !== null && path !== '').length
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Team } from '@/shared'
|
||||
import {
|
||||
entryShareIndex,
|
||||
readEntryTeamShares,
|
||||
rowLogoCount,
|
||||
rowTeamLogos,
|
||||
rowTeamText,
|
||||
tickerRowText,
|
||||
} from '../jointTeams'
|
||||
|
||||
/**
|
||||
* 大屏的联队渲染(devdocs/19)。要钉住的是:
|
||||
* 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张,
|
||||
* 联队成员表里根本没有这条报名的位置;
|
||||
* 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画;
|
||||
* 3. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath),
|
||||
* 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。
|
||||
*/
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const TEAMS: Team[] = [
|
||||
team(1, { code: 'A', name: '第一分公司', shortName: '一司', logoPath: '/uploads/a.png' }),
|
||||
team(2, { code: 'B', name: '第二分公司', shortName: '二司', logoPath: '/uploads/b.png' }),
|
||||
team(3, { code: 'C', name: '第三分公司', shortName: '三司', logoPath: null }),
|
||||
]
|
||||
|
||||
test('teamShares 解析:缺字段 / 类型不对一律当「没有配置」,不抛异常', () => {
|
||||
assert.deepEqual(readEntryTeamShares({}), [])
|
||||
assert.deepEqual(readEntryTeamShares({ teamShares: null }), [])
|
||||
assert.deepEqual(readEntryTeamShares({ teamShares: 'A+B' }), [])
|
||||
assert.deepEqual(readEntryTeamShares(null), [])
|
||||
assert.equal(readEntryTeamShares({ teamShares: [{ teamId: 1, weight: 1 }] }).length, 1)
|
||||
})
|
||||
|
||||
test('联队进表:成员单位带上各自的 logo(名字与 logo 都来自两处不同的接口)', () => {
|
||||
const index = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
)
|
||||
const members = index.get(11)
|
||||
assert.equal(members?.length, 2)
|
||||
assert.equal(members?.[1]?.logoPath, '/uploads/b.png')
|
||||
assert.equal(members?.[1]?.percent, 50)
|
||||
})
|
||||
|
||||
test('普通报名(单单位 / 老服务端没有 teamShares)不进表 = 屏上按原来那样渲染', () => {
|
||||
const index = entryShareIndex(
|
||||
[
|
||||
{ id: 11, teamId: 1, teamCode: 'A', teamName: '第一分公司' },
|
||||
{
|
||||
id: 12,
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
teamShares: [{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' }],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
)
|
||||
assert.equal(index.size, 0)
|
||||
assert.equal(index.get(11), undefined)
|
||||
})
|
||||
|
||||
test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」', () => {
|
||||
const row = { shortName: '一司', teamName: '第一分公司' }
|
||||
assert.equal(rowTeamText(row, null), '一司')
|
||||
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司')
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
// 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。
|
||||
assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队')
|
||||
})
|
||||
|
||||
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」', () => {
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: '张三', shortName: '一司', teamName: '第一分公司' }, members),
|
||||
'张三',
|
||||
)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, members),
|
||||
'第一分公司+第二分公司联队',
|
||||
)
|
||||
assert.equal(
|
||||
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null),
|
||||
'一司',
|
||||
)
|
||||
})
|
||||
|
||||
test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => {
|
||||
const row = { teamId: 1, logoPath: '/uploads/a.png' }
|
||||
assert.deepEqual(rowTeamLogos(row, null), ['/uploads/a.png'])
|
||||
assert.equal(rowLogoCount(row, null), 1)
|
||||
|
||||
const members = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 11,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, teamName: '第二分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(11)
|
||||
assert.ok(members !== null && members !== undefined)
|
||||
// 主单位的 logo 来自行本身(服务端读侧口径),成员单位的 logo 来自快照单位表,不重复画。
|
||||
assert.deepEqual(rowTeamLogos(row, members), ['/uploads/a.png', '/uploads/b.png'])
|
||||
assert.equal(rowLogoCount(row, members), 2)
|
||||
|
||||
// 成员没有 logo 时是 null(TeamLogo 不渲染),不影响另一个 logo 与名字。
|
||||
const noLogo = entryShareIndex(
|
||||
[
|
||||
{
|
||||
id: 12,
|
||||
teamId: 1,
|
||||
teamName: '第一分公司',
|
||||
teamShares: [
|
||||
{ teamId: 1, weight: 1, teamName: '第一分公司' },
|
||||
{ teamId: 3, weight: 1, teamName: '第三分公司' },
|
||||
],
|
||||
},
|
||||
],
|
||||
TEAMS,
|
||||
).get(12)
|
||||
assert.ok(noLogo !== null && noLogo !== undefined)
|
||||
assert.deepEqual(rowTeamLogos(row, noLogo), ['/uploads/a.png', null])
|
||||
assert.equal(rowLogoCount(row, noLogo), 1, '没有 logo 的那一份不占 logo 宽度')
|
||||
})
|
||||
|
||||
test('大屏为屏上的小项读报名表:读失败静默(屏上仍是主单位,不清屏)', () => {
|
||||
const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
|
||||
assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表')
|
||||
assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补')
|
||||
assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏')
|
||||
assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位')
|
||||
})
|
||||
@@ -289,7 +289,9 @@ test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
assert.match(source, /正在获取当前项目…/)
|
||||
assert.match(source, /暂无进行中的项目/)
|
||||
assert.match(source, /该项目暂无已发布的成绩/)
|
||||
assert.match(source, /:path="row\.logoPath"/)
|
||||
// 单位 logo 按 teamLogos(row) 逐张画:联队一行两个单位的 logo 都要出现(普通报名只有一个元素)。
|
||||
assert.match(source, /v-for="\(logo, index\) in teamLogos\(row\)"/)
|
||||
assert.match(source, /:path="logo"/)
|
||||
// 定时兜底必须挂在页面上,否则项目切换会停在旧结论。
|
||||
assert.match(source, /LIVE_REFRESH_INTERVAL_MS/)
|
||||
// 数据来自接口结论(liveFeed),且挂载时立刻拉一次。
|
||||
@@ -330,13 +332,15 @@ test('单位列宽同样由内容算出来,长单位名不再被写死列宽
|
||||
// 单栏与拆两块:单位列写成 minmax(内容宽, 1fr),短名字时与运动员列一样宽,观感不变。
|
||||
assert.match(source, /minmax\(var\(--team-width, 300px\), 1fr\)/)
|
||||
assert.match(source, /minmax\(var\(--team-width, 150px\), 1fr\)/)
|
||||
// 页面必须把单位名与 logo 一起喂给字号计算,否则又变成「算完字号再被单位名挤破」。
|
||||
// 页面必须把单位名(联队时是「A+B联队」)与 logo 一起喂给字号计算,
|
||||
// 否则又变成「算完字号再被单位名挤破」;logo 的份数也要喂(联队一行两个 logo)。
|
||||
assert.match(source, /liveTeamColumnPx/)
|
||||
assert.match(source, /row\.shortName \?\? row\.teamName/)
|
||||
assert.match(source, /rowTeamText\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
assert.match(source, /rowLogoCount\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
assert.match(source, /'--team-width': `\$\{liveTeamColumnPx/)
|
||||
assert.match(
|
||||
source,
|
||||
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,/,
|
||||
/fitLiveRowFontPx\(\s*liveBlockRowCount\([\s\S]*?teamTexts\.value,\s*teamsWithLogo\.value,\s*maxLogosPerRow\.value,/,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,19 +8,26 @@ import { compileTemplate, parse } from 'vue/compiler-sfc'
|
||||
|
||||
const LOGO_SOURCE = readFileSync(new URL('../components/TeamLogo.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)。 */
|
||||
const PAGES: readonly [string, string][] = [
|
||||
['StandingsPage.vue', 'name'],
|
||||
['TeamMedalsPage.vue', 'name'],
|
||||
['EventResultsPage.vue', 'team'],
|
||||
['AthleteStandingsPage.vue', 'name'],
|
||||
/**
|
||||
* 页面 → 放 logo 的那一列(分项成绩页的单位列用的是 .team)+ 该页 `:path` 的表达式。
|
||||
*
|
||||
* 三张榜单页的行本来就属于某个单位,logo 直接取 `row.logoPath`;
|
||||
* 分项成绩页在联队时要画**两个**单位的 logo(devdocs/19),所以它按 `teamLogos(row)` 逐张渲染
|
||||
* ——普通报名那个数组只有一个元素 `row.logoPath`,屏上与改造前一模一样。
|
||||
*/
|
||||
const PAGES: readonly [string, string, string][] = [
|
||||
['StandingsPage.vue', 'name', 'row.logoPath'],
|
||||
['TeamMedalsPage.vue', 'name', 'row.logoPath'],
|
||||
['EventResultsPage.vue', 'team', 'logo'],
|
||||
['AthleteStandingsPage.vue', 'name', 'row.logoPath'],
|
||||
]
|
||||
|
||||
// @vue/compiler-core 的 NodeTypes 没有从 compiler-sfc 再导出,按枚举值取用。
|
||||
const NODE_ELEMENT = 1
|
||||
const NODE_IF = 9
|
||||
const NODE_ATTRIBUTE = 6
|
||||
const NODE_DIRECTIVE = 7
|
||||
const NODE_IF = 9
|
||||
const NODE_FOR = 11
|
||||
|
||||
interface AstAttr {
|
||||
type: number
|
||||
@@ -55,12 +62,20 @@ function classTokens(node: AstNode): string[] {
|
||||
return attr?.value?.content?.split(/\s+/).filter(Boolean) ?? []
|
||||
}
|
||||
|
||||
/** 直接子元素;v-if 分支展开一层,免得元素被条件包住时漏看。 */
|
||||
/**
|
||||
* 直接子元素;v-if 分支与 v-for 循环展开一层,免得元素被条件 / 循环包住时漏看。
|
||||
*
|
||||
* v-for 展开是联队要的:分项成绩页的单位列按 `teamLogos(row)` 逐张画 logo(普通报名时数组
|
||||
* 只有一个元素),渲染时它就是一个循环,不展开的话这一列看着一个 logo 都没有。
|
||||
* 展开后「子元素个数」数的仍是模板里写了几处,不受运行时数组长度影响。
|
||||
*/
|
||||
function childElements(node: AstNode): AstNode[] {
|
||||
const out: AstNode[] = []
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.type === NODE_IF) {
|
||||
for (const branch of child.branches ?? []) out.push(...branch.children)
|
||||
} else if (child.type === NODE_FOR) {
|
||||
out.push(...childElements(child))
|
||||
} else {
|
||||
out.push(child)
|
||||
}
|
||||
@@ -153,7 +168,7 @@ test('TeamLogo 用 em 定尺寸、object-fit: contain,不撑行高', () => {
|
||||
assert.match(rule, /flex:\s*none/, 'logo 不得被 flex 压缩变形')
|
||||
})
|
||||
|
||||
for (const [page, column] of PAGES) {
|
||||
for (const [page, column, pathExpr] of PAGES) {
|
||||
test(`${page} 用 TeamLogo 在 ${column} 列的名字前渲染单位 logo`, () => {
|
||||
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
|
||||
const ast = templateAst(source, page)
|
||||
@@ -166,7 +181,7 @@ for (const [page, column] of PAGES) {
|
||||
container.classes.includes(column),
|
||||
`logo 应放在 .${column} 列里,实际是 .${container.classes.join('.')}`,
|
||||
)
|
||||
assert.equal(container.logoPathExpr, 'row.logoPath', 'logo 应取行数据的 logoPath')
|
||||
assert.equal(container.logoPathExpr, pathExpr, 'logo 应取行数据的 logoPath')
|
||||
|
||||
// 用户要求:logo 在名字前面。名字是容器里紧跟 logo 的那个元素。
|
||||
assert.equal(container.children[0]?.tag, 'TeamLogo', 'logo 应是这一列的首个子元素')
|
||||
@@ -175,6 +190,24 @@ for (const [page, column] of PAGES) {
|
||||
})
|
||||
}
|
||||
|
||||
// 联队(一条报名挂两个单位)时两个单位的 logo 都要出现:分项成绩页的那一列按 teamLogos(row)
|
||||
// 逐张画(普通报名时数组只有一个元素),路径表达式从 teamLogos 求和,成员 logo 落在数组里。
|
||||
test('分项成绩页的 logo 列表来自 teamLogos(row),联队时两个都画', () => {
|
||||
const source = readFileSync(new URL('../components/pages/EventResultsPage.vue', import.meta.url), 'utf8')
|
||||
const ast = templateAst(source, 'EventResultsPage.vue')
|
||||
const nodes: AstNode[] = []
|
||||
collect(ast, nodes)
|
||||
const logo = nodes.find((node) => node.tag === 'TeamLogo')
|
||||
assert.ok(logo !== undefined)
|
||||
assert.equal(boundExpression(logo, 'path'), 'logo', ':path 应为 v-for 的每一项')
|
||||
assert.match(
|
||||
source,
|
||||
/v-for="\(logo, index\) in teamLogos\(row\)"/,
|
||||
'logo 必须按 teamLogos(row) 循环渲染,否则联队画不出第二个',
|
||||
)
|
||||
assert.match(source, /rowTeamLogos\(row, jointTeamOf\(row\.entryId\)\)/)
|
||||
})
|
||||
|
||||
test('四个页面都不再手写 logo 的 <img>,逻辑只留一份', () => {
|
||||
for (const [page] of PAGES) {
|
||||
const source = readFileSync(new URL(`../components/pages/${page}`, import.meta.url), 'utf8')
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
EventResultGroup,
|
||||
RealtimeStatus,
|
||||
StandingRow,
|
||||
Team,
|
||||
} from '@/shared'
|
||||
import {
|
||||
DEFAULT_ROTATE_SECONDS,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
parseTeamBreakdown,
|
||||
} from './guards'
|
||||
import type { DisplayLiveFeed, DisplaySettings, DisplaySnapshot } from './guards'
|
||||
import { entryShareIndex } from './jointTeams'
|
||||
import type { DisplayTeamShare } from './jointTeams'
|
||||
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
|
||||
import { applyDisplayFonts } from './fonts'
|
||||
import { liveRefreshDelay } from './live'
|
||||
@@ -51,6 +54,8 @@ const athletes = ref<AthleteStanding[]>([])
|
||||
const latestResults = ref<EventResultGroup[]>([])
|
||||
// 小项清单:实时赛况页要显示「当前项目」的名字,而指定项目不一定在 latestResults 里。
|
||||
const events = ref<Event[]>([])
|
||||
// 单位表:联队成员单位的 logo 只能从这里取(entries 接口不下发 logoPath)。
|
||||
const teams = ref<Team[]>([])
|
||||
/**
|
||||
* 「实时赛况」的当前项目结论(GET /api/display/live)。
|
||||
*
|
||||
@@ -183,6 +188,7 @@ function applySnapshot(snapshot: DisplaySnapshot, kind: DisplaySource): void {
|
||||
settings.value = snapshot.settings
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
teams.value = snapshot.teams
|
||||
version.value = snapshot.version
|
||||
applyStandings(snapshot.standings)
|
||||
source.value = kind
|
||||
@@ -251,6 +257,85 @@ function reportLiveFailure(wasStale: boolean, reason: string): void {
|
||||
console.warn('[实时赛况]', `拉取当前项目失败,继续显示上一次成绩:${reason}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 联队成员(一条报名挂在多个单位下,devdocs/19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `entryId → 联队成员`。只有联队(成员 ≥ 2)在里面,单单位报名取不到 = 按原来那样渲染。
|
||||
*
|
||||
* 成绩行里只有主单位,成员单位只存在于报名表,所以大屏要为「正在显示的小项」多读一次
|
||||
* `GET /api/events/{id}/entries`(public,guest 可读)。拿不到就退回单单位渲染:
|
||||
* 大屏是无人值守的,为了一句名字把整页搞空不值得。
|
||||
*/
|
||||
const entryShares = ref<Map<number, DisplayTeamShare[]>>(new Map())
|
||||
|
||||
/** 每个小项上次成功拉到报名表的时间;为 0 表示没拉过。 */
|
||||
const sharesFetchedAt = new Map<number, number>()
|
||||
/** 正在飞的小项,避免定时刷新把同一个请求叠成好几份。 */
|
||||
const sharesInFlight = new Set<number>()
|
||||
/** 每个小项上次读回来的联队报名 id:重读时要先删掉它们,改回单单位的行才不会留在屏上。 */
|
||||
const shareEntryIds = new Map<number, number[]>()
|
||||
|
||||
/**
|
||||
* 报名表的重读间隔(毫秒)。
|
||||
*
|
||||
* 份额改动只影响分记在谁头上,推送的是榜单而不是这条报名,所以屏上要自己按期重读一次;
|
||||
* 60s 让「改完几十秒内上屏」,同时每个小项每分钟最多一次请求(局域网里可以忽略)。
|
||||
*/
|
||||
const SHARES_TTL_MS = 60_000
|
||||
|
||||
async function loadEventShares(eventId: number): Promise<void> {
|
||||
if (sharesInFlight.has(eventId)) return
|
||||
sharesInFlight.add(eventId)
|
||||
try {
|
||||
const entries = await api.events.entries(eventId)
|
||||
const index = entryShareIndex(entries, teams.value)
|
||||
const next = new Map(entryShares.value)
|
||||
for (const id of shareEntryIds.get(eventId) ?? []) next.delete(id)
|
||||
const ids: number[] = []
|
||||
for (const [entryId, members] of index) {
|
||||
next.set(entryId, members)
|
||||
ids.push(entryId)
|
||||
}
|
||||
shareEntryIds.set(eventId, ids)
|
||||
entryShares.value = next
|
||||
sharesFetchedAt.set(eventId, Date.now())
|
||||
} catch {
|
||||
// 静默:没有联队信息时屏上仍是主单位,与升级前一致。
|
||||
} finally {
|
||||
sharesInFlight.delete(eventId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉一批小项的报名表:没拉过的、以及过了重读间隔的。 */
|
||||
async function ensureEntryShares(eventIds: readonly number[]): Promise<void> {
|
||||
const now = Date.now()
|
||||
const due = eventIds.filter(
|
||||
(eventId) => eventId > 0 && now - (sharesFetchedAt.get(eventId) ?? 0) > SHARES_TTL_MS,
|
||||
)
|
||||
await Promise.all(due.map((eventId) => loadEventShares(eventId)))
|
||||
}
|
||||
|
||||
/** 屏上正在显示的小项 id(分项成绩 + 实时赛况去重)。 */
|
||||
const sharesEventIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const group of latestResults.value) ids.add(group.eventId)
|
||||
for (const item of liveFeed.value?.events ?? []) ids.add(item.eventId)
|
||||
return [...ids].filter((id) => id > 0).sort((a, b) => a - b)
|
||||
})
|
||||
/** 只有这串 id 真的变了才去读报名表(展示层的其它变化与此无关)。 */
|
||||
const sharesKey = computed(() => sharesEventIds.value.join(','))
|
||||
|
||||
/** 某个报名项的联队成员;单单位 / 没读到都是 null,调用方退回原来的渲染。 */
|
||||
function jointTeamOf(entryId: number): DisplayTeamShare[] | null {
|
||||
return entryShares.value.get(entryId) ?? null
|
||||
}
|
||||
|
||||
watch(sharesKey, () => {
|
||||
void ensureEntryShares(sharesEventIds.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 拉一次「实时赛况」的当前项目(GET /api/display/live)。
|
||||
*
|
||||
@@ -293,6 +378,9 @@ async function refreshLiveEvents(): Promise<void> {
|
||||
} else {
|
||||
liveFeed.value = feed
|
||||
liveStale.value = false
|
||||
// 30s 一次的兜底轮询顺带重读报名表(受 SHARES_TTL_MS 限制,实际每分钟最多一次):
|
||||
// 现场改完成员单位后,屏上不必等到项目切换才跟上。
|
||||
void ensureEntryShares(feed.events.map((item) => item.eventId))
|
||||
}
|
||||
} catch (error) {
|
||||
liveStale.value = true
|
||||
@@ -352,6 +440,7 @@ function activateMock(): void {
|
||||
settings.value = parseSettings(snapshot.settings)
|
||||
latestResults.value = snapshot.latestResults
|
||||
events.value = snapshot.events
|
||||
teams.value = snapshot.teams
|
||||
athletes.value = createMockAthletes()
|
||||
standings.value = snapshot.standings
|
||||
source.value = 'mock'
|
||||
@@ -642,6 +731,8 @@ export function useScoreboard() {
|
||||
athletes,
|
||||
latestResults,
|
||||
events,
|
||||
teams,
|
||||
jointTeamOf,
|
||||
liveFeed,
|
||||
liveStale,
|
||||
page,
|
||||
|
||||
@@ -14,9 +14,11 @@ import type {
|
||||
BackupRestoreResult,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
Entry,
|
||||
EntryHeatPayload,
|
||||
EntryHeatResult,
|
||||
EntryImportResult,
|
||||
EntryTeamShare,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
EventCategory,
|
||||
@@ -312,6 +314,20 @@ export interface EntriesApi {
|
||||
* 只改录入分组,不影响任何成绩与积分。
|
||||
*/
|
||||
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
|
||||
/**
|
||||
* 改写一条报名的成员单位与份额(`PUT /api/entries/{id}/team-shares`,见 devdocs/19)。
|
||||
*
|
||||
* 整组替换、幂等(body.requestId 与 X-Request-Id 是同一个键):改完服务端在同一事务里
|
||||
* 重算该 (小项, 组别) 的流水与名次并推榜,所以成功后的榜单已经是新口径。
|
||||
* 校验失败回 400 + 人话 message(不能为空 / 必须含主单位 / 单位重复 / 单位不存在 / 权重为负 / 成员超限),
|
||||
* 调用方原样展示那句话即可——前端不自己改写服务端的措辞。
|
||||
*/
|
||||
saveTeamShares(
|
||||
entryId: number,
|
||||
teamShares: EntryTeamShare[],
|
||||
requestId?: string,
|
||||
): Promise<Entry>
|
||||
|
||||
/**
|
||||
* 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。
|
||||
*
|
||||
@@ -929,6 +945,13 @@ export class ApiClient {
|
||||
},
|
||||
setHeat: (payload) =>
|
||||
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
|
||||
// 不在前端拼 requestId:send() 会生成一个并同时用于 header 与 body,
|
||||
// 调用方只在重试同一次写操作时才显式传(与批量操作 / 存草稿同一口径)。
|
||||
saveTeamShares: (entryId, teamShares, requestId) =>
|
||||
http.request<Entry>(`/entries/${entryId}/team-shares`, {
|
||||
method: 'PUT',
|
||||
body: requestId === undefined ? { teamShares } : { teamShares, requestId },
|
||||
}),
|
||||
downloadTemplate: (language) =>
|
||||
http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }),
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ export {
|
||||
normalizeCountToTeamMedals,
|
||||
} from './channels'
|
||||
export { readTeamRanksView, readTeamRanksViews } from './teamRanks'
|
||||
export {
|
||||
MAX_JOINT_TEAM_MEMBERS,
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeTeamShares,
|
||||
shareText,
|
||||
shareWeightOf,
|
||||
splitSharePoints,
|
||||
teamSharePayload,
|
||||
} from './jointTeams'
|
||||
export type { TeamShareView } from './jointTeams'
|
||||
export {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { EntryTeamShare } from './types'
|
||||
|
||||
/**
|
||||
* 联队(一条报名挂多个单位)的展示口径,前端只有这一份实现(devdocs/19)。
|
||||
*
|
||||
* 管理端的录入表 / 复核页与大屏的榜单行 / 底栏都要把同一个东西念成同一个样子,
|
||||
* 两处各拼一套「A+B联队」,迟早会变成大屏写简称、管理端写全称这种对不上的局面。
|
||||
*
|
||||
* 这里只做**展示**:权重的归一(`weight / Σweight`)与名字的拼接。
|
||||
* 真份额在服务端算(domain.ResolveShares / splitSharePoints),前端不参与分账。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 一个联队最多几个成员单位。与服务端 `maxJointTeamMembers` 一致。
|
||||
*
|
||||
* 上限的作用是拦住「一整格都是加号」这类误填,而不是限制业务(现场最多见过三个单位合并);
|
||||
* 导入说明与份额编辑器都要说这个数,所以放在共享层,避免两处各写一个 5。
|
||||
*/
|
||||
export const MAX_JOINT_TEAM_MEMBERS = 5
|
||||
|
||||
/** 展示用的一条份额:权重已归一成整数百分比,名字已补好。 */
|
||||
export interface TeamShareView {
|
||||
teamId: number
|
||||
teamCode: string
|
||||
teamName: string
|
||||
/** 相对权重(原值,缺省按 1 处理)。 */
|
||||
weight: number
|
||||
/** 展示份额:`weight / Σweight` 的整数百分比(1+1 → 50,2+1 → 67 与 33)。 */
|
||||
percent: number
|
||||
/** 这枚奖牌是否整枚记给该单位(缺省 true)。 */
|
||||
medal: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 权重取值:非有限或 <= 0 一律按 1(均分)。
|
||||
*
|
||||
* 与服务端 shareWeight 同口径:份额是相对权重,写 0 就是「不填」,负数在写侧已被 400 拦下,
|
||||
* 这里是读侧的兜底——老服务端 / 脏数据不该让界面算出 NaN%。
|
||||
*/
|
||||
export function shareWeightOf(weight: number | null | undefined): number {
|
||||
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight <= 0) return 1
|
||||
return weight
|
||||
}
|
||||
|
||||
function memberName(share: Pick<EntryTeamShare, 'teamId' | 'teamCode' | 'teamName'>): string {
|
||||
const name = (share.teamName ?? '').trim()
|
||||
if (name !== '') return name
|
||||
const code = (share.teamCode ?? '').trim()
|
||||
return code !== '' ? code : `单位 ${share.teamId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取侧收口:把接口给的份额补齐成**至少一条**,并算好展示百分比。
|
||||
*
|
||||
* `fallback` 是「这条报名的主单位」(外层 `teamId` / `teamCode` / `teamName`),
|
||||
* 老服务端不下发 `teamShares`、或者份额整组为空时按主单位独占 100% 补一条——
|
||||
* 这正是升级前所有报名的口径,界面上一个像素都不该变。
|
||||
*
|
||||
* 脏数据(teamId 非法、同一单位出现两次)在这里丢掉:界面上把同一个单位列两遍、
|
||||
* 或者凭空多出一个「单位 0」,比少显示一份更容易误导现场。真份额由服务端校验,这里只是兜底。
|
||||
*/
|
||||
export function normalizeTeamShares(
|
||||
shares: readonly EntryTeamShare[] | null | undefined,
|
||||
fallback?: EntryTeamShare | null,
|
||||
): TeamShareView[] {
|
||||
const list = Array.isArray(shares) ? shares : []
|
||||
const seen = new Set<number>()
|
||||
const valid: EntryTeamShare[] = []
|
||||
for (const share of list) {
|
||||
if (share === null || typeof share !== 'object') continue
|
||||
const teamId = share.teamId
|
||||
if (typeof teamId !== 'number' || !Number.isInteger(teamId) || teamId <= 0) continue
|
||||
if (seen.has(teamId)) continue
|
||||
seen.add(teamId)
|
||||
valid.push(share)
|
||||
}
|
||||
const source =
|
||||
valid.length > 0
|
||||
? valid
|
||||
: fallback != null && Number.isInteger(fallback.teamId) && fallback.teamId > 0
|
||||
? [fallback]
|
||||
: []
|
||||
if (source.length === 0) return []
|
||||
|
||||
const total = source.reduce((sum, share) => sum + shareWeightOf(share.weight), 0)
|
||||
return source.map((share) => {
|
||||
const weight = shareWeightOf(share.weight)
|
||||
return {
|
||||
teamId: share.teamId,
|
||||
teamCode: (share.teamCode ?? '').trim(),
|
||||
teamName: memberName(share),
|
||||
weight,
|
||||
percent: Math.round((weight / total) * 100),
|
||||
// 缺省(undefined)= 整枚记给该单位;只有显式 false 才是「这枚牌不记给它」。
|
||||
medal: share.medal !== false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 是不是联队:成员单位多于一个。单单位(普通报名)返回 false,调用方据此走原来的渲染。 */
|
||||
export function isJointTeam(shares: readonly { teamId: number }[]): boolean {
|
||||
return shares.length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 联队的展示名:`第一分公司+第二分公司联队`(成员单位名逐个写全,用 + 连起来)。
|
||||
*
|
||||
* 用全称而不是简称:现场要认的是「这两个单位合并参赛」这件事,
|
||||
* 简称(一司、二司)在别的单位的人眼里认不出来。单单位时调用方不该用这个函数。
|
||||
*/
|
||||
export function jointTeamLabel(shares: readonly TeamShareView[]): string {
|
||||
return `${shares.map((share) => share.teamName).join('+')}联队`
|
||||
}
|
||||
|
||||
/** 一条份额的展示文案:`第一分公司 50%`;权重不是 1(非均分)时把权重也写出来。 */
|
||||
export function shareText(share: TeamShareView): string {
|
||||
return share.weight === 1 ? `${share.teamName} ${share.percent}%` : `${share.teamName} ${share.percent}%(权重 ${share.weight})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条成绩的积分按份额拆给各成员单位,返回值与 `shares` 一一对应。
|
||||
*
|
||||
* 与服务端 `domain.splitSharePoints` 同口径:先按权重之和归一,最后一份取「总分减去前面各份」
|
||||
* 的余数——2:1 这种除不尽的权重各份直接算会剩下 1e-15 级零头,榜单上就会印出
|
||||
* 9.999999999999998 这种数。管理端的实时积分预览靠它把「预览说 A +15」改成「A、B 各 +7.5」。
|
||||
*/
|
||||
export function splitSharePoints(total: number, shares: readonly { weight: number }[]): number[] {
|
||||
const out: number[] = []
|
||||
if (shares.length === 0) return out
|
||||
const weights = shares.map((share) => shareWeightOf(share.weight))
|
||||
const sum = weights.reduce((acc, weight) => acc + weight, 0)
|
||||
let acc = 0
|
||||
for (let i = 0; i < shares.length; i++) {
|
||||
if (i === shares.length - 1) {
|
||||
out.push(total - acc)
|
||||
break
|
||||
}
|
||||
const part = (total * (weights[i] ?? 1)) / sum
|
||||
out.push(part)
|
||||
acc += part
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 写侧载荷:只带服务端认的三个字段(teamCode / teamName 是读侧补的展示字段)。 */
|
||||
export function teamSharePayload(shares: readonly EntryTeamShare[]): EntryTeamShare[] {
|
||||
return shares.map((share) => {
|
||||
const item: EntryTeamShare = { teamId: share.teamId, weight: shareWeightOf(share.weight) }
|
||||
// medal 缺省即「整枚各记」,不下发它就是缺省口径;显式关掉才必须带上。
|
||||
if (share.medal === false) item.medal = false
|
||||
return item
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
isJointTeam,
|
||||
jointTeamLabel,
|
||||
normalizeTeamShares,
|
||||
shareText,
|
||||
shareWeightOf,
|
||||
splitSharePoints,
|
||||
teamSharePayload,
|
||||
} from '../jointTeams'
|
||||
import type { EntryTeamShare } from '../types'
|
||||
|
||||
/**
|
||||
* 联队的展示与拆分口径(devdocs/19)。要钉住的是:
|
||||
* 1. **普通报名一字不变**:没有 teamShares(老服务端)/ 只有一条时,收口成主单位独占 100%,
|
||||
* 拼出来的名字就是主单位名,界面上与升级前完全一样;
|
||||
* 2. 份额是**相对权重**:1+1 是 50/50,2+1 是 67/33(不是 200/100);
|
||||
* 3. 积分平均拆、接受 .5:15 分给两家就是 7.5 + 7.5,且 2:1 这种除不尽的权重
|
||||
* 两份之和必须**逐位等于**总分(末位取余数,不许出现 9.999999999999998)。
|
||||
*/
|
||||
|
||||
function share(over: Partial<EntryTeamShare> & Pick<EntryTeamShare, 'teamId'>): EntryTeamShare {
|
||||
return { weight: 1, ...over }
|
||||
}
|
||||
|
||||
test('没有 teamShares(老服务端)时按主单位独占 100%', () => {
|
||||
const shares = normalizeTeamShares(undefined, {
|
||||
teamId: 3,
|
||||
weight: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
})
|
||||
assert.equal(shares.length, 1)
|
||||
assert.deepEqual(shares[0], {
|
||||
teamId: 3,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
weight: 1,
|
||||
percent: 100,
|
||||
medal: true,
|
||||
})
|
||||
assert.equal(isJointTeam(shares), false, '单单位不是联队')
|
||||
})
|
||||
|
||||
test('份额整组为空且没有主单位时返回空数组,不造 0 号单位', () => {
|
||||
assert.deepEqual(normalizeTeamShares([], null), [])
|
||||
assert.deepEqual(normalizeTeamShares([], { teamId: 0, weight: 1 }), [])
|
||||
})
|
||||
|
||||
test('1+1 均分:各 50%,联队名把两个单位名都写全', () => {
|
||||
const shares = normalizeTeamShares([
|
||||
share({ teamId: 1, teamCode: 'A', teamName: '第一分公司' }),
|
||||
share({ teamId: 2, teamCode: 'B', teamName: '第二分公司' }),
|
||||
])
|
||||
assert.equal(isJointTeam(shares), true)
|
||||
assert.deepEqual(
|
||||
shares.map((item) => item.percent),
|
||||
[50, 50],
|
||||
)
|
||||
assert.equal(jointTeamLabel(shares), '第一分公司+第二分公司联队')
|
||||
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 50%')
|
||||
})
|
||||
|
||||
test('2+1 是相对权重:67% 与 33%(不是 200% / 100%)', () => {
|
||||
const shares = normalizeTeamShares([
|
||||
share({ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' }),
|
||||
share({ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' }),
|
||||
])
|
||||
assert.deepEqual(
|
||||
shares.map((item) => item.percent),
|
||||
[67, 33],
|
||||
)
|
||||
// 非均分时把权重也写出来,现场一眼看得出「为什么不是一半一半」。
|
||||
assert.equal(shareText(shares[0] as (typeof shares)[number]), '第一分公司 67%(权重 2)')
|
||||
})
|
||||
|
||||
test('权重缺省 / 0 / 负数一律按 1(均分),不产生 NaN%', () => {
|
||||
assert.equal(shareWeightOf(undefined), 1)
|
||||
assert.equal(shareWeightOf(0), 1)
|
||||
assert.equal(shareWeightOf(-3), 1)
|
||||
assert.equal(shareWeightOf(Number.NaN), 1)
|
||||
const shares = normalizeTeamShares([
|
||||
{ teamId: 1, weight: 0, teamName: '甲' },
|
||||
{ teamId: 2, weight: Number.NaN, teamName: '乙' },
|
||||
])
|
||||
assert.deepEqual(
|
||||
shares.map((item) => item.percent),
|
||||
[50, 50],
|
||||
)
|
||||
})
|
||||
|
||||
test('奖牌缺省 true;只有显式 false 才是「这枚牌不记给它」', () => {
|
||||
const shares = normalizeTeamShares([
|
||||
share({ teamId: 1, teamName: '甲' }),
|
||||
share({ teamId: 2, teamName: '乙', medal: false }),
|
||||
])
|
||||
assert.equal(shares[0]?.medal, true)
|
||||
assert.equal(shares[1]?.medal, false)
|
||||
})
|
||||
|
||||
test('名字缺失时退回单位编码,再退回「单位 id」,不留空白格', () => {
|
||||
const shares = normalizeTeamShares([
|
||||
share({ teamId: 1, teamCode: 'A' }),
|
||||
share({ teamId: 2 }),
|
||||
])
|
||||
assert.equal(jointTeamLabel(shares), 'A+单位 2联队')
|
||||
})
|
||||
|
||||
test('脏数据(非法 teamId / 重复单位)在读取侧丢掉,界面不列两遍', () => {
|
||||
const shares = normalizeTeamShares([
|
||||
share({ teamId: 1, teamName: '甲' }),
|
||||
share({ teamId: 1, teamName: '甲的重复行' }),
|
||||
share({ teamId: 0, teamName: '零号' }),
|
||||
])
|
||||
assert.equal(shares.length, 1)
|
||||
assert.equal(shares[0]?.teamName, '甲')
|
||||
})
|
||||
|
||||
test('积分按份额拆:15 分给两家是 7.5 + 7.5(接受 .5)', () => {
|
||||
const split = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }])
|
||||
assert.deepEqual(split, [7.5, 7.5])
|
||||
})
|
||||
|
||||
test('除不尽的权重:各份之和逐位等于总分,不出现浮点零头', () => {
|
||||
const total = 15
|
||||
const split = splitSharePoints(total, [{ weight: 2 }, { weight: 1 }])
|
||||
assert.equal(split[0]! + split[1]!, total, '两份之和必须正好等于总分')
|
||||
assert.equal(split[1], 15 - split[0]!)
|
||||
assert.equal(split[0], 10)
|
||||
assert.equal(split[1], 5)
|
||||
// 三单位 1:1:1 同样要闭合。
|
||||
const three = splitSharePoints(15, [{ weight: 1 }, { weight: 1 }, { weight: 1 }])
|
||||
assert.equal(
|
||||
three.reduce((sum, part) => sum + part, 0),
|
||||
15,
|
||||
)
|
||||
})
|
||||
|
||||
test('单条份额(普通报名)拆出来就是原分,与升级前逐位相同', () => {
|
||||
assert.deepEqual(splitSharePoints(15, [{ weight: 1 }]), [15])
|
||||
assert.deepEqual(splitSharePoints(7.5, [{ weight: 2 }]), [7.5])
|
||||
})
|
||||
|
||||
test('写侧载荷只带 teamId / weight(+ 显式关闭的 medal),不带读侧展示字段', () => {
|
||||
const payload = teamSharePayload([
|
||||
{ teamId: 1, weight: 2, teamCode: 'A', teamName: '第一分公司' },
|
||||
{ teamId: 2, weight: 1, medal: false, teamCode: 'B', teamName: '第二分公司' },
|
||||
])
|
||||
assert.deepEqual(payload, [
|
||||
{ teamId: 1, weight: 2 },
|
||||
{ teamId: 2, weight: 1, medal: false },
|
||||
])
|
||||
})
|
||||
@@ -218,6 +218,22 @@ export type ScoringProfileInput = Omit<ScoringProfile, 'id'>
|
||||
|
||||
export type EntryStatus = 'registered' | 'scratched' | 'dq' | 'dns'
|
||||
|
||||
/**
|
||||
* 一条报名的归属份额(联队的一个成员单位)。见 devdocs/19。
|
||||
*
|
||||
* `weight` 是**相对权重**(不是百分比):展示份额写成 `weight / Σweight`,缺省 1 = 均分;
|
||||
* 引擎按权重之和归一,所以 1+1 与 0.5+0.5 等价。`medal` 缺省 true = 这枚奖牌**整枚**记给该单位,
|
||||
* 只有显式关掉才会是 false(联队的金牌两个单位各记一枚,与权重无关)。
|
||||
* `teamCode` / `teamName` 是读侧补的展示字段,写侧不必带。
|
||||
*/
|
||||
export interface EntryTeamShare {
|
||||
teamId: number
|
||||
weight: number
|
||||
medal?: boolean
|
||||
teamCode?: string
|
||||
teamName?: string
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
id: number
|
||||
eventId: number
|
||||
@@ -238,6 +254,11 @@ export interface Entry {
|
||||
teamName?: string
|
||||
shortName?: string | null
|
||||
categoryName?: string
|
||||
/**
|
||||
* 归属份额:读侧**恒为数组且至少一条**,普通报名就是 `[{主单位, weight:1}]`,
|
||||
* 联队是多条(外层 `teamId` 恒为主单位)。老服务端不下发这个字段,收口在 normalizeTeamShares。
|
||||
*/
|
||||
teamShares?: EntryTeamShare[]
|
||||
}
|
||||
|
||||
export interface EntryWithResult extends Entry {
|
||||
|
||||
Reference in new issue
Block a user