feat(admin): art-design-pro integration with local session auth

P1b Task 9: apps/admin based on art-design-pro v3.0.2 (tag, commit
d224dd4; source recorded in apps/admin/SOURCE.md), trimmed to
layout/login/i18n/request/table-form core. Login wired to real
POST /v1/auth/password (single-token session), Bearer header on all
requests, 401 interceptor logout+redirect, request-driven sliding
renewal (refresh at ~80% TTL, single-flight), best-effort
POST /v1/auth/logout on user-menu logout. One protected stub page
(相册管理). Dev port 5176 strictPort + /v1 proxy to 8080. Admin keeps
its own eslint9 flat config; root eslint ignores apps/admin/**.
vitest unit tests for renewal pacing + Playwright e2e (guard redirect,
localized wrong-password error, login lands on stub, token persists
across reload, logout clears session).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:47:32 +08:00
parent 90fe64f637
commit a6cc190751
273 changed files with 32565 additions and 48 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* HTTP 错误处理模块
*
* 提供统一的 HTTP 请求错误处理机制
*
* ## 主要功能
*
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
* - 错误日志记录,便于问题追踪和调试
* - 错误和成功消息的统一展示
* - 类型守卫函数,用于判断错误类型
*
* ## 使用场景
*
* - HTTP 请求拦截器中统一处理错误
* - 业务代码中捕获和处理特定错误
* - 错误日志收集和上报
*
* @module utils/http/error
* @author Art Design Pro Team
*/
import { AxiosError } from 'axios'
import { ApiStatus } from './status'
import { $t } from '@/locales'
// 错误响应接口(契约形状:{code, message, details?},code 为字符串错误码)
export interface ErrorResponse {
/** 契约错误码(如 AUTH_INVALID_CREDENTIALS) */
code: string
/** 错误消息(服务端英文兜底文案) */
message: string
/** 错误附加数据 */
details?: unknown
}
// 错误日志数据接口
export interface ErrorLogData {
/** 错误状态码 */
code: number
/** 错误消息 */
message: string
/** 错误附加数据 */
data?: unknown
/** 错误发生时间戳 */
timestamp: string
/** 请求 URL */
url?: string
/** 请求方法 */
method?: string
/** 错误堆栈信息 */
stack?: string
}
// 自定义 HttpError 类
export class HttpError extends Error {
public readonly code: number
public readonly data?: unknown
public readonly timestamp: string
public readonly url?: string
public readonly method?: string
constructor(
message: string,
code: number,
options?: {
data?: unknown
url?: string
method?: string
}
) {
super(message)
this.name = 'HttpError'
this.code = code
this.data = options?.data
this.timestamp = new Date().toISOString()
this.url = options?.url
this.method = options?.method
}
public toLogData(): ErrorLogData {
return {
code: this.code,
message: this.message,
data: this.data,
timestamp: this.timestamp,
url: this.url,
method: this.method,
stack: this.stack
}
}
}
/**
* 获取错误消息
* @param status 错误状态码
* @returns 错误消息
*/
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.unauthorized]: 'httpMsg.unauthorized',
[ApiStatus.forbidden]: 'httpMsg.forbidden',
[ApiStatus.notFound]: 'httpMsg.notFound',
[ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed',
[ApiStatus.requestTimeout]: 'httpMsg.requestTimeout',
[ApiStatus.internalServerError]: 'httpMsg.internalServerError',
[ApiStatus.badGateway]: 'httpMsg.badGateway',
[ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable',
[ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout'
}
return $t(errorMap[status] || 'httpMsg.internalServerError')
}
/** 契约错误码 → 本地化文案键(服务端 message 是英文兜底,优先本地化) */
const API_CODE_MSG_KEYS: Record<string, string> = {
AUTH_INVALID_CREDENTIALS: 'apiMsg.AUTH_INVALID_CREDENTIALS',
AUTH_TOKEN_EXPIRED: 'httpMsg.unauthorized',
AUTH_RATE_LIMITED: 'apiMsg.AUTH_RATE_LIMITED',
AUTH_FORBIDDEN: 'httpMsg.forbidden',
VALIDATION_FAILED: 'apiMsg.VALIDATION_FAILED',
INTERNAL_ERROR: 'httpMsg.internalServerError'
}
/**
* 契约错误体 → 展示文案:已知错误码取本地化,未知码回退服务端 message,
* 再回退 HTTP 状态码文案
*/
export function apiErrorMessage(body: unknown, status?: number): string {
const errorBody = body as Partial<ErrorResponse> | undefined
const code = errorBody?.code
if (code && API_CODE_MSG_KEYS[code]) {
return $t(API_CODE_MSG_KEYS[code])
}
if (errorBody?.message) return errorBody.message
return status ? getErrorMessage(status) : $t('httpMsg.requestFailed')
}
/**
* 处理错误
* @param error 错误对象
* @returns 错误对象
*/
export function handleError(error: AxiosError<ErrorResponse>): never {
// 处理取消的请求
if (error.code === 'ERR_CANCELED') {
console.warn('Request cancelled:', error.message)
throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error)
}
const statusCode = error.response?.status
const requestConfig = error.config
// 处理网络错误
if (!error.response) {
throw new HttpError($t('httpMsg.networkError'), ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
// 契约错误体优先(本地化),无错误码时按 HTTP 状态码兜底
const message = apiErrorMessage(error.response.data, statusCode)
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
/**
* 显示错误消息
* @param error 错误对象
* @param showMessage 是否显示错误消息
*/
export function showError(error: HttpError, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.error(error.message)
}
// 记录错误日志
console.error('[HTTP Error]', error.toLogData())
}
/**
* 显示成功消息
* @param message 成功消息
* @param showMessage 是否显示消息
*/
export function showSuccess(message: string, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.success(message)
}
}
/**
* 判断是否为 HttpError 类型
* @param error 错误对象
* @returns 是否为 HttpError 类型
*/
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError
}
+231
View File
@@ -0,0 +1,231 @@
/**
* HTTP 请求封装模块(@photo/admin 适配版)
*
* 在模板 Axios 封装基础上对齐 @photo/contracts 的 API 约定:
* - 成功响应无信封:HTTP 2xx 直接返回 JSON 体(logout 为 204 无体)
* - 错误响应体为 {code, message, details?},code 是契约错误码字符串
* - 认证头:Authorization: Bearer <sessionToken>(单令牌会话)
* - 401 语义区分:
* · 普通请求 401(AUTH_TOKEN_EXPIRED)→ 登出并跳转登录页(带防抖)
* · 登录请求 401(AUTH_INVALID_CREDENTIALS)→ 正常业务错误,展示本地化文案
* - 滑动续期:每次成功响应后调用 maybeRefreshSession()(请求驱动,~80% TTL)
*
* @module utils/http
*/
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ApiStatus } from './status'
import { HttpError, apiErrorMessage, handleError, showError, showSuccess } from './error'
import { $t } from '@/locales'
import { maybeRefreshSession } from '@/utils/auth/session'
/** 请求配置常量 */
const REQUEST_TIMEOUT = 15000
const LOGOUT_DELAY = 500
const MAX_RETRIES = 0
const RETRY_DELAY = 1000
const UNAUTHORIZED_DEBOUNCE_TIME = 3000
/** 401防抖状态 */
let isUnauthorizedErrorShown = false
let unauthorizedTimer: NodeJS.Timeout | null = null
/**
* 扩展 AxiosRequestConfig
* skipUnauthorizedLogout:登录等凭据类接口 401 时不走「会话过期登出」分支
*/
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
showErrorMessage?: boolean
showSuccessMessage?: boolean
/** 契约响应无 msg 字段,成功提示文案由使用方显式传入 */
successMessage?: string
skipUnauthorizedLogout?: boolean
}
const { VITE_WITH_CREDENTIALS } = import.meta.env
/** Axios实例(baseURL 缺省同源相对路径,dev 由 Vite 代理 /v1 → 8080) */
const axiosInstance = axios.create({
timeout: REQUEST_TIMEOUT,
withCredentials: VITE_WITH_CREDENTIALS === 'true',
validateStatus: (status) => status >= 200 && status < 300,
transformResponse: [
(data, headers) => {
const contentType = headers['content-type']
if (contentType?.includes('application/json')) {
try {
return JSON.parse(data)
} catch {
return data
}
}
return data
}
]
})
/** 请求拦截器:附加 Bearer 令牌 */
axiosInstance.interceptors.request.use(
(request: InternalAxiosRequestConfig) => {
const { sessionToken } = useUserStore()
if (sessionToken) request.headers.set('Authorization', `Bearer ${sessionToken}`)
if (request.data && !(request.data instanceof FormData) && !request.headers['Content-Type']) {
request.headers.set('Content-Type', 'application/json')
request.data = JSON.stringify(request.data)
}
return request
},
(error) => {
showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error))
return Promise.reject(error)
}
)
/** 响应拦截器 */
axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// 请求驱动的滑动续期:到期才真正发 refresh(内部单飞去并发)
maybeRefreshSession()
return response
},
(error) => {
const status = error.response?.status
const config = error.config as ExtendedAxiosRequestConfig | undefined
const body = error.response?.data
if (status === ApiStatus.unauthorized) {
// 登录等凭据接口的 401 是业务错误(账号或密码错误),不应触发登出跳转
if (config?.skipUnauthorizedLogout) {
return Promise.reject(
createHttpError(apiErrorMessage(body, status), ApiStatus.unauthorized)
)
}
handleUnauthorizedError()
}
return Promise.reject(handleError(error))
}
)
/** 统一创建HttpError */
function createHttpError(message: string, code: number) {
return new HttpError(message, code)
}
/** 处理401错误(带防抖) */
function handleUnauthorizedError(message?: string): never {
const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
if (!isUnauthorizedErrorShown) {
isUnauthorizedErrorShown = true
logOut()
unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME)
showError(error, true)
throw error
}
throw error
}
/** 重置401防抖状态 */
function resetUnauthorizedError() {
isUnauthorizedErrorShown = false
if (unauthorizedTimer) clearTimeout(unauthorizedTimer)
unauthorizedTimer = null
}
/** 退出登录函数(仅清本地态;服务端吊销由 logoutWithApi 负责) */
function logOut() {
setTimeout(() => {
useUserStore().logOut()
}, LOGOUT_DELAY)
}
/** 是否需要重试 */
function shouldRetry(statusCode: number) {
return [
ApiStatus.requestTimeout,
ApiStatus.internalServerError,
ApiStatus.badGateway,
ApiStatus.serviceUnavailable,
ApiStatus.gatewayTimeout
].includes(statusCode)
}
/** 请求重试逻辑 */
async function retryRequest<T>(
config: ExtendedAxiosRequestConfig,
retries: number = MAX_RETRIES
): Promise<T> {
try {
return await request<T>(config)
} catch (error) {
if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) {
await delay(RETRY_DELAY)
return retryRequest<T>(config, retries - 1)
}
throw error
}
}
/** 延迟函数 */
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 请求函数
* 成功直接返回响应体(契约无 {code,msg,data} 信封;204 无体返回空字符串)
*/
async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T> {
// POST | PUT 参数自动填充
if (
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
config.params &&
!config.data
) {
config.data = config.params
config.params = undefined
}
try {
const res = await axiosInstance.request<T>(config)
// 显示成功消息(契约无 msg 字段,仅在使用方显式传入文案时展示)
if (config.showSuccessMessage && typeof config.successMessage === 'string') {
showSuccess(config.successMessage)
}
return res.data as T
} catch (error) {
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
const showMsg = config.showErrorMessage !== false
showError(error, showMsg)
}
return Promise.reject(error)
}
}
/** API方法集合 */
const api = {
get<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'GET' })
},
post<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' })
},
put<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' })
},
del<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'DELETE' })
},
request<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>(config)
}
}
export default api
+18
View File
@@ -0,0 +1,18 @@
/**
* 接口状态码
*/
export enum ApiStatus {
success = 200, // 成功
error = 400, // 错误
unauthorized = 401, // 未授权
forbidden = 403, // 禁止访问
notFound = 404, // 未找到
methodNotAllowed = 405, // 方法不允许
requestTimeout = 408, // 请求超时
internalServerError = 500, // 服务器错误
notImplemented = 501, // 未实现
badGateway = 502, // 网关错误
serviceUnavailable = 503, // 服务不可用
gatewayTimeout = 504, // 网关超时
httpVersionNotSupported = 505 // HTTP版本不支持
}