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:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+176
View File
@@ -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'
}