feat: 项目初始化
This commit is contained in:
212
src/lib/api/api-client.ts
Normal file
212
src/lib/api/api-client.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import ky, { HTTPError, type Options, TimeoutError } from 'ky'
|
||||
import {
|
||||
API_ERROR_MESSAGES,
|
||||
DEFAULT_REQUEST_ACCEPT_HEADER,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} from '@/constants'
|
||||
import {
|
||||
handleUnauthorizedSession,
|
||||
tryRefreshAuthSession,
|
||||
} from '@/lib/auth/auth-session'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
import { ApiError } from './api-error'
|
||||
import type { ApiResponse } from './types'
|
||||
|
||||
type RequestOptions = Omit<Options, 'json'>
|
||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||
json?: TBody
|
||||
}
|
||||
|
||||
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
|
||||
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
|
||||
const appEnv = import.meta.env.VITE_APP_ENV
|
||||
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||
|
||||
function normalizeApiBaseUrl(baseUrl: string | undefined) {
|
||||
const candidate = baseUrl?.trim()
|
||||
|
||||
if (!candidate) {
|
||||
throw new Error('VITE_API_BASE_URL 未配置')
|
||||
}
|
||||
|
||||
if (/^https?:\/\//.test(candidate)) {
|
||||
return candidate.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
return candidate.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response) {
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return response.text()
|
||||
}
|
||||
|
||||
function getErrorMessage(response: Response, data: unknown) {
|
||||
if (data && typeof data === 'object') {
|
||||
const message =
|
||||
'message' in data ? data.message : 'msg' in data ? data.msg : null
|
||||
|
||||
if (typeof message === 'string' && message.length > 0) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
return `Request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
async function toApiError(error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
const data = error.data
|
||||
|
||||
return new ApiError({
|
||||
message: getErrorMessage(error.response, data),
|
||||
status: error.response.status,
|
||||
data,
|
||||
url: error.response.url,
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof TimeoutError) {
|
||||
return new ApiError({
|
||||
message: API_ERROR_MESSAGES.timeout,
|
||||
status: 408,
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ApiError({
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
return new ApiError({
|
||||
message: API_ERROR_MESSAGES.unexpected,
|
||||
})
|
||||
}
|
||||
|
||||
export const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
|
||||
|
||||
const apiClient = ky.create({
|
||||
prefix: apiBaseUrl,
|
||||
retry: 0,
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
({ request }) => {
|
||||
request.headers.set('Accept', DEFAULT_REQUEST_ACCEPT_HEADER)
|
||||
|
||||
const token = useAuthStore.getState().accessToken
|
||||
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
|
||||
if (shouldLogRequests) {
|
||||
console.info(`[api:${appEnv}] ${request.method} ${request.url}`)
|
||||
}
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
({ request, response }) => {
|
||||
if (shouldLogRequests) {
|
||||
console.info(
|
||||
`[api:${appEnv}] ${request.method} ${response.url} -> ${response.status}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
async function request<TResponse>(input: string, options?: Options) {
|
||||
try {
|
||||
const response = await apiClient(input, options)
|
||||
const data = await parseResponseBody(response)
|
||||
|
||||
return data as TResponse
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HTTPError &&
|
||||
error.response.status === 401 &&
|
||||
options?.context?.[AUTH_SKIP_REFRESH_CONTEXT_KEY] !== true &&
|
||||
options?.context?.[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY] !== true
|
||||
) {
|
||||
const refreshed = await tryRefreshAuthSession()
|
||||
|
||||
if (refreshed) {
|
||||
return request<TResponse>(input, {
|
||||
...options,
|
||||
context: {
|
||||
...options?.context,
|
||||
[AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof HTTPError && error.response.status === 401) {
|
||||
handleUnauthorizedSession()
|
||||
}
|
||||
|
||||
throw await toApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRest<TData>(input: string, options?: Options) {
|
||||
return request<ApiResponse<TData>>(input, options)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get<TData>(input: string, options?: RequestOptions) {
|
||||
return requestRest<TData>(input, {
|
||||
...options,
|
||||
method: 'get',
|
||||
})
|
||||
},
|
||||
post<TData, TBody = unknown>(
|
||||
input: string,
|
||||
{ json, ...options }: JsonRequestOptions<TBody> = {},
|
||||
) {
|
||||
return requestRest<TData>(input, {
|
||||
...options,
|
||||
json,
|
||||
method: 'post',
|
||||
})
|
||||
},
|
||||
put<TData, TBody = unknown>(
|
||||
input: string,
|
||||
{ json, ...options }: JsonRequestOptions<TBody> = {},
|
||||
) {
|
||||
return requestRest<TData>(input, {
|
||||
...options,
|
||||
json,
|
||||
method: 'put',
|
||||
})
|
||||
},
|
||||
patch<TData, TBody = unknown>(
|
||||
input: string,
|
||||
{ json, ...options }: JsonRequestOptions<TBody> = {},
|
||||
) {
|
||||
return requestRest<TData>(input, {
|
||||
...options,
|
||||
json,
|
||||
method: 'patch',
|
||||
})
|
||||
},
|
||||
delete<TData>(input: string, options?: RequestOptions) {
|
||||
return requestRest<TData>(input, {
|
||||
...options,
|
||||
method: 'delete',
|
||||
})
|
||||
},
|
||||
}
|
||||
20
src/lib/api/api-error.ts
Normal file
20
src/lib/api/api-error.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface ApiErrorOptions {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
url?: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number | null
|
||||
data: unknown
|
||||
url: string | null
|
||||
|
||||
constructor({ message, status, data, url }: ApiErrorOptions) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status ?? null
|
||||
this.data = data ?? null
|
||||
this.url = url ?? null
|
||||
}
|
||||
}
|
||||
6
src/lib/api/types.ts
Normal file
6
src/lib/api/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @description 后端统一响应体结构。 */
|
||||
export interface ApiResponse<T> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
104
src/lib/auth/auth-session.ts
Normal file
104
src/lib/auth/auth-session.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { AuthSessionInput, AuthUser } from '@/store/auth-store'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
||||
export type RefreshSessionHandler = (
|
||||
refreshToken: string,
|
||||
) => Promise<AuthSessionInput | null>
|
||||
|
||||
let currentUserInitializer: CurrentUserInitializer | null = null
|
||||
let refreshSessionHandler: RefreshSessionHandler | null = null
|
||||
let authInitializationPromise: Promise<void> | null = null
|
||||
let refreshSessionPromise: Promise<boolean> | null = null
|
||||
|
||||
export function registerCurrentUserInitializer(
|
||||
initializer: CurrentUserInitializer | null,
|
||||
) {
|
||||
currentUserInitializer = initializer
|
||||
}
|
||||
|
||||
export function registerRefreshSessionHandler(
|
||||
handler: RefreshSessionHandler | null,
|
||||
) {
|
||||
refreshSessionHandler = handler
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
const snapshot = useAuthStore.getState()
|
||||
|
||||
return snapshot.status === 'authenticated' && Boolean(snapshot.accessToken)
|
||||
}
|
||||
|
||||
export function handleUnauthorizedSession() {
|
||||
useAuthStore.getState().markUnauthorized()
|
||||
}
|
||||
|
||||
export async function initializeAuthSession() {
|
||||
if (authInitializationPromise) {
|
||||
return authInitializationPromise
|
||||
}
|
||||
|
||||
authInitializationPromise = (async () => {
|
||||
await useAuthStore.persist.rehydrate()
|
||||
|
||||
const snapshot = useAuthStore.getState()
|
||||
|
||||
if (
|
||||
!snapshot.accessToken ||
|
||||
snapshot.currentUser ||
|
||||
!currentUserInitializer
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentUser = await currentUserInitializer()
|
||||
|
||||
useAuthStore.getState().setCurrentUser(currentUser)
|
||||
})().finally(() => {
|
||||
authInitializationPromise = null
|
||||
})
|
||||
|
||||
return authInitializationPromise
|
||||
}
|
||||
|
||||
export async function tryRefreshAuthSession() {
|
||||
if (refreshSessionPromise) {
|
||||
return refreshSessionPromise
|
||||
}
|
||||
|
||||
const snapshot = useAuthStore.getState()
|
||||
|
||||
if (!snapshot.refreshToken || !refreshSessionHandler) {
|
||||
return false
|
||||
}
|
||||
|
||||
const refreshToken = snapshot.refreshToken
|
||||
|
||||
refreshSessionPromise = (async () => {
|
||||
try {
|
||||
const nextSession = await refreshSessionHandler(refreshToken)
|
||||
|
||||
if (!nextSession?.accessToken) {
|
||||
handleUnauthorizedSession()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
useAuthStore.getState().startSession({
|
||||
accessToken: nextSession.accessToken,
|
||||
currentUser: nextSession.currentUser ?? snapshot.currentUser,
|
||||
refreshToken: nextSession.refreshToken ?? snapshot.refreshToken,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
handleUnauthorizedSession()
|
||||
|
||||
return false
|
||||
} finally {
|
||||
refreshSessionPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return refreshSessionPromise
|
||||
}
|
||||
29
src/lib/auth/require-auth.ts
Normal file
29
src/lib/auth/require-auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from '@tanstack/react-router'
|
||||
|
||||
import type { AppLanguage } from '@/i18n'
|
||||
import { getPreferredLanguage } from '@/i18n'
|
||||
import { useAuthStore } from '@/store/auth-store'
|
||||
|
||||
import { initializeAuthSession, isAuthenticated } from './auth-session'
|
||||
|
||||
interface RequireAuthenticatedSessionOptions {
|
||||
fallbackLanguage?: AppLanguage
|
||||
}
|
||||
|
||||
export async function requireAuthenticatedSession(
|
||||
options: RequireAuthenticatedSessionOptions = {},
|
||||
) {
|
||||
await initializeAuthSession()
|
||||
|
||||
if (isAuthenticated()) {
|
||||
return useAuthStore.getState()
|
||||
}
|
||||
|
||||
throw redirect({
|
||||
to: '/$lang',
|
||||
params: {
|
||||
lang: options.fallbackLanguage ?? getPreferredLanguage(),
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
95
src/lib/head/document-metadata.ts
Normal file
95
src/lib/head/document-metadata.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { APP_DEFAULT_DESCRIPTION, APP_NAME } from '@/constants'
|
||||
|
||||
interface DocumentMetadata {
|
||||
description?: string
|
||||
robots?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
function upsertMetaTag(
|
||||
selector: string,
|
||||
attributes: Record<string, string>,
|
||||
content: string,
|
||||
) {
|
||||
let tag = document.head.querySelector<HTMLMetaElement>(selector)
|
||||
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta')
|
||||
|
||||
for (const [attribute, value] of Object.entries(attributes)) {
|
||||
tag.setAttribute(attribute, value)
|
||||
}
|
||||
|
||||
document.head.append(tag)
|
||||
}
|
||||
|
||||
tag.setAttribute('content', content)
|
||||
}
|
||||
|
||||
export function buildDocumentTitle(title?: string) {
|
||||
if (!title) {
|
||||
return APP_NAME
|
||||
}
|
||||
|
||||
return `${title} | ${APP_NAME}`
|
||||
}
|
||||
|
||||
export function applyDocumentMetadata({
|
||||
description = APP_DEFAULT_DESCRIPTION,
|
||||
robots = 'index,follow',
|
||||
title,
|
||||
}: DocumentMetadata) {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedTitle = buildDocumentTitle(title)
|
||||
|
||||
document.title = resolvedTitle
|
||||
|
||||
upsertMetaTag(
|
||||
'meta[name="description"]',
|
||||
{ name: 'description' },
|
||||
description,
|
||||
)
|
||||
upsertMetaTag('meta[name="robots"]', { name: 'robots' }, robots)
|
||||
upsertMetaTag(
|
||||
'meta[property="og:title"]',
|
||||
{ property: 'og:title' },
|
||||
resolvedTitle,
|
||||
)
|
||||
upsertMetaTag(
|
||||
'meta[property="og:description"]',
|
||||
{ property: 'og:description' },
|
||||
description,
|
||||
)
|
||||
upsertMetaTag(
|
||||
'meta[property="og:site_name"]',
|
||||
{ property: 'og:site_name' },
|
||||
APP_NAME,
|
||||
)
|
||||
upsertMetaTag(
|
||||
'meta[name="twitter:title"]',
|
||||
{ name: 'twitter:title' },
|
||||
resolvedTitle,
|
||||
)
|
||||
upsertMetaTag(
|
||||
'meta[name="twitter:description"]',
|
||||
{ name: 'twitter:description' },
|
||||
description,
|
||||
)
|
||||
}
|
||||
|
||||
export function useDocumentMetadata(metadata: DocumentMetadata) {
|
||||
const { description, robots, title } = metadata
|
||||
|
||||
useEffect(() => {
|
||||
applyDocumentMetadata({
|
||||
description,
|
||||
robots,
|
||||
title,
|
||||
})
|
||||
}, [description, robots, title])
|
||||
}
|
||||
43
src/lib/query/query-client.ts
Normal file
43
src/lib/query/query-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
QUERY_DEFAULT_GC_TIME_MS,
|
||||
QUERY_DEFAULT_STALE_TIME_MS,
|
||||
QUERY_RETRY_LIMIT,
|
||||
QUERY_RETRYABLE_STATUS_CODES,
|
||||
} from '@/constants'
|
||||
|
||||
import { ApiError } from '../api/api-error'
|
||||
|
||||
const retryableStatusCodes = new Set<number>(QUERY_RETRYABLE_STATUS_CODES)
|
||||
|
||||
function shouldRetryRequest(error: unknown) {
|
||||
if (!(error instanceof ApiError)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.status === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return retryableStatusCodes.has(error.status) || error.status >= 500
|
||||
}
|
||||
|
||||
export function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: QUERY_DEFAULT_STALE_TIME_MS,
|
||||
gcTime: QUERY_DEFAULT_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
retry(failureCount, error) {
|
||||
return failureCount < QUERY_RETRY_LIMIT && shouldRetryRequest(error)
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const queryClient = createQueryClient()
|
||||
Reference in New Issue
Block a user