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

+172
View File
@@ -0,0 +1,172 @@
import { ref } from 'vue'
import { ApiError, createApiClient } from '@/shared/api'
import type {
AthleteStanding,
AuditLog,
CurrentUser,
DisplayCommand,
EntryWithResult,
Event,
EventSettings,
HealthInfo,
Page,
PageQuery,
PointAdjustment,
PublishPayload,
RecordEntry,
Result,
SaveDraftPayload,
ScoringProfile,
Sport,
StandingRow,
Team,
TeamBreakdown,
} from '@/shared'
import { mockDb } from './mock'
/**
* 管理端数据入口。
*
* 后端未就绪(连接失败 / 超时 / 响应不是约定结构)时自动降级到内存示例数据,
* 并把 `mockMode` 置为 true,界面据此显示「示例数据」标记。
* 业务错误(鉴权失败、校验失败、冲突)不降级——那些是真实服务端的回答,必须原样呈现。
*/
const FALLBACK_CODES = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
export const mockMode = ref(false)
export const mockReason = ref<string | null>(null)
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler
}
const client = createApiClient({
onUnauthorized: () => unauthorizedHandler?.(),
})
function isFallback(error: unknown): boolean {
// 404 表示该接口在当前服务端尚未实现(或整个后端没起来),同样按「未就绪」降级。
return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
}
async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
try {
const value = await real()
if (mockMode.value) {
mockMode.value = false
mockReason.value = null
}
return value
} catch (error) {
if (!isFallback(error)) throw error
mockMode.value = true
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
return await mock()
}
}
export interface Bootstrap {
settings: EventSettings
teams: Team[]
sports: Sport[]
events: Event[]
profiles: ScoringProfile[]
standings: StandingRow[]
athleteStandings: AthleteStanding[]
}
async function bootstrap(): Promise<Bootstrap> {
return call(
async () => {
const [settings, teams, sports, events, profiles, standings, athleteStandings] =
await Promise.all([
client.settings.get(),
client.teams.list(),
client.sports.list(),
client.events.list(),
client.scoringProfiles.list(),
client.standings.teams(),
client.standings.athletes(),
])
return { settings, teams, sports, events, profiles, standings, athleteStandings }
},
() => mockDb.bootstrap(),
)
}
export const adminApi = {
client,
bootstrap,
eventDetail: (id: number): Promise<Event> =>
call(() => client.events.get(id), () => mockDb.eventDetail(id)),
entries: (eventId: number): Promise<EntryWithResult[]> =>
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
saveDraft: (payload: SaveDraftPayload): Promise<Result[]> =>
call(() => client.results.saveDraft(payload), () => mockDb.saveDraft(payload)),
publish: (payload: PublishPayload): Promise<Result[]> =>
call(() => client.results.publish(payload), () => mockDb.publish(payload)),
unpublish: (payload: PublishPayload): Promise<Result[]> =>
call(() => client.results.unpublish(payload), () => mockDb.unpublish(payload)),
standings: (): Promise<StandingRow[]> =>
call(() => client.standings.teams(), () => mockDb.standings()),
athleteStandings: (): Promise<AthleteStanding[]> =>
call(() => client.standings.athletes(), () => mockDb.athleteStandings()),
breakdown: (teamId: number): Promise<TeamBreakdown> =>
call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)),
records: (): Promise<RecordEntry[]> =>
call(() => client.records.list(), () => mockDb.records()),
adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> =>
call(() => client.adjustments.list(query), () => mockDb.adjustments()),
auditLogs: (query: PageQuery = {}): Promise<Page<AuditLog>> =>
call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
displayCommand: (command: DisplayCommand): Promise<void> =>
call(() => client.display.command(command), () => undefined),
login: (username: string, password: string): Promise<CurrentUser> =>
call(
() => client.auth.login(username, password),
() => mockDb.login(username),
),
logout: (): Promise<void> => call(() => client.auth.logout(), () => undefined),
me: (): Promise<CurrentUser> =>
call(
() => client.auth.me(),
() => {
// 示例数据模式下没有真实会话,统一按「未登录」处理。
throw new ApiError('AUTH_REQUIRED', '示例数据模式下无会话', 0)
},
),
health: (): Promise<HealthInfo> =>
call(
() => client.health(),
() => mockDb.health(),
),
exportUrl: (kind: 'standings' | 'breakdown' | 'results'): string | null => {
if (mockMode.value) return null
if (kind === 'standings') return client.exports.standingsUrl()
if (kind === 'breakdown') return client.exports.breakdownUrl({})
return client.exports.resultsUrl()
},
}
export type AdminApi = typeof adminApi
export { ApiError }
+731
View File
@@ -0,0 +1,731 @@
import type {
AthleteStanding,
AuditLog,
CurrentUser,
DraftResultInput,
Entry,
EntryWithResult,
Event,
EventCategory,
EventSettings,
HealthInfo,
Page,
PointAdjustment,
PublishPayload,
RecordEntry,
Result,
ResultStatus,
ResultType,
SaveDraftPayload,
ScoreChannel,
ScoringProfile,
Sport,
StandingRow,
Team,
TeamBreakdown,
BreakdownItem,
} from '@/shared'
import { computeEventScore, type ScorableRow } from './scoring'
/**
* 后端未就绪时的本地示例数据源。
*
* 只覆盖管理端真正用到的读 / 写路径,并在内存里模拟草稿 → 发布 → 积分榜变化,
* 保证「后端没起来」时录入页依然能完整走通交互。所有界面必须显式标注「示例数据」。
*/
const ATHLETES = ['张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛', '吴敏', '郑浩']
const TEAMS: Team[] = [
['JG', '机关本部', '机关'],
['F1', '第一分公司', '一分公司'],
['F2', '第二分公司', '二分公司'],
['F3', '第三分公司', '三分公司'],
['F4', '第四分公司', '四分公司'],
['F5', '第五分公司', '五分公司'],
['F6', '第六分公司', '六分公司'],
].map(([code, name, short], index) => ({
id: index + 1,
code: code as string,
name: name as string,
shortName: short as string,
logoPath: null,
color: ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C'][index] ?? null,
sortOrder: index + 1,
active: true,
}))
const SPORTS: Sport[] = [
{ id: 1, code: 'TJ', name: '田径', shortName: '田径', defaultProfileId: 1, sortOrder: 1, active: true },
{ id: 2, code: 'QL', name: '球类', shortName: '球类', defaultProfileId: 2, sortOrder: 2, active: true },
{ id: 3, code: 'QW', name: '趣味', shortName: '趣味', defaultProfileId: 2, sortOrder: 3, active: true },
]
const PROFILES: ScoringProfile[] = [
{
id: 1,
code: 'individual',
name: '个人名次积分',
tieMode: 'skip',
recordBonusMode: 'max',
awardRanks: 6,
lastPlaceZero: false,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 8 },
{ rank: 3, points: 6 },
{ rank: 4, points: 3 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
},
{
id: 2,
code: 'team',
name: '团体名次积分',
tieMode: 'skip',
recordBonusMode: 'max',
awardRanks: 6,
lastPlaceZero: false,
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
{ rank: 4, points: 4 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
},
]
interface EventSeed {
id: number
sportId: number
code: string
name: string
eventType: Event['eventType']
resultMode: Event['resultMode']
resultType: ResultType
compare: Event['compare']
scored: boolean
categoryName: string
/** 已发布的成绩:单位序号(从 1 开始)+ 成绩文本 + 手动名次(团体项目用)。 */
published?: Array<[number, string] | [number, string, number]>
}
const EVENT_SEEDS: EventSeed[] = [
{
id: 101,
sportId: 1,
code: 'M100',
name: '男子 100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
scored: true,
categoryName: '男子组',
published: [
[1, '11.58'],
[2, '11.92'],
[3, '12.01'],
[4, '12.20'],
[5, '12.35'],
[6, '12.48'],
[7, '12.60'],
],
},
{
id: 102,
sportId: 1,
code: 'W100',
name: '女子 100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
scored: true,
categoryName: '女子组',
},
{
id: 103,
sportId: 1,
code: 'M800',
name: '男子 800 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
scored: true,
categoryName: '男子组',
published: [
[2, '2:14.30'],
[1, '2:16.05'],
[4, '2:18.77'],
[3, '2:20.10'],
[5, '2:22.44'],
],
},
{
id: 104,
sportId: 1,
code: 'LJ',
name: '跳远',
eventType: 'individual',
resultMode: 'auto',
resultType: 'distance',
compare: 'desc',
scored: true,
categoryName: '男子组',
},
{
id: 105,
sportId: 3,
code: 'PU',
name: '引体向上',
eventType: 'individual',
resultMode: 'auto',
resultType: 'count',
compare: 'desc',
scored: true,
categoryName: '男子组',
},
{
id: 106,
sportId: 2,
code: 'BB',
name: '篮球(团体)',
eventType: 'team',
resultMode: 'manual',
resultType: 'score',
compare: 'none',
scored: true,
categoryName: '混合组',
published: [
[3, '第一名', 1],
[1, '第二名', 2],
[5, '第三名', 3],
],
},
{
id: 107,
sportId: 3,
code: 'FUN',
name: '趣味接力(不计分)',
eventType: 'team',
resultMode: 'manual',
resultType: 'none',
compare: 'none',
scored: false,
categoryName: '混合组',
},
]
const SETTINGS: EventSettings = {
event: {
name: '2026 职工运动会',
subtitle: '示例数据',
host: '示例主办方',
logoPath: null,
primaryColor: '#3482FF',
},
display: {
background: null,
logo: null,
title: '2026 职工运动会',
subtitle: '积分榜',
primaryColor: '#3482FF',
rotateSeconds: 15,
},
standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true },
defaultProfileId: 1,
requireApproval: false,
}
function parseValue(text: string, type: ResultType): number | null {
const trimmed = text.trim()
if (trimmed === '') return null
if (type === 'time') {
const parts = trimmed.split(':')
const numbers = parts.map((part) => Number(part))
if (numbers.some((value) => !Number.isFinite(value))) return null
if (numbers.length === 1) return numbers[0] ?? null
return (numbers[0] ?? 0) * 60 + (numbers[1] ?? 0)
}
const cleaned = trimmed.replace(/[^\d.+-]/g, '')
if (cleaned === '') return null
const numeric = Number(cleaned)
return Number.isFinite(numeric) ? numeric : null
}
function unitOf(type: ResultType): string {
if (type === 'time') return 's'
if (type === 'distance') return 'm'
if (type === 'count') return '次'
if (type === 'score') return 'pts'
return ''
}
class MockDb {
private readonly teams = TEAMS
private readonly sports = SPORTS
private readonly profiles = PROFILES
private readonly events: Event[]
private readonly categories: EventCategory[]
private readonly channels: ScoreChannel[]
private readonly entries: Entry[] = []
private readonly results = new Map<number, Result>()
private readonly logs: AuditLog[] = []
private nextResultId = 9000
private nextLogId = 1
constructor() {
this.events = EVENT_SEEDS.map((seed, index) => ({
id: seed.id,
sportId: seed.sportId,
code: seed.code,
name: seed.name,
eventType: seed.eventType,
resultMode: seed.resultMode,
resultType: seed.resultType,
compare: seed.compare,
recordEligible: seed.resultType === 'time' || seed.resultType === 'distance',
scored: seed.scored,
multiplier: 1,
status: seed.published === undefined ? 'pending' : 'published',
scheduledAt: null,
venue: seed.sportId === 1 ? '田径场' : '体育馆',
sortOrder: index + 1,
remark: null,
}))
this.categories = EVENT_SEEDS.map((seed) => ({
id: seed.id * 10 + 1,
eventId: seed.id,
name: seed.categoryName,
multiplier: 1,
gender: seed.categoryName.startsWith('男子') ? '男' : seed.categoryName.startsWith('女子') ? '女' : null,
ageMin: null,
ageMax: null,
sortOrder: 1,
}))
this.channels = EVENT_SEEDS.map((seed) => ({
id: seed.id * 10 + 1,
eventId: seed.id,
scope: seed.eventType === 'individual' ? 'athlete' : 'team',
basis: seed.resultMode === 'manual' ? 'manual' : 'result',
profileId: seed.eventType === 'individual' ? 1 : 2,
sortOrder: 1,
}))
for (const seed of EVENT_SEEDS) this.seedEntries(seed)
for (const seed of EVENT_SEEDS) this.seedResults(seed)
}
private seedEntries(seed: EventSeed): void {
const categoryId = seed.id * 10 + 1
this.teams.forEach((team, index) => {
const entryId = seed.id * 1000 + team.id * 10 + 1
const athleteName = seed.eventType === 'individual' ? ATHLETES[(seed.id + index) % ATHLETES.length] ?? '运动员' : null
this.entries.push({
id: entryId,
eventId: seed.id,
categoryId,
teamId: team.id,
athleteId: seed.eventType === 'individual' ? entryId : null,
memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`],
bib: seed.eventType === 'individual' ? String(entryId % 1000) : null,
seed: null,
status: 'registered',
remark: null,
athleteName,
teamCode: team.code,
teamName: team.name,
shortName: team.shortName,
categoryName: seed.categoryName,
})
})
}
private seedResults(seed: EventSeed): void {
if (seed.published === undefined) return
const categoryId = seed.id * 10 + 1
const rows: DraftResultInput[] = seed.published.map((entry) => ({
entryId: seed.id * 1000 + entry[0] * 10 + 1,
resultText: entry[1],
resultValue: parseValue(entry[1], seed.resultType),
rank: entry.length === 3 ? entry[2] : null,
status: 'ok',
}))
const saved = this.writeResults(seed.id, categoryId, rows, 'published')
for (const result of saved) this.results.set(result.entryId, result)
}
private eventOf(eventId: number): Event {
const event = this.events.find((item) => item.id === eventId)
if (event === undefined) throw new Error(`示例数据里没有项目 ${eventId}`)
return event
}
private scorable(eventId: number, categoryId: number | undefined): ScorableRow[] {
return this.entries
.filter((entry) => entry.eventId === eventId && (categoryId === undefined || entry.categoryId === categoryId))
.map((entry) => {
const result = this.results.get(entry.id)
return {
entryId: entry.id,
teamId: entry.teamId,
resultValue: result?.resultValue ?? null,
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
isTie: result?.isTie ?? false,
}
})
}
private scoreContext(eventId: number, categoryId: number | undefined) {
const event = this.eventOf(eventId)
const category = this.categories.find((item) => item.id === categoryId) ?? null
return {
event,
category,
channels: this.channels.filter((item) => item.eventId === eventId),
profiles: this.profiles,
weight: SETTINGS.standings.individualToTeamWeight,
}
}
private writeResults(
eventId: number,
categoryId: number | undefined,
inputs: readonly DraftResultInput[],
state: Result['state'],
): Result[] {
const event = this.eventOf(eventId)
const unit = unitOf(event.resultType)
const rows: ScorableRow[] = inputs.map((input) => {
const existing = this.results.get(input.entryId)
const entry = this.entries.find((item) => item.id === input.entryId)
const resultValue =
input.resultValue !== undefined
? input.resultValue
: input.resultText !== undefined
? parseValue(input.resultText, event.resultType)
: (existing?.resultValue ?? null)
return {
entryId: input.entryId,
teamId: entry?.teamId ?? 0,
resultValue,
rank: input.rank ?? existing?.rank ?? null,
status: input.status ?? existing?.status ?? 'ok',
isTie: input.isTie ?? existing?.isTie ?? false,
}
})
const ranks = computeEventScore(rows, this.scoreContext(eventId, categoryId)).ranks
return inputs.map((input) => {
const existing = this.results.get(input.entryId)
const resultText = input.resultText ?? existing?.resultText ?? ''
const resultValue =
input.resultValue !== undefined
? input.resultValue
: parseValue(resultText, event.resultType)
const status: ResultStatus = input.status ?? existing?.status ?? 'ok'
const rank = event.resultMode === 'manual' || event.compare === 'none'
? (input.rank ?? existing?.rank ?? null)
: (status === 'ok' ? (ranks.get(input.entryId) ?? null) : null)
const result: Result = {
id: existing?.id ?? (this.nextResultId += 1),
eventId,
entryId: input.entryId,
rank,
resultText,
resultValue,
unit,
isTie: input.isTie ?? existing?.isTie ?? false,
pointsOverride: input.pointsOverride ?? null,
status,
state,
recordedBy: state === 'published' ? 1 : null,
recordedAt: new Date().toISOString(),
verifiedBy: state === 'published' ? 1 : null,
verifiedAt: state === 'published' ? new Date().toISOString() : null,
note: input.note ?? existing?.note ?? null,
points: null,
}
this.results.set(input.entryId, result)
return result
})
}
private log(action: string, entityType: string, entityId: number, detail: string): void {
this.logs.unshift({
id: (this.nextLogId += 1),
userId: 1,
action,
entityType,
entityId,
beforeJson: null,
afterJson: JSON.stringify({ detail }),
ip: '127.0.0.1',
requestId: null,
createdAt: new Date().toISOString(),
})
}
// ── 对外方法 ────────────────────────────────────────────────────────────
bootstrap(): {
settings: EventSettings
teams: Team[]
sports: Sport[]
events: Event[]
profiles: ScoringProfile[]
standings: StandingRow[]
athleteStandings: AthleteStanding[]
} {
return {
settings: SETTINGS,
teams: this.teams,
sports: this.sports,
events: this.events,
profiles: this.profiles,
standings: this.standings(),
athleteStandings: this.athleteStandings(),
}
}
eventDetail(id: number): Event {
const event = this.eventOf(id)
return {
...event,
categories: this.categories.filter((item) => item.eventId === id),
channels: this.channels.filter((item) => item.eventId === id),
}
}
listEntries(eventId: number): EntryWithResult[] {
return this.entries
.filter((entry) => entry.eventId === eventId)
.map((entry) => {
const result = this.results.get(entry.id) ?? null
return { ...entry, result: result === null ? null : this.withPoints(result) }
})
}
saveDraft(payload: SaveDraftPayload): Result[] {
const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft')
this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`)
return saved.map((result) => this.withPoints(result))
}
publish(payload: PublishPayload): Result[] {
const targets = this.entries.filter(
(entry) =>
entry.eventId === payload.eventId &&
(payload.categoryId === undefined || entry.categoryId === payload.categoryId),
)
const changed = targets
.map((entry) => this.results.get(entry.id))
.filter((result): result is Result => result !== undefined)
for (const result of changed) {
result.state = 'published'
result.recordedAt = new Date().toISOString()
result.verifiedAt = result.recordedAt
}
const event = this.eventOf(payload.eventId)
event.status = 'published'
this.log('result.publish', 'event', payload.eventId, `发布 ${changed.length} 条成绩`)
return changed.map((result) => this.withPoints(result))
}
unpublish(payload: PublishPayload): Result[] {
const targets = this.entries.filter(
(entry) =>
entry.eventId === payload.eventId &&
(payload.categoryId === undefined || entry.categoryId === payload.categoryId),
)
const changed = targets
.map((entry) => this.results.get(entry.id))
.filter((result): result is Result => result !== undefined)
for (const result of changed) result.state = 'draft'
const event = this.eventOf(payload.eventId)
event.status = 'ongoing'
this.log('result.unpublish', 'event', payload.eventId, `撤销发布 ${changed.length} 条成绩`)
return changed.map((result) => this.withPoints(result))
}
private withPoints(result: Result): Result {
const points = computeEventScore(
this.scorable(result.eventId, undefined),
this.scoreContext(result.eventId, undefined),
).rowPoints.get(result.entryId)
return { ...result, points: points ?? 0 }
}
standings(): StandingRow[] {
const totals = new Map<number, { points: number; gold: number; silver: number; bronze: number }>()
for (const team of this.teams) totals.set(team.id, { points: 0, gold: 0, silver: 0, bronze: 0 })
for (const event of this.events) {
const scorable = this.scorable(event.id, undefined)
const score = computeEventScore(scorable, this.scoreContext(event.id, undefined))
for (const [teamId, points] of score.teamPoints) {
const bucket = totals.get(teamId)
if (bucket !== undefined) bucket.points += points
}
for (const row of scorable) {
if (row.status !== 'ok') continue
const rank = score.ranks.get(row.entryId)
const bucket = totals.get(row.teamId)
if (bucket === undefined || rank === null) continue
if (rank === 1) bucket.gold += 1
else if (rank === 2) bucket.silver += 1
else if (rank === 3) bucket.bronze += 1
}
}
return this.teams
.map((team) => {
const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 }
return {
teamId: team.id,
teamCode: team.code,
teamName: team.name,
shortName: team.shortName,
color: team.color,
logoPath: team.logoPath,
rank: 0,
gold: bucket.gold,
silver: bucket.silver,
bronze: bucket.bronze,
medalTotal: bucket.gold + bucket.silver + bucket.bronze,
totalPoints: round(bucket.points),
rankChange: null,
}
})
.sort(
(a, b) =>
b.totalPoints - a.totalPoints ||
b.gold - a.gold ||
b.silver - a.silver ||
b.bronze - a.bronze ||
a.teamCode.localeCompare(b.teamCode),
)
.map((row, index) => ({ ...row, rank: index + 1 }))
}
athleteStandings(): AthleteStanding[] {
const rows: AthleteStanding[] = []
for (const event of this.events) {
if (event.eventType !== 'individual') continue
const scorable = this.scorable(event.id, undefined)
const score = computeEventScore(scorable, this.scoreContext(event.id, undefined))
for (const entry of this.entries.filter((item) => item.eventId === event.id)) {
const points = score.rowPoints.get(entry.id) ?? 0
if (points === 0) continue
const rank = score.ranks.get(entry.id) ?? null
rows.push({
rank: 0,
athleteId: entry.athleteId ?? entry.id,
athleteName: entry.athleteName ?? '运动员',
gender: null,
teamId: entry.teamId,
teamCode: entry.teamCode ?? '',
teamName: entry.teamName ?? '',
shortName: entry.shortName ?? null,
totalPoints: points,
gold: rank === 1 ? 1 : 0,
silver: rank === 2 ? 1 : 0,
bronze: rank === 3 ? 1 : 0,
})
}
}
return rows
.sort((a, b) => b.totalPoints - a.totalPoints || a.athleteName.localeCompare(b.athleteName))
.map((row, index) => ({ ...row, rank: index + 1 }))
}
breakdown(teamId: number): TeamBreakdown {
const team = this.teams.find((item) => item.id === teamId)
const items: BreakdownItem[] = []
for (const event of this.events) {
const score = computeEventScore(
this.scorable(event.id, undefined),
this.scoreContext(event.id, undefined),
)
for (const entry of this.entries.filter((item) => item.eventId === event.id && item.teamId === teamId)) {
const result = this.results.get(entry.id)
if (result === undefined || result.state !== 'published') continue
const points = score.rowPoints.get(entry.id) ?? 0
items.push({
ledgerId: result.id,
sourceType: 'result',
eventId: event.id,
eventCode: event.code,
eventName: event.name,
categoryName: entry.categoryName ?? null,
athleteId: entry.athleteId,
athleteName: entry.athleteName ?? null,
rank: score.ranks.get(entry.id) ?? null,
resultText: result.resultText,
points,
createdAt: result.recordedAt ?? new Date().toISOString(),
})
}
}
return {
teamId,
teamCode: team?.code ?? '',
teamName: team?.name ?? '未知单位',
totalPoints: round(items.reduce((sum, item) => sum + item.points, 0)),
items,
}
}
records(): RecordEntry[] {
return [
{
id: 1,
eventId: 101,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
holderName: '示例纪录保持者',
holderTeamId: 1,
achievedAt: '2025-09-01',
isCurrent: true,
source: 'history',
},
]
}
adjustments(): Page<PointAdjustment> {
return { items: [], total: 0, page: 1, size: 20 }
}
auditLogs(page = 1, size = 20): Page<AuditLog> {
return { items: this.logs.slice((page - 1) * size, page * size), total: this.logs.length, page, size }
}
login(username: string): CurrentUser {
return {
id: 1,
username,
displayName: username === '' ? '示例用户' : username,
role: 'admin',
lastLoginAt: null,
}
}
health(): HealthInfo {
return { version: 'mock' }
}
}
function round(value: number): number {
return Number(value.toFixed(2))
}
export const mockDb = new MockDb()
+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'
}