181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
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)
|
|
},
|
|
),
|
|
|
|
changePassword: (currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> =>
|
|
call(
|
|
() => client.auth.changePassword(currentPassword, newPassword),
|
|
() => {
|
|
throw new ApiError('INTERNAL', '示例数据模式下无法修改密码', 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 }
|