Files
Scoreboard/web/src/admin/components/baseData/mockBaseData.ts
T

530 lines
19 KiB
TypeScript

import type {
Athlete,
AthleteInput,
EntryImportResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
ScoringProfile,
ScoringProfileInput,
ScoreChannel,
ScoreChannelInput,
Sport,
SportInput,
Team,
TeamInput,
} from '@/shared'
/**
* 基础数据页的离线示例数据源。
*
* 后端未就绪(连接失败 / 接口未实现)时页面降级到这里,保证每个分区都能走通
* 「列表 → 新增 → 编辑 → 停用/删除 → 整体保存」的完整交互。数据只在内存里,
* 刷新即重置;界面必须显式标注「示例数据」。
*/
const TEAM_COLORS = ['#3482FF', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C']
const SEED_TEAMS: Team[] = [
['JG', '机关本部', '机关'],
['F1', '第一分公司', '一分公司'],
['F2', '第二分公司', '二分公司'],
['F3', '第三分公司', '三分公司'],
['F4', '第四分公司', '四分公司'],
['F5', '第五分公司', '五分公司'],
['F6', '第六分公司', '六分公司'],
].map(([code, name, short], index) => ({
id: index + 1,
code: code ?? '',
name: name ?? '',
shortName: short ?? null,
logoPath: null,
color: TEAM_COLORS[index] ?? null,
sortOrder: index + 1,
active: true,
}))
const SEED_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 SEED_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 {
event: Omit<Event, 'id' | 'categories' | 'channels'>
category: string
scope: ScoreChannel['scope']
basis: ScoreChannel['basis']
profileId: number
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
entryCount: number
}
const SEED_EVENTS: EventSeed[] = [
{
event: {
sportId: 1, code: 'M100', name: '男子 100 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 1, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 7,
},
{
event: {
sportId: 1, code: 'W100', name: '女子 100 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 2, remark: null,
},
category: '女子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 1, code: 'M800', name: '男子 800 米', eventType: 'individual', resultMode: 'auto',
resultType: 'time', compare: 'asc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 3, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 5,
},
{
event: {
sportId: 1, code: 'LJ', name: '跳远', eventType: 'individual', resultMode: 'auto',
resultType: 'distance', compare: 'desc', recordEligible: true, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '田径场', sortOrder: 4, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 3, code: 'PU', name: '引体向上', eventType: 'individual', resultMode: 'auto',
resultType: 'count', compare: 'desc', recordEligible: false, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 5, remark: null,
},
category: '男子组', scope: 'athlete', basis: 'result', profileId: 1,
entryCount: 0,
},
{
event: {
sportId: 2, code: 'BB', name: '篮球(团体)', eventType: 'team', resultMode: 'manual',
resultType: 'score', compare: 'none', recordEligible: false, scored: true, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 6, remark: null,
},
category: '混合组', scope: 'team', basis: 'manual', profileId: 2,
entryCount: 3,
},
{
event: {
sportId: 3, code: 'FUN', name: '趣味接力(不计分)', eventType: 'team', resultMode: 'manual',
resultType: 'none', compare: 'none', recordEligible: false, scored: false, multiplier: 1,
status: 'pending', scheduledAt: null, venue: '体育馆', sortOrder: 7, remark: null,
},
category: '混合组', scope: 'team', basis: 'manual', profileId: 2,
entryCount: 0,
},
]
const ATHLETE_NAMES = [
'张伟', '李强', '王芳', '刘洋', '陈晨', '赵磊', '孙倩', '周涛',
'吴敏', '郑浩', '冯雪', '褚健', '卫东', '蒋楠', '沈莉', '韩旭',
'杨帆', '朱琳', '秦峰', '许诺', '何静', '吕明', '施雨', '张晓',
]
/** 报名表模板列,与服务端 importColumns 保持一致(大小写不敏感)。 */
const IMPORT_COLUMNS = [
'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names',
]
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
class MockBaseData {
private teams: Team[] = SEED_TEAMS.map((team) => ({ ...team }))
private sports: Sport[] = SEED_SPORTS.map((sport) => ({ ...sport }))
private profiles: ScoringProfile[] = SEED_PROFILES.map((profile) => ({
...profile,
rules: profile.rules.map((rule) => ({ ...rule })),
}))
private events: Event[] = []
private categories: EventCategory[] = []
private channels: ScoreChannel[] = []
private athletes: Athlete[] = []
private nextTeamId = 100
private nextSportId = 100
private nextEventId = 1000
private nextCategoryId = 1000
private nextChannelId = 1000
private nextProfileId = 100
private nextAthleteId = 1000
/** 每个小项的示例报名条数,用于复刻服务端「有报名就不能删项目 / 替换组别」的约束。 */
private readonly entryCounts = new Map<number, number>()
constructor() {
SEED_EVENTS.forEach((seed, index) => {
const id = 101 + index
this.events.push({ ...seed.event, id })
this.entryCounts.set(id, seed.entryCount)
this.categories.push({
id: (this.nextCategoryId += 1),
eventId: id,
name: seed.category,
multiplier: 1,
gender: seed.category.startsWith('男子') ? '男' : seed.category.startsWith('女子') ? '女' : null,
ageMin: null,
ageMax: null,
sortOrder: 1,
})
this.channels.push({
id: (this.nextChannelId += 1),
eventId: id,
scope: seed.scope,
basis: seed.basis,
profileId: seed.profileId,
sortOrder: 1,
})
})
this.teams.forEach((team, teamIndex) => {
ATHLETE_NAMES.slice(teamIndex * 3, teamIndex * 3 + 3).forEach((name, nameIndex) => {
this.athletes.push({
id: (this.nextAthleteId += 1),
name,
gender: nameIndex % 2 === 0 ? '男' : '女',
teamId: team.id,
employeeNo: `${team.code}-${String(nameIndex + 1).padStart(3, '0')}`,
active: true,
})
})
})
}
// ── 参赛单位 ──────────────────────────────────────────────────────────────
listTeams(): Team[] {
return this.teams.map((team) => ({ ...team }))
}
createTeam(input: TeamInput): Team {
if (this.teams.some((team) => team.code === input.code)) throw new Error(`单位代码 ${input.code} 已存在`)
const team: Team = { ...input, id: (this.nextTeamId += 1) }
this.teams = [...this.teams, team]
return { ...team }
}
updateTeam(id: number, input: TeamInput): Team {
const index = this.teams.findIndex((team) => team.id === id)
if (index < 0) throw new Error(`单位 ${id} 不存在`)
if (this.teams.some((team) => team.id !== id && team.code === input.code)) {
throw new Error(`单位代码 ${input.code} 已存在`)
}
const team: Team = { ...input, id }
this.teams[index] = team
return { ...team }
}
// ── 大项 ──────────────────────────────────────────────────────────────────
listSports(): Sport[] {
return this.sports.map((sport) => ({ ...sport }))
}
createSport(input: SportInput): Sport {
if (this.sports.some((sport) => sport.code === input.code)) throw new Error(`大项代码 ${input.code} 已存在`)
const sport: Sport = { ...input, id: (this.nextSportId += 1) }
this.sports = [...this.sports, sport]
return { ...sport }
}
updateSport(id: number, input: SportInput): Sport {
const index = this.sports.findIndex((sport) => sport.id === id)
if (index < 0) throw new Error(`大项 ${id} 不存在`)
const sport: Sport = { ...input, id }
this.sports[index] = sport
return { ...sport }
}
removeSport(id: number): void {
const index = this.sports.findIndex((sport) => sport.id === id)
if (index < 0) throw new Error(`大项 ${id} 不存在`)
if (this.events.some((event) => event.sportId === id)) throw new Error('该大项下还有小项,请先删除小项')
this.sports.splice(index, 1)
}
// ── 小项 ──────────────────────────────────────────────────────────────────
listEvents(): Event[] {
return this.events.map((event) => ({ ...event }))
}
getEvent(id: number): Event {
const event = this.events.find((item) => item.id === id)
if (event === undefined) throw new Error(`小项 ${id} 不存在`)
return {
...event,
categories: this.categories.filter((item) => item.eventId === id).map((item) => ({ ...item })),
channels: this.channels.filter((item) => item.eventId === id).map((item) => ({ ...item })),
}
}
createEvent(input: EventInput): Event {
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
const id = (this.nextEventId += 1)
const event: Event = { ...input, id }
this.events = [...this.events, event]
this.entryCounts.set(id, 0)
return { ...event }
}
updateEvent(id: number, input: EventInput): Event {
const index = this.events.findIndex((event) => event.id === id)
if (index < 0) throw new Error(`小项 ${id} 不存在`)
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
throw new Error(`小项代码 ${input.code} 已存在`)
}
const event: Event = { ...input, id }
this.events[index] = event
return { ...event }
}
removeEvent(id: number): void {
const index = this.events.findIndex((event) => event.id === id)
if (index < 0) throw new Error(`小项 ${id} 不存在`)
if ((this.entryCounts.get(id) ?? 0) > 0) throw new Error('小项已有报名数据,不能删除')
this.events.splice(index, 1)
this.entryCounts.delete(id)
this.categories = this.categories.filter((item) => item.eventId !== id)
this.channels = this.channels.filter((item) => item.eventId !== id)
}
saveCategories(eventId: number, inputs: EventCategoryInput[]): EventCategory[] {
this.getEvent(eventId)
if ((this.entryCounts.get(eventId) ?? 0) > 0) throw new Error('该项目已有报名数据,不能整体替换组别')
const names = new Set<string>()
for (const input of inputs) {
const name = input.name.trim()
if (name === '') throw new Error('组别名称必填')
if (names.has(name)) throw new Error(`组别名称重复:${name}`)
names.add(name)
}
this.categories = this.categories.filter((item) => item.eventId !== eventId)
for (const input of inputs) {
this.categories.push({ ...input, id: (this.nextCategoryId += 1), eventId })
}
return this.getEvent(eventId).categories ?? []
}
saveChannels(eventId: number, inputs: ScoreChannelInput[]): ScoreChannel[] {
this.getEvent(eventId)
const combinations = new Set<string>()
for (const input of inputs) {
const key = `${input.scope}:${input.basis}`
if (combinations.has(key)) throw new Error(`同一 scope+basis 只能配置一个通道:${key}`)
combinations.add(key)
}
this.channels = this.channels.filter((item) => item.eventId !== eventId)
for (const input of inputs) {
this.channels.push({ ...input, id: (this.nextChannelId += 1), eventId })
}
return this.getEvent(eventId).channels ?? []
}
// ── 积分模板 ──────────────────────────────────────────────────────────────
listProfiles(): ScoringProfile[] {
return this.profiles.map((profile) => ({ ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }))
}
createProfile(input: ScoringProfileInput): ScoringProfile {
if (this.profiles.some((profile) => profile.code === input.code)) throw new Error(`模板代码 ${input.code} 已存在`)
const profile: ScoringProfile = {
...input,
id: (this.nextProfileId += 1),
rules: input.rules.map((rule) => ({ ...rule })),
}
this.profiles = [...this.profiles, profile]
return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }
}
updateProfile(id: number, input: ScoringProfileInput): ScoringProfile {
const index = this.profiles.findIndex((profile) => profile.id === id)
if (index < 0) throw new Error(`积分模板 ${id} 不存在`)
const profile: ScoringProfile = {
...input,
id,
rules: input.rules.map((rule) => ({ ...rule })),
}
this.profiles[index] = profile
return { ...profile, rules: profile.rules.map((rule) => ({ ...rule })) }
}
// ── 运动员 ────────────────────────────────────────────────────────────────
listAthletes(): Athlete[] {
return this.athletes.map((athlete) => ({ ...athlete }))
}
createAthlete(input: AthleteInput): Athlete {
const athlete: Athlete = { ...input, id: (this.nextAthleteId += 1) }
this.athletes = [...this.athletes, athlete]
return { ...athlete }
}
updateAthlete(id: number, input: AthleteInput): Athlete {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
const athlete: Athlete = { ...input, id }
this.athletes[index] = athlete
return { ...athlete }
}
removeAthlete(id: number): void {
const index = this.athletes.findIndex((athlete) => athlete.id === id)
if (index < 0) throw new Error(`运动员 ${id} 不存在`)
this.athletes.splice(index, 1)
}
// ── 报名导入 ──────────────────────────────────────────────────────────────
async importEntries(file: File): Promise<EntryImportResult> {
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv') {
return { created: 0, updated: 0, skipped: 0, errors: [XLSX_HINT] }
}
const rows = parseCsv(await file.text())
if (rows.length < 2) throw new Error('报名表为空')
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
const index = new Map(header.map((name, position) => [name, position]))
for (const column of IMPORT_COLUMNS) {
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
}
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [] }
rows.slice(1).forEach((row, offset) => {
const line = offset + 2
if (row.every((cell) => cell.trim() === '')) return
const cell = (name: string): string => {
const position = index.get(name)
return position === undefined ? '' : (row[position] ?? '').trim()
}
const eventCode = cell('event_code')
const teamCode = cell('team_code')
const categoryName = cell('category')
if (eventCode === '' || teamCode === '') {
result.skipped += 1
result.errors.push(`第 ${line} 行:event_code / team_code 必填`)
return
}
const event = this.events.find((item) => item.code === eventCode)
if (event === undefined) {
result.skipped += 1
result.errors.push(`第 ${line} 行:小项 ${eventCode} 不存在`)
return
}
const team = this.teams.find((item) => item.code === teamCode)
if (team === undefined) {
result.skipped += 1
result.errors.push(`第 ${line} 行:单位 ${teamCode} 不存在`)
return
}
const categories = this.categories.filter((item) => item.eventId === event.id)
if (categoryName !== '' && !categories.some((item) => item.name === categoryName)) {
result.skipped += 1
result.errors.push(`第 ${line} 行:组别 ${categoryName} 不存在`)
return
}
if (categoryName === '' && categories.length === 0) {
result.skipped += 1
result.errors.push(`第 ${line} 行:小项 ${eventCode} 没有组别`)
return
}
result.created += 1
})
return result
}
}
/** 够用的 CSV 解析:支持双引号包裹与 `""` 转义,报名表不会出现更复杂的结构。 */
function parseCsv(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let index = 0; index < text.length; index += 1) {
const char = text[index]
if (quoted) {
if (char === '"') {
if (text[index + 1] === '"') {
cell += '"'
index += 1
} else {
quoted = false
}
} else {
cell += char
}
continue
}
if (char === '"') {
quoted = true
} else if (char === ',') {
row.push(cell)
cell = ''
} else if (char === '\n') {
row.push(cell)
rows.push(row)
row = []
cell = ''
} else if (char !== '\r') {
cell += char
}
}
if (cell !== '' || row.length > 0) {
row.push(cell)
rows.push(row)
}
return rows
}
export const mockBaseData = new MockBaseData()