Files
Scoreboard/web/src/admin/api/client.ts
T

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 }