import { ref } from 'vue' import { ApiError, createApiClient } from '@/shared/api' import type { AthletePurgeResult } 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, * 界面据此显示「示例数据」标记。 * 业务错误(404、鉴权失败、校验失败、冲突)与写操作一律不降级——那些是真实服务端的回答。 */ // 只有「根本连不上服务端」才降级到示例数据;404 / 解析失败是服务端给出的真实回答, // 必须原样抛出,否则「用户不存在」「接口不存在」会被伪装成「后端未就绪」。 const FALLBACK_CODES = ['NETWORK', 'TIMEOUT'] export const mockMode = ref(false) export const mockReason = ref(null) let unauthorizedHandler: (() => void) | null = null export function setUnauthorizedHandler(handler: () => void): void { unauthorizedHandler = handler } const client = createApiClient({ onUnauthorized: () => unauthorizedHandler?.(), }) function isFallback(error: unknown): boolean { return error instanceof ApiError && FALLBACK_CODES.includes(error.code) } /** 与 call 同构,但把「本次是否降级」一并返回,供页面显示局部示例数据标记。 */ async function callWithMock( real: () => Promise, mock: () => T | Promise, ): Promise<{ data: T; mock: boolean }> { try { const data = await real() if (mockMode.value) { mockMode.value = false mockReason.value = null } return { data, mock: false } } catch (error) { if (!isFallback(error)) throw error mockMode.value = true mockReason.value = error instanceof Error ? error.message : '无法连接服务端' return { data: await mock(), mock: true } } } async function call(real: () => Promise, mock: () => T | Promise): Promise { return (await callWithMock(real, mock)).data } /** 写操作一律不降级:示例数据里的「保存成功」会让操作员以为成绩已经落库。 */ async function callWrite(real: () => Promise): Promise { return real() } export interface Bootstrap { /** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */ settings: EventSettings | null teams: Team[] sports: Sport[] events: Event[] profiles: ScoringProfile[] standings: StandingRow[] athleteStandings: AthleteStanding[] } /** 除 settings 外都视为必需:失败时交给 call 决定降级到示例数据还是抛出业务错误。 */ function required(result: PromiseSettledResult): T { if (result.status === 'fulfilled') return result.value throw result.reason } async function bootstrap(): Promise { return call( async () => { const [settings, teams, sports, events, profiles, standings, athleteStandings] = await Promise.allSettled([ client.settings.get(), client.teams.list(), client.sports.list(), client.events.list(), client.scoringProfiles.list(), client.standings.teams(), client.standings.athletes(), ] as const) // GET /api/settings 是 admin-only,guest/recorder 必然 403。 // 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。 return { settings: settings.status === 'fulfilled' ? settings.value : null, teams: required(teams), sports: required(sports), events: required(events), profiles: required(profiles), standings: required(standings), athleteStandings: required(athleteStandings), } }, () => mockDb.bootstrap(), ) } export const adminApi = { client, bootstrap, eventDetail: (id: number): Promise => call(() => client.events.get(id), () => mockDb.eventDetail(id)), entries: (eventId: number): Promise => call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)), saveDraft: (payload: SaveDraftPayload): Promise => callWrite(() => client.results.saveDraft(payload)), publish: (payload: PublishPayload): Promise => callWrite(() => client.results.publish(payload)), unpublish: (payload: PublishPayload): Promise => callWrite(() => client.results.unpublish(payload)), standings: (): Promise => call(() => client.standings.teams(), () => mockDb.standings()), athleteStandings: (): Promise => call(() => client.standings.athletes(), () => mockDb.athleteStandings()), breakdown: (teamId: number): Promise => call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)), /** * 彻底删除运动员:服务端物理删除人员、报名与成绩,并重算受影响小项的积分与名次, * 返回删除规模统计供页面提示。失败原样抛出:人员不存在(404)或网络错误都要让操作员看到那句话。 */ purgeAthlete: (id: number): Promise => callWrite(() => client.athletes.purge(id)), /** * 彻底删除参赛单位:与运动员不同,这是「假删除」——单位从列表里消失,但它的报名、成绩与 * 积分流水一行不动,也不会触发任何重算。空响应,没有统计。失败原样抛出(含 404)。 */ purgeTeam: (id: number): Promise => callWrite(() => client.teams.purge(id)), records: (): Promise => call(() => client.records.list(), () => mockDb.records()), adjustments: (query: PageQuery = {}): Promise> => call(() => client.adjustments.list(query), () => mockDb.adjustments()), auditLogs: (query: PageQuery = {}): Promise> => call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)), /** 审计日志需要知道「这一批是不是示例数据」,用局部标记而不是读全局 mockMode。 */ auditLogsWithMock: (query: PageQuery = {}): Promise<{ data: Page; mock: boolean }> => callWithMock(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)), displayCommand: (command: DisplayCommand): Promise => callWrite(() => client.display.command(command)), login: (username: string, password: string): Promise => callWrite(() => client.auth.login(username, password)), logout: (): Promise => callWrite(() => client.auth.logout()), me: (): Promise => call( () => client.auth.me(), () => { // 示例数据模式下没有真实会话,统一按「未登录」处理。 throw new ApiError('AUTH_REQUIRED', '示例数据模式下无会话', 0) }, ), changePassword: (currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> => callWrite(() => client.auth.changePassword(currentPassword, newPassword)), health: (): Promise => call( () => client.health(), () => mockDb.health(), ), } export type AdminApi = typeof adminApi export { ApiError }