- 添加WebSocket URL构建逻辑和认证令牌刷新功能 - 实现游戏状态显示包括阶段、网络状态、时钟等信息 - 添加游戏桌面背景图片和玩家座位装饰组件 - 重构CSS样式为网格布局提升响应式体验 - 配置环境变量支持API和WebSocket代理目标设置 - 优化WebSocket连接管理增加错误处理机制 - 添加游戏桌墙体和中心计数器等UI元素 - 修复多处字符串国际化和路径处理问题
156 lines
4.1 KiB
TypeScript
156 lines
4.1 KiB
TypeScript
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
|
|
}
|
|
|
|
if (API_BASE_URL.startsWith('/')) {
|
|
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
|
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
|
return normalizedPath
|
|
}
|
|
|
|
return `${basePath}${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()
|
|
}
|
|
}
|