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,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 }
|
||||
@@ -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()
|
||||
@@ -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