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 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() 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() 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() 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 { 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()