feat: 项目初始化
This commit is contained in:
41
src/constants/index.ts
Normal file
41
src/constants/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** @description 应用启动阶段使用的根节点常量。 */
|
||||
export const APP_ROOT_ELEMENT_ID = 'root'
|
||||
|
||||
/** @description 应用名称,用于文档标题和分享元信息。 */
|
||||
export const APP_NAME = 'React SPA Template'
|
||||
|
||||
/** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */
|
||||
export const APP_DEFAULT_DESCRIPTION =
|
||||
'A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome.'
|
||||
|
||||
/** @description 认证状态持久化到浏览器时使用的存储键。 */
|
||||
export const AUTH_STORAGE_KEY = 'auth-session'
|
||||
|
||||
/** @description 接口请求的默认超时时间,单位为毫秒。 */
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
/** @description 请求默认声明可接收的响应内容类型。 */
|
||||
export const DEFAULT_REQUEST_ACCEPT_HEADER = 'application/json'
|
||||
|
||||
/** @description 请求层统一使用的错误提示文案。 */
|
||||
export const API_ERROR_MESSAGES = {
|
||||
timeout: 'Request timed out',
|
||||
unexpected: 'Unexpected request error',
|
||||
} as const
|
||||
|
||||
/** @description TanStack Query 默认的缓存新鲜时间。 */
|
||||
export const QUERY_DEFAULT_STALE_TIME_MS = 30_000
|
||||
|
||||
/** @description TanStack Query 默认的缓存回收时间。 */
|
||||
export const QUERY_DEFAULT_GC_TIME_MS = 5 * 60_000
|
||||
|
||||
/** @description 查询请求默认允许的最大重试次数。 */
|
||||
export const QUERY_RETRY_LIMIT = 2
|
||||
|
||||
/** @description 可被视为瞬时失败并允许重试的状态码集合。 */
|
||||
export const QUERY_RETRYABLE_STATUS_CODES = [
|
||||
408, 429, 500, 502, 503, 504,
|
||||
] as const
|
||||
|
||||
/** @description 国际化语言设置持久化到浏览器时使用的存储键。 */
|
||||
export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
|
||||
112
src/i18n/index.ts
Normal file
112
src/i18n/index.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import { I18N_LANGUAGE_STORAGE_KEY } from '@/constants'
|
||||
import enUSCommon from '@/locales/en-US/common'
|
||||
import zhCNCommon from '@/locales/zh-CN/common'
|
||||
|
||||
export const supportedLanguages = ['zh-CN', 'en-US'] as const
|
||||
export type AppLanguage = (typeof supportedLanguages)[number]
|
||||
|
||||
const defaultLanguage: AppLanguage = 'zh-CN'
|
||||
|
||||
/** @description 判断给定语言是否在当前应用支持列表中。 */
|
||||
export function isSupportedLanguage(
|
||||
value: string | null | undefined,
|
||||
): value is AppLanguage {
|
||||
return supportedLanguages.includes(value as AppLanguage)
|
||||
}
|
||||
|
||||
/** @description 从浏览器设置中推断最匹配的语言。 */
|
||||
function detectBrowserLanguage() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return defaultLanguage
|
||||
}
|
||||
|
||||
const browserLanguages = [...navigator.languages, navigator.language]
|
||||
|
||||
for (const language of browserLanguages) {
|
||||
if (isSupportedLanguage(language)) {
|
||||
return language
|
||||
}
|
||||
|
||||
const normalizedLanguage = language.toLowerCase()
|
||||
|
||||
if (normalizedLanguage.startsWith('zh')) {
|
||||
return 'zh-CN'
|
||||
}
|
||||
|
||||
if (normalizedLanguage.startsWith('en')) {
|
||||
return 'en-US'
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLanguage
|
||||
}
|
||||
|
||||
/** @description 获取应用启动时应使用的初始语言。 */
|
||||
function getInitialLanguage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultLanguage
|
||||
}
|
||||
|
||||
const persistedLanguage = window.localStorage.getItem(
|
||||
I18N_LANGUAGE_STORAGE_KEY,
|
||||
)
|
||||
|
||||
if (isSupportedLanguage(persistedLanguage)) {
|
||||
return persistedLanguage
|
||||
}
|
||||
|
||||
return detectBrowserLanguage()
|
||||
}
|
||||
|
||||
/** @description 暴露当前应用应优先使用的语言。 */
|
||||
export function getPreferredLanguage() {
|
||||
return getInitialLanguage()
|
||||
}
|
||||
|
||||
/** @description 从路由路径中解析语言前缀。 */
|
||||
export function getLanguageFromPathname(pathname: string) {
|
||||
const [, firstSegment] = pathname.split('/')
|
||||
|
||||
if (isSupportedLanguage(firstSegment)) {
|
||||
return firstSegment
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: defaultLanguage,
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
resources: {
|
||||
'zh-CN': {
|
||||
common: zhCNCommon,
|
||||
},
|
||||
'en-US': {
|
||||
common: enUSCommon,
|
||||
},
|
||||
},
|
||||
defaultNS: 'common',
|
||||
})
|
||||
|
||||
/** @description 同步当前语言到 html 标签并持久化到浏览器。 */
|
||||
function syncLanguageState(language: string) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = language
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && isSupportedLanguage(language)) {
|
||||
window.localStorage.setItem(I18N_LANGUAGE_STORAGE_KEY, language)
|
||||
}
|
||||
}
|
||||
|
||||
syncLanguageState(i18n.resolvedLanguage ?? defaultLanguage)
|
||||
i18n.on('languageChanged', syncLanguageState)
|
||||
|
||||
export default i18n
|
||||
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()
|
||||
42
src/locales/en-US/common.ts
Normal file
42
src/locales/en-US/common.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: 'Home',
|
||||
},
|
||||
shell: {
|
||||
eyebrow: 'React SPA scaffold',
|
||||
subtitle: 'TanStack Router + Query + Auth + i18n',
|
||||
},
|
||||
notFound: {
|
||||
eyebrow: '404',
|
||||
title: 'The page you requested could not be found.',
|
||||
description: 'This route does not exist. Return to the scaffold home page.',
|
||||
home: 'Back home',
|
||||
},
|
||||
home: {
|
||||
eyebrow: 'Blank scaffold ready',
|
||||
title: 'Start building your frontend project from here.',
|
||||
description:
|
||||
'The template already includes routing, data fetching, session state, head metadata, and i18n. After removing the examples, this page becomes your clean starting point.',
|
||||
cards: {
|
||||
routingMode: 'Routing mode',
|
||||
dataLayer: 'Data layer',
|
||||
transport: 'Transport',
|
||||
auth: 'Auth layer',
|
||||
metadata: 'Page metadata',
|
||||
},
|
||||
values: {
|
||||
routingMode: 'SPA + file routes',
|
||||
dataLayer: 'TanStack Query',
|
||||
transport: 'ky',
|
||||
auth: 'Zustand session scaffold',
|
||||
metadata: 'Dynamic title / meta',
|
||||
},
|
||||
footnote:
|
||||
'A practical place to start is replacing src/routes/$lang/index.tsx, src/lib/api/api-client.ts, and src/store/auth-store.ts with your project-specific structure.',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
},
|
||||
} as const
|
||||
42
src/locales/zh-CN/common.ts
Normal file
42
src/locales/zh-CN/common.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
nav: {
|
||||
home: '首页',
|
||||
},
|
||||
shell: {
|
||||
eyebrow: 'React SPA 脚手架',
|
||||
subtitle: 'TanStack Router + Query + Auth + i18n',
|
||||
},
|
||||
notFound: {
|
||||
eyebrow: '404',
|
||||
title: '找不到你访问的页面。',
|
||||
description: '当前路由不存在,你可以返回脚手架首页继续开始开发。',
|
||||
home: '返回首页',
|
||||
},
|
||||
home: {
|
||||
eyebrow: '空白脚手架已就绪',
|
||||
title: '从这里开始搭建你的前端项目。',
|
||||
description:
|
||||
'模板已经接好路由、请求层、会话状态、head metadata 和多语言。删除示例后,这一页就是你的干净起点。',
|
||||
cards: {
|
||||
routingMode: '路由模式',
|
||||
dataLayer: '数据层',
|
||||
transport: '请求层',
|
||||
auth: '认证层',
|
||||
metadata: '页面元信息',
|
||||
},
|
||||
values: {
|
||||
routingMode: 'SPA + 文件路由',
|
||||
dataLayer: 'TanStack Query',
|
||||
transport: 'ky',
|
||||
auth: 'Zustand 会话基座',
|
||||
metadata: '动态 title / meta',
|
||||
},
|
||||
footnote:
|
||||
'建议先从 src/routes/$lang/index.tsx、src/lib/api/api-client.ts 和 src/store/auth-store.ts 开始替换成你的项目结构。',
|
||||
},
|
||||
language: {
|
||||
label: '语言',
|
||||
zhCN: '中文',
|
||||
enUS: 'English',
|
||||
},
|
||||
} as const
|
||||
31
src/main.tsx
Normal file
31
src/main.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
||||
import '@/i18n'
|
||||
import { initializeAuthSession } from '@/lib/auth/auth-session'
|
||||
import { queryClient } from '@/lib/query/query-client'
|
||||
import { router } from '@/router'
|
||||
import './styles.css'
|
||||
|
||||
const rootElement = document.getElementById(APP_ROOT_ELEMENT_ID)
|
||||
const shouldShowQueryDevtools =
|
||||
import.meta.env.VITE_APP_ENV === 'development' &&
|
||||
import.meta.env.VITE_ENABLE_QUERY_DEVTOOLS === 'true'
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
void initializeAuthSession()
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
{shouldShowQueryDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
104
src/routeTree.gen.ts
Normal file
104
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LangRouteRouteImport } from './routes/$lang/route'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as LangIndexRouteImport } from './routes/$lang/index'
|
||||
|
||||
const LangRouteRoute = LangRouteRouteImport.update({
|
||||
id: '/$lang',
|
||||
path: '/$lang',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LangIndexRoute = LangIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => LangRouteRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/$lang': typeof LangRouteRouteWithChildren
|
||||
'/$lang/': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/$lang': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/$lang': typeof LangRouteRouteWithChildren
|
||||
'/$lang/': typeof LangIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/$lang' | '/$lang/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/$lang'
|
||||
id: '__root__' | '/' | '/$lang' | '/$lang/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LangRouteRoute: typeof LangRouteRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/$lang': {
|
||||
id: '/$lang'
|
||||
path: '/$lang'
|
||||
fullPath: '/$lang'
|
||||
preLoaderRoute: typeof LangRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$lang/': {
|
||||
id: '/$lang/'
|
||||
path: '/'
|
||||
fullPath: '/$lang/'
|
||||
preLoaderRoute: typeof LangIndexRouteImport
|
||||
parentRoute: typeof LangRouteRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface LangRouteRouteChildren {
|
||||
LangIndexRoute: typeof LangIndexRoute
|
||||
}
|
||||
|
||||
const LangRouteRouteChildren: LangRouteRouteChildren = {
|
||||
LangIndexRoute: LangIndexRoute,
|
||||
}
|
||||
|
||||
const LangRouteRouteWithChildren = LangRouteRoute._addFileChildren(
|
||||
LangRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LangRouteRoute: LangRouteRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
14
src/router.tsx
Normal file
14
src/router.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
19
src/routes/$lang/index.tsx
Normal file
19
src/routes/$lang/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
|
||||
export const Route = createFileRoute('/$lang/')({
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('home.title'),
|
||||
description: t('home.description'),
|
||||
})
|
||||
|
||||
return <div>111</div>
|
||||
}
|
||||
46
src/routes/$lang/route.tsx
Normal file
46
src/routes/$lang/route.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
||||
|
||||
export const Route = createFileRoute('/$lang')({
|
||||
component: LanguageLayout,
|
||||
})
|
||||
|
||||
function LanguageLayout() {
|
||||
const { lang } = Route.useParams()
|
||||
const { i18n } = useTranslation()
|
||||
const preferredLanguage = getPreferredLanguage()
|
||||
const isValidLanguage = isSupportedLanguage(lang)
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidLanguage && i18n.resolvedLanguage !== lang) {
|
||||
void i18n.changeLanguage(lang)
|
||||
}
|
||||
}, [i18n, isValidLanguage, lang])
|
||||
|
||||
if (!isValidLanguage) {
|
||||
return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace />
|
||||
}
|
||||
|
||||
// function changeLanguage(nextLanguage: AppLanguage) {
|
||||
// const nextPathname = location.pathname.replace(
|
||||
// /^\/(zh-CN|en-US)(?=\/|$)/,
|
||||
// `/${nextLanguage}`,
|
||||
// )
|
||||
// void i18n.changeLanguage(nextLanguage)
|
||||
// void navigate({
|
||||
// to: nextPathname,
|
||||
// search: location.search,
|
||||
// hash: location.hash,
|
||||
// replace: true,
|
||||
// })
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
64
src/routes/__root.tsx
Normal file
64
src/routes/__root.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
Link,
|
||||
Outlet,
|
||||
useLocation,
|
||||
} from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { getLanguageFromPathname, getPreferredLanguage } from '@/i18n'
|
||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
||||
|
||||
function NotFoundPage() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const routeLanguage =
|
||||
getLanguageFromPathname(location.pathname) ??
|
||||
(i18n.resolvedLanguage || getPreferredLanguage())
|
||||
const homePath = `/${routeLanguage}`
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getLanguageFromPathname(location.pathname) &&
|
||||
i18n.resolvedLanguage !== routeLanguage
|
||||
) {
|
||||
void i18n.changeLanguage(routeLanguage)
|
||||
}
|
||||
}, [i18n, location.pathname, routeLanguage])
|
||||
|
||||
useDocumentMetadata({
|
||||
title: t('notFound.title'),
|
||||
description: t('notFound.description'),
|
||||
robots: 'noindex,nofollow',
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="flex flex-1 flex-col justify-center gap-6 px-6 py-14 md:px-10">
|
||||
<span className="inline-flex w-fit rounded-full border border-rose-400/25 bg-rose-400/10 px-3 py-1 text-xs font-semibold tracking-[0.22em] text-rose-200 uppercase">
|
||||
{t('notFound.eyebrow')}
|
||||
</span>
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-white md:text-5xl">
|
||||
{t('notFound.title')}
|
||||
</h1>
|
||||
<p className="text-base leading-7 text-neutral-300 md:text-lg">
|
||||
{t('notFound.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to={homePath}
|
||||
className="inline-flex min-h-11 items-center rounded-full border border-rose-300/40 bg-rose-300/12 px-5 text-sm font-medium text-rose-100 transition hover:border-rose-200/60 hover:bg-rose-300/18"
|
||||
>
|
||||
{t('notFound.home')}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Outlet,
|
||||
notFoundComponent: NotFoundPage,
|
||||
})
|
||||
13
src/routes/index.tsx
Normal file
13
src/routes/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
|
||||
import { getPreferredLanguage } from '@/i18n'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: RootRedirectPage,
|
||||
})
|
||||
|
||||
function RootRedirectPage() {
|
||||
return (
|
||||
<Navigate to="/$lang" params={{ lang: getPreferredLanguage() }} replace />
|
||||
)
|
||||
}
|
||||
143
src/store/auth-store.ts
Normal file
143
src/store/auth-store.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
import { AUTH_STORAGE_KEY } from '@/constants'
|
||||
|
||||
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
||||
|
||||
export interface AuthUser {
|
||||
email?: string
|
||||
id: string
|
||||
name?: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
accessToken: string
|
||||
currentUser?: AuthUser | null
|
||||
refreshToken?: string | null
|
||||
}
|
||||
|
||||
interface PersistedAuthState {
|
||||
accessToken: string | null
|
||||
currentUser: AuthUser | null
|
||||
refreshToken: string | null
|
||||
}
|
||||
|
||||
interface AuthState extends PersistedAuthState {
|
||||
clearAccessToken: () => void
|
||||
clearSession: () => void
|
||||
finishHydration: () => void
|
||||
isHydrated: boolean
|
||||
lastUnauthorizedAt: string | null
|
||||
markUnauthorized: () => void
|
||||
setAccessToken: (token: string) => void
|
||||
setCurrentUser: (user: AuthUser | null) => void
|
||||
startSession: (session: AuthSessionInput) => void
|
||||
status: AuthStatus
|
||||
updateTokens: (tokens: {
|
||||
accessToken: string
|
||||
refreshToken?: string | null
|
||||
}) => void
|
||||
}
|
||||
|
||||
function resolveAuthStatus(accessToken: string | null): AuthStatus {
|
||||
return accessToken ? 'authenticated' : 'anonymous'
|
||||
}
|
||||
|
||||
const initialPersistedState: PersistedAuthState = {
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
currentUser: null,
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialPersistedState,
|
||||
status: 'restoring',
|
||||
isHydrated: false,
|
||||
lastUnauthorizedAt: null,
|
||||
setAccessToken: (token) => {
|
||||
set({
|
||||
accessToken: token,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
})
|
||||
},
|
||||
setCurrentUser: (currentUser) => {
|
||||
set((state) => ({
|
||||
currentUser,
|
||||
status: resolveAuthStatus(state.accessToken),
|
||||
}))
|
||||
},
|
||||
startSession: ({
|
||||
accessToken,
|
||||
currentUser = null,
|
||||
refreshToken = null,
|
||||
}) => {
|
||||
set({
|
||||
accessToken,
|
||||
currentUser,
|
||||
refreshToken,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
})
|
||||
},
|
||||
updateTokens: ({ accessToken, refreshToken }) => {
|
||||
set((state) => ({
|
||||
accessToken,
|
||||
refreshToken: refreshToken ?? state.refreshToken,
|
||||
status: 'authenticated',
|
||||
isHydrated: true,
|
||||
}))
|
||||
},
|
||||
finishHydration: () => {
|
||||
set((state) => ({
|
||||
isHydrated: true,
|
||||
status: resolveAuthStatus(state.accessToken),
|
||||
}))
|
||||
},
|
||||
clearAccessToken: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
status: 'anonymous',
|
||||
isHydrated: true,
|
||||
})
|
||||
},
|
||||
clearSession: () => {
|
||||
set({
|
||||
...initialPersistedState,
|
||||
status: 'anonymous',
|
||||
isHydrated: true,
|
||||
})
|
||||
},
|
||||
markUnauthorized: () => {
|
||||
set({
|
||||
...initialPersistedState,
|
||||
status: 'anonymous',
|
||||
isHydrated: true,
|
||||
lastUnauthorizedAt: new Date().toISOString(),
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
currentUser: state.currentUser,
|
||||
refreshToken: state.refreshToken,
|
||||
}),
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error) {
|
||||
state?.clearSession()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
state?.finishHydration()
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
22
src/styles.css
Normal file
22
src/styles.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 h-full w-full;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
}
|
||||
12
src/vite-env.d.ts
vendored
Normal file
12
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user