first commit

This commit is contained in:
2026-02-18 14:30:42 +08:00
commit f79920ad6a
212 changed files with 3850 additions and 0 deletions

146
src/api/authed-request.ts Normal file
View File

@@ -0,0 +1,146 @@
import { refreshAccessToken } from './auth'
export interface AuthSession {
token: string
tokenType?: string
refreshToken?: string
expiresIn?: number
}
export interface AuthedRequestOptions {
method: 'GET' | 'POST'
path: string
auth: AuthSession
body?: Record<string, unknown>
onAuthUpdated?: (next: AuthSession) => void
}
export interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
interface ApiErrorPayload {
code?: number
msg?: string
message?: string
error?: string
}
export class AuthExpiredError extends Error {
constructor(message = '登录状态已过期,请重新登录') {
super(message)
this.name = 'AuthExpiredError'
}
}
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
function buildUrl(path: string): string {
if (/^https?:\/\//.test(path)) {
return path
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (!API_BASE_URL) {
return normalizedPath
}
try {
const baseUrl = new URL(API_BASE_URL)
const basePath = baseUrl.pathname.replace(/\/$/, '')
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
}
} catch {
// API_BASE_URL may be a relative path.
}
return `${API_BASE_URL}${normalizedPath}`
}
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
const normalizedToken = token.trim()
if (/^\S+\s+\S+/.test(normalizedToken)) {
return normalizedToken
}
return `${tokenType || 'Bearer'} ${normalizedToken}`
}
async function parseEnvelope<T>(response: Response): Promise<ApiEnvelope<T>> {
const payload = (await response.json().catch(() => ({}))) as ApiEnvelope<T> & ApiErrorPayload
if (!response.ok) {
throw new Error(payload.msg ?? payload.message ?? payload.error ?? '请求失败,请稍后再试')
}
if (typeof payload.code === 'number' && payload.code !== 0) {
throw new Error(payload.msg ?? '接口返回失败')
}
return payload
}
async function runRequest<T>(
options: Omit<AuthedRequestOptions, 'auth'> & { session: AuthSession },
): Promise<{ response: Response; parsed: ApiEnvelope<T> }> {
const response = await fetch(buildUrl(options.path), {
method: options.method,
headers: {
...(options.body ? { 'Content-Type': 'application/json' } : undefined),
Authorization: createAuthHeader(options.session.token, options.session.tokenType),
},
body: options.body ? JSON.stringify(options.body) : undefined,
})
if (response.status === 401) {
return {
response,
parsed: { code: 401, msg: 'unauthorized', data: {} as T },
}
}
const parsed = await parseEnvelope<T>(response)
return { response, parsed }
}
export async function authedRequest<T>(options: AuthedRequestOptions): Promise<T> {
const first = await runRequest<T>({ ...options, session: options.auth })
if (first.response.status !== 401) {
return first.parsed.data
}
if (!options.auth.refreshToken) {
throw new AuthExpiredError()
}
try {
const refreshed = await refreshAccessToken({
token: options.auth.token,
tokenType: options.auth.tokenType,
refreshToken: options.auth.refreshToken,
})
const nextAuth: AuthSession = {
token: refreshed.token,
tokenType: refreshed.tokenType ?? options.auth.tokenType,
refreshToken: refreshed.refreshToken ?? options.auth.refreshToken,
expiresIn: refreshed.expiresIn,
}
options.onAuthUpdated?.(nextAuth)
const second = await runRequest<T>({ ...options, session: nextAuth })
if (second.response.status === 401) {
throw new AuthExpiredError()
}
return second.parsed.data
} catch (error) {
if (error instanceof AuthExpiredError) {
throw error
}
throw new AuthExpiredError()
}
}