feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
367
frontend/admin/src/lib/http/client.ts
Normal file
367
frontend/admin/src/lib/http/client.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { config } from '@/lib/config'
|
||||
import { AppError, ErrorType } from '@/lib/errors'
|
||||
import type { ApiResponse, RequestOptions } from '@/types'
|
||||
import {
|
||||
clearRefreshPromise,
|
||||
clearSession,
|
||||
endRefreshing,
|
||||
getAccessToken,
|
||||
getRefreshPromise,
|
||||
isAccessTokenExpired,
|
||||
isRefreshing,
|
||||
setAccessToken,
|
||||
setRefreshPromise,
|
||||
startRefreshing,
|
||||
} from './auth-session'
|
||||
import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage'
|
||||
import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
|
||||
import type { TokenBundle } from '@/types'
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
function isFormDataBody(body: unknown): body is FormData {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData
|
||||
}
|
||||
|
||||
function serializeBody(body: unknown): BodyInit | undefined {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isFormDataBody(body)) {
|
||||
return body
|
||||
}
|
||||
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
function resolveApiBaseUrl(): URL {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
||||
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
|
||||
? config.apiBaseUrl
|
||||
: config.apiBaseUrl.startsWith('/')
|
||||
? config.apiBaseUrl
|
||||
: `/${config.apiBaseUrl}`
|
||||
|
||||
const baseUrl = new URL(rawBaseUrl, origin)
|
||||
if (!baseUrl.pathname.endsWith('/')) {
|
||||
baseUrl.pathname = `${baseUrl.pathname}/`
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
|
||||
const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl())
|
||||
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
url.searchParams.append(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function cleanupSessionOnAuthFailure(): never {
|
||||
clearRefreshToken()
|
||||
clearSession()
|
||||
throw AppError.auth('会话已过期,请重新登录')
|
||||
}
|
||||
|
||||
function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => window.clearTimeout(timeoutId),
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<ApiResponse<T>> {
|
||||
return response.json() as Promise<ApiResponse<T>>
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<TokenBundle> {
|
||||
const refreshToken = getRefreshToken()
|
||||
const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined
|
||||
|
||||
const response = await fetch(buildUrl('/auth/refresh'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return cleanupSessionOnAuthFailure()
|
||||
}
|
||||
|
||||
const result = await parseJsonResponse<TokenBundle>(response)
|
||||
if (result.code !== 0) {
|
||||
return cleanupSessionOnAuthFailure()
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function performRefresh(): Promise<string> {
|
||||
if (isRefreshing()) {
|
||||
const promise = getRefreshPromise()
|
||||
if (promise) {
|
||||
await promise
|
||||
}
|
||||
|
||||
const token = getAccessToken()
|
||||
if (!token) {
|
||||
return cleanupSessionOnAuthFailure()
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
startRefreshing()
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const tokenBundle = await refreshAccessToken()
|
||||
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
|
||||
setRefreshToken(tokenBundle.refresh_token)
|
||||
return tokenBundle.access_token
|
||||
} finally {
|
||||
endRefreshing()
|
||||
clearRefreshPromise()
|
||||
}
|
||||
})()
|
||||
|
||||
setRefreshPromise(
|
||||
promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {
|
||||
if (!auth) {
|
||||
return null
|
||||
}
|
||||
|
||||
let token = getAccessToken()
|
||||
if (isRefreshing()) {
|
||||
const promise = getRefreshPromise()
|
||||
if (promise) {
|
||||
await promise
|
||||
token = getAccessToken()
|
||||
}
|
||||
}
|
||||
|
||||
if (token && isAccessTokenExpired()) {
|
||||
token = await performRefresh()
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const {
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
body,
|
||||
params,
|
||||
auth = true,
|
||||
credentials = 'include',
|
||||
signal,
|
||||
} = options
|
||||
|
||||
const url = buildUrl(path, params)
|
||||
const requestHeaders: Record<string, string> = { ...headers }
|
||||
|
||||
if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) {
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (CSRF_PROTECTED_METHODS.includes(method)) {
|
||||
Object.assign(requestHeaders, getCSRFHeaders())
|
||||
}
|
||||
|
||||
const authToken = await resolveAuthorizationHeader(auth)
|
||||
if (authToken) {
|
||||
requestHeaders.Authorization = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
const timeout = createTimeoutSignal(signal)
|
||||
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: serializeBody(body),
|
||||
credentials,
|
||||
signal: timeout.signal,
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth) {
|
||||
const refreshedToken = await performRefresh()
|
||||
requestHeaders.Authorization = `Bearer ${refreshedToken}`
|
||||
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: serializeBody(body),
|
||||
credentials,
|
||||
signal: timeout.signal,
|
||||
})
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return cleanupSessionOnAuthFailure()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) {
|
||||
throw AppError.forbidden()
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new AppError(404, '请求的资源不存在', {
|
||||
status: 404,
|
||||
type: ErrorType.NOT_FOUND,
|
||||
})
|
||||
}
|
||||
throw AppError.network(`请求失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await parseJsonResponse<T>(response)
|
||||
if (result.code !== 0) {
|
||||
throw AppError.fromResponse(result, response.status)
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw AppError.network('请求超时,请稍后重试')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
timeout.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export function get<T>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
options?: Omit<RequestOptions, 'method' | 'params' | 'body'>,
|
||||
): Promise<T> {
|
||||
return request<T>(path, { ...options, method: 'GET', params })
|
||||
}
|
||||
|
||||
export function post<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>,
|
||||
): Promise<T> {
|
||||
return request<T>(path, { ...options, method: 'POST', body })
|
||||
}
|
||||
|
||||
export function put<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>,
|
||||
): Promise<T> {
|
||||
return request<T>(path, { ...options, method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function del<T>(
|
||||
path: string,
|
||||
options?: Omit<RequestOptions, 'method'>,
|
||||
): Promise<T> {
|
||||
return request<T>(path, { ...options, method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function resolveAuthorizedHeaders(options?: Omit<RequestOptions, 'method' | 'params' | 'body'>): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { ...(options?.headers ?? {}) }
|
||||
|
||||
if (options?.auth !== false) {
|
||||
const token = await resolveAuthorizationHeader(true)
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function download(
|
||||
path: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
options?: Omit<RequestOptions, 'method' | 'params'>,
|
||||
): Promise<Blob> {
|
||||
const url = buildUrl(path, params)
|
||||
const headers = await resolveAuthorizedHeaders(options)
|
||||
const timeout = createTimeoutSignal(options?.signal)
|
||||
|
||||
try {
|
||||
let response = await fetch(url, {
|
||||
headers,
|
||||
credentials: options?.credentials ?? 'include',
|
||||
signal: timeout.signal,
|
||||
})
|
||||
|
||||
if (response.status === 401 && options?.auth !== false) {
|
||||
const refreshedToken = await performRefresh()
|
||||
headers.Authorization = `Bearer ${refreshedToken}`
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
credentials: options?.credentials ?? 'include',
|
||||
signal: timeout.signal,
|
||||
})
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return cleanupSessionOnAuthFailure()
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw AppError.network(`下载失败: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw AppError.network('下载超时,请稍后重试')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
timeout.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export async function upload<T>(
|
||||
path: string,
|
||||
file: File,
|
||||
fieldName: string = 'file',
|
||||
additionalData?: Record<string, string>,
|
||||
options?: Omit<RequestOptions, 'method' | 'body'>,
|
||||
): Promise<T> {
|
||||
const formData = new FormData()
|
||||
formData.append(fieldName, file)
|
||||
|
||||
if (additionalData) {
|
||||
for (const [key, value] of Object.entries(additionalData)) {
|
||||
formData.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return request<T>(path, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export { request }
|
||||
Reference in New Issue
Block a user