feat: 项目初始化

This commit is contained in:
JiaJun
2026-04-23 16:41:39 +08:00
parent be4669a9f1
commit bd92f10b83
42 changed files with 6047 additions and 2 deletions

212
src/lib/api/api-client.ts Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
/** @description 后端统一响应体结构。 */
export interface ApiResponse<T> {
code: number
msg: string
data: T
}

View 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
}

View 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,
})
}

View 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])
}

View 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()