feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
CompareMode,
|
||||
Event,
|
||||
EventCategory,
|
||||
ResultStatus,
|
||||
ScoreChannel,
|
||||
ScoringProfile,
|
||||
TieMode,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* 名次与积分的纯函数实现。
|
||||
*
|
||||
* 录入页的「实时预览」和 mock 数据源的发布结果必须用同一套算法,
|
||||
* 否则演示态与真实态算出的分会不一致,预览就失去意义。
|
||||
* 服务端就绪后这里只用于预览,真值仍以 /api/standings 为准。
|
||||
*/
|
||||
|
||||
export interface ScorableRow {
|
||||
entryId: number
|
||||
teamId: number
|
||||
resultValue: number | null
|
||||
rank: number | null
|
||||
status: ResultStatus
|
||||
isTie: boolean
|
||||
}
|
||||
|
||||
const EPSILON = 1e-9
|
||||
|
||||
function isRanked(row: ScorableRow): boolean {
|
||||
return row.status === 'ok' && row.resultValue !== null
|
||||
}
|
||||
|
||||
/** 按比较方向排序并赋名次;并列按 tieMode 处理。manual / compare=none 时直接用录入的名次。 */
|
||||
export function assignRanks(
|
||||
rows: readonly ScorableRow[],
|
||||
compare: CompareMode,
|
||||
tieMode: TieMode,
|
||||
manual: boolean,
|
||||
): Map<number, number | null> {
|
||||
const result = new Map<number, number | null>()
|
||||
const ranked = rows.filter(isRanked)
|
||||
|
||||
if (manual || compare === 'none') {
|
||||
for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null)
|
||||
return result
|
||||
}
|
||||
|
||||
const direction = compare === 'asc' ? 1 : -1
|
||||
const sorted = [...ranked].sort(
|
||||
(a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)),
|
||||
)
|
||||
|
||||
let index = 0
|
||||
while (index < sorted.length) {
|
||||
const group: ScorableRow[] = [sorted[index] as ScorableRow]
|
||||
const head = group[0] as ScorableRow
|
||||
let cursor = index + 1
|
||||
while (
|
||||
cursor < sorted.length &&
|
||||
Math.abs((sorted[cursor] as ScorableRow).resultValue! - head.resultValue!) < EPSILON
|
||||
) {
|
||||
group.push(sorted[cursor] as ScorableRow)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
const position = index + 1
|
||||
const tied = group.length > 1
|
||||
let rank = position
|
||||
if (tied && tieMode === 'average') {
|
||||
rank = (position + (position + group.length - 1)) / 2
|
||||
}
|
||||
// skip / higher:并列者都拿靠前的名次,后续名次顺延(02 第 2.2 节)。
|
||||
|
||||
for (const row of group) result.set(row.entryId, rank)
|
||||
index = cursor
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (!result.has(row.entryId)) result.set(row.entryId, null)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 名次 → 分值;并列平均产生小数名次时,取相邻两档的均值。 */
|
||||
export function pointsForRank(rank: number | null, profile: ScoringProfile | null): number {
|
||||
if (rank === null || profile === null || !Number.isFinite(rank)) return 0
|
||||
const exact = profile.rules.find((rule) => rule.rank === rank)
|
||||
if (exact !== undefined) return exact.points
|
||||
|
||||
const lower = profile.rules.find((rule) => rule.rank === Math.floor(rank))
|
||||
const upper = profile.rules.find((rule) => rule.rank === Math.ceil(rank))
|
||||
if (lower === undefined && upper === undefined) return 0
|
||||
if (lower === undefined) return upper?.points ?? 0
|
||||
if (upper === undefined) return lower.points
|
||||
return (lower.points + upper.points) / 2
|
||||
}
|
||||
|
||||
export interface ChannelPlan {
|
||||
athleteProfile: ScoringProfile | null
|
||||
teamProfile: ScoringProfile | null
|
||||
}
|
||||
|
||||
/** 一个项目可挂多个通道;预览只关心「个人表」和「团体表」两张。 */
|
||||
export function planChannels(
|
||||
channels: readonly ScoreChannel[],
|
||||
profiles: readonly ScoringProfile[],
|
||||
): ChannelPlan {
|
||||
const pick = (scope: ScoreChannel['scope']): ScoringProfile | null => {
|
||||
const channel = channels.find((item) => item.scope === scope)
|
||||
if (channel === undefined) return null
|
||||
return profiles.find((profile) => profile.id === channel.profileId) ?? null
|
||||
}
|
||||
return { athleteProfile: pick('athlete'), teamProfile: pick('team') }
|
||||
}
|
||||
|
||||
export interface EventScoreResult {
|
||||
/** 每行「直接产生」的积分,用于表格里的积分列。 */
|
||||
rowPoints: Map<number, number>
|
||||
/** 每行名次,用于自动排名展示。 */
|
||||
ranks: Map<number, number | null>
|
||||
/** 按单位聚合的得分增量,用于预览面板。 */
|
||||
teamPoints: Map<number, number>
|
||||
}
|
||||
|
||||
export interface ScoreContext {
|
||||
event: Event
|
||||
category: EventCategory | null
|
||||
channels: readonly ScoreChannel[]
|
||||
profiles: readonly ScoringProfile[]
|
||||
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */
|
||||
weight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一组成绩的积分。
|
||||
*
|
||||
* 个人项目:行分记给个人,单位获得 `个人分 × 系数`;
|
||||
* 团体 / 对抗项目:行分直接记给单位(02 第 2.4 节)。
|
||||
*/
|
||||
export function computeEventScore(
|
||||
rows: readonly ScorableRow[],
|
||||
context: ScoreContext,
|
||||
): EventScoreResult {
|
||||
const manual = context.event.resultMode === 'manual'
|
||||
const ranks = assignRanks(rows, context.event.compare, tieModeOf(context), manual)
|
||||
const plan = planChannels(context.channels, context.profiles)
|
||||
const multiplier = context.event.multiplier * (context.category?.multiplier ?? 1)
|
||||
const isIndividual = context.event.eventType === 'individual'
|
||||
|
||||
const rowPoints = new Map<number, number>()
|
||||
const teamPoints = new Map<number, number>()
|
||||
const addTeam = (teamId: number, points: number): void => {
|
||||
if (points === 0) return
|
||||
teamPoints.set(teamId, (teamPoints.get(teamId) ?? 0) + points)
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const rank = ranks.get(row.entryId) ?? null
|
||||
const profile = isIndividual
|
||||
? (plan.athleteProfile ?? plan.teamProfile)
|
||||
: (plan.teamProfile ?? plan.athleteProfile)
|
||||
const points = pointsForRank(rank, profile) * multiplier
|
||||
rowPoints.set(row.entryId, points)
|
||||
|
||||
if (isIndividual) addTeam(row.teamId, points * context.weight)
|
||||
else addTeam(row.teamId, points)
|
||||
}
|
||||
|
||||
return { rowPoints, ranks, teamPoints }
|
||||
}
|
||||
|
||||
function tieModeOf(context: ScoreContext): TieMode {
|
||||
const profile = planChannels(context.channels, context.profiles).athleteProfile
|
||||
return profile?.tieMode ?? 'skip'
|
||||
}
|
||||
Reference in new issue
Block a user