530 lines
19 KiB
TypeScript
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()
|