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:
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 表格缓存管理模块
|
||||
*
|
||||
* 提供高性能的表格数据缓存机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 基于参数的智能缓存键生成(使用 ohash)
|
||||
* - LRU(最近最少使用)缓存淘汰策略
|
||||
* - 缓存过期时间管理
|
||||
* - 缓存大小限制和自动清理
|
||||
* - 基于标签的缓存分组管理
|
||||
* - 多种缓存失效策略(清空所有、清空当前、清空分页等)
|
||||
* - 缓存访问统计和命中率分析
|
||||
* - 缓存大小估算
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 表格数据的分页缓存
|
||||
* - 减少重复的 API 请求
|
||||
* - 提升表格切换和返回的响应速度
|
||||
* - 搜索条件变化时的智能缓存管理
|
||||
* - 数据更新后的缓存失效处理
|
||||
*
|
||||
* ## 缓存策略
|
||||
*
|
||||
* - CLEAR_ALL: 清空所有缓存(适用于全局数据更新)
|
||||
* - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新)
|
||||
* - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作)
|
||||
* - KEEP_ALL: 不清除缓存(适用于只读操作)
|
||||
*
|
||||
* @module utils/table/tableCache
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import { hash } from 'ohash'
|
||||
|
||||
// 缓存失效策略枚举
|
||||
export enum CacheInvalidationStrategy {
|
||||
/** 清空所有缓存 */
|
||||
CLEAR_ALL = 'clear_all',
|
||||
/** 仅清空当前查询条件的缓存 */
|
||||
CLEAR_CURRENT = 'clear_current',
|
||||
/** 清空所有分页缓存(保留不同搜索条件的缓存) */
|
||||
CLEAR_PAGINATION = 'clear_pagination',
|
||||
/** 不清除缓存 */
|
||||
KEEP_ALL = 'keep_all'
|
||||
}
|
||||
|
||||
// 通用 API 响应接口(兼容不同的后端响应格式)
|
||||
export interface ApiResponse<T = unknown> {
|
||||
records?: T[]
|
||||
data?: T[]
|
||||
total?: number
|
||||
current?: number
|
||||
size?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// 缓存存储接口
|
||||
export interface CacheItem<T> {
|
||||
data: T[]
|
||||
response: ApiResponse<T>
|
||||
timestamp: number
|
||||
params: string
|
||||
// 缓存标签,用于分组管理
|
||||
tags: Set<string>
|
||||
// 访问次数(用于 LRU 算法)
|
||||
accessCount: number
|
||||
// 最后访问时间
|
||||
lastAccessTime: number
|
||||
}
|
||||
|
||||
// 增强的缓存管理类
|
||||
export class TableCache<T> {
|
||||
private cache = new Map<string, CacheItem<T>>()
|
||||
private cacheTime: number
|
||||
private maxSize: number
|
||||
private enableLog: boolean
|
||||
|
||||
constructor(cacheTime = 5 * 60 * 1000, maxSize = 50, enableLog = false) {
|
||||
// 默认5分钟,最多50条缓存
|
||||
this.cacheTime = cacheTime
|
||||
this.maxSize = maxSize
|
||||
this.enableLog = enableLog
|
||||
}
|
||||
|
||||
// 内部日志工具
|
||||
private log(message: string, ...args: any[]) {
|
||||
if (this.enableLog) {
|
||||
console.log(`[TableCache] ${message}`, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成稳定的缓存键
|
||||
private generateKey(params: unknown): string {
|
||||
return hash(params)
|
||||
}
|
||||
|
||||
// 🔧 优化:增强类型安全性
|
||||
private generateTags(params: Record<string, unknown>): Set<string> {
|
||||
const tags = new Set<string>()
|
||||
|
||||
// 添加搜索条件标签
|
||||
const searchKeys = Object.keys(params).filter(
|
||||
(key) =>
|
||||
!['current', 'size', 'total'].includes(key) &&
|
||||
params[key] !== undefined &&
|
||||
params[key] !== '' &&
|
||||
params[key] !== null
|
||||
)
|
||||
|
||||
if (searchKeys.length > 0) {
|
||||
const searchTag = searchKeys.map((key) => `${key}:${String(params[key])}`).join('|')
|
||||
tags.add(`search:${searchTag}`)
|
||||
} else {
|
||||
tags.add('search:default')
|
||||
}
|
||||
|
||||
// 添加分页标签
|
||||
tags.add(`pagination:${params.size || 10}`)
|
||||
// 添加通用分页标签,用于清理所有分页缓存
|
||||
tags.add('pagination')
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// 🔧 优化:LRU 缓存清理
|
||||
private evictLRU(): void {
|
||||
if (this.cache.size <= this.maxSize) return
|
||||
|
||||
// 找到最少使用的缓存项
|
||||
let lruKey = ''
|
||||
let minAccessCount = Infinity
|
||||
let oldestTime = Infinity
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (
|
||||
item.accessCount < minAccessCount ||
|
||||
(item.accessCount === minAccessCount && item.lastAccessTime < oldestTime)
|
||||
) {
|
||||
lruKey = key
|
||||
minAccessCount = item.accessCount
|
||||
oldestTime = item.lastAccessTime
|
||||
}
|
||||
}
|
||||
|
||||
if (lruKey) {
|
||||
this.cache.delete(lruKey)
|
||||
this.log(`LRU 清理缓存: ${lruKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置缓存
|
||||
set(params: unknown, data: T[], response: ApiResponse<T>): void {
|
||||
const key = this.generateKey(params)
|
||||
const tags = this.generateTags(params as Record<string, unknown>)
|
||||
const now = Date.now()
|
||||
|
||||
// 检查是否需要清理
|
||||
this.evictLRU()
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
response,
|
||||
timestamp: now,
|
||||
params: key,
|
||||
tags,
|
||||
accessCount: 1,
|
||||
lastAccessTime: now
|
||||
})
|
||||
}
|
||||
|
||||
// 获取缓存
|
||||
get(params: unknown): CacheItem<T> | null {
|
||||
const key = this.generateKey(params)
|
||||
const item = this.cache.get(key)
|
||||
|
||||
if (!item) return null
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - item.timestamp > this.cacheTime) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
// 更新访问统计
|
||||
item.accessCount++
|
||||
item.lastAccessTime = Date.now()
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// 根据标签清除缓存
|
||||
clearByTags(tags: string[]): number {
|
||||
let clearedCount = 0
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
// 检查是否包含任意一个标签
|
||||
const hasMatchingTag = tags.some((tag) =>
|
||||
Array.from(item.tags).some((itemTag) => itemTag.includes(tag))
|
||||
)
|
||||
|
||||
if (hasMatchingTag) {
|
||||
this.cache.delete(key)
|
||||
clearedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return clearedCount
|
||||
}
|
||||
|
||||
// 清除当前搜索条件的缓存
|
||||
clearCurrentSearch(params: unknown): number {
|
||||
const key = this.generateKey(params)
|
||||
const deleted = this.cache.delete(key)
|
||||
return deleted ? 1 : 0
|
||||
}
|
||||
|
||||
// 清除分页缓存
|
||||
clearPagination(): number {
|
||||
return this.clearByTags(['pagination'])
|
||||
}
|
||||
|
||||
// 清空所有缓存
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
// 获取缓存统计信息
|
||||
getStats(): { total: number; size: string; hitRate: string } {
|
||||
const total = this.cache.size
|
||||
let totalSize = 0
|
||||
let totalAccess = 0
|
||||
|
||||
for (const item of this.cache.values()) {
|
||||
// 粗略估算大小(JSON字符串长度)
|
||||
totalSize += JSON.stringify(item.data).length
|
||||
totalAccess += item.accessCount
|
||||
}
|
||||
|
||||
// 转换为人类可读的大小
|
||||
const sizeInKB = (totalSize / 1024).toFixed(2)
|
||||
const avgHits = total > 0 ? (totalAccess / total).toFixed(1) : '0'
|
||||
|
||||
return {
|
||||
total,
|
||||
size: `${sizeInKB}KB`,
|
||||
hitRate: `${avgHits} avg hits`
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpired(): number {
|
||||
let cleanedCount = 0
|
||||
const now = Date.now()
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > this.cacheTime) {
|
||||
this.cache.delete(key)
|
||||
cleanedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 表格全局配置模块
|
||||
*
|
||||
* 提供表格与后端接口的字段映射配置
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 响应数据字段自动识别和映射
|
||||
* - 支持多种常见的后端响应格式
|
||||
* - 请求参数字段映射配置
|
||||
* - 可扩展的字段配置机制
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 适配不同后端的分页接口格式
|
||||
* - 统一前端表格组件的数据处理
|
||||
* - 减少重复的数据转换代码
|
||||
* - 支持多个后端服务的接口对接
|
||||
*
|
||||
* ## 配置说明
|
||||
*
|
||||
* - recordFields: 列表数据字段名(按优先级顺序查找)
|
||||
* - totalFields: 总条数字段名
|
||||
* - currentFields: 当前页码字段名
|
||||
* - sizeFields: 每页大小字段名
|
||||
* - paginationKey: 前端发送请求时使用的分页参数名
|
||||
*
|
||||
* ## 扩展方式
|
||||
*
|
||||
* 如果后端使用其他字段名,可以在对应数组中添加新的字段名
|
||||
* 例如:recordFields: ['list', 'data', 'records', 'items', 'yourCustomField']
|
||||
*
|
||||
* @module utils/table/tableConfig
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
export const tableConfig = {
|
||||
// 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段
|
||||
// 列表数据
|
||||
recordFields: ['list', 'data', 'records', 'items', 'result', 'rows'],
|
||||
// 总条数
|
||||
totalFields: ['total', 'count'],
|
||||
// 当前页码
|
||||
currentFields: ['current', 'page', 'pageNum'],
|
||||
// 每页大小
|
||||
sizeFields: ['size', 'pageSize', 'limit'],
|
||||
|
||||
// 请求参数映射配置,前端发送请求时使用的分页参数名
|
||||
// useTable 组合式函数传递分页参数的时候 用 current 跟 size
|
||||
paginationKey: {
|
||||
// 当前页码
|
||||
current: 'current',
|
||||
// 每页大小
|
||||
size: 'size'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 表格工具函数模块
|
||||
*
|
||||
* 提供表格数据处理和请求管理的核心工具函数
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 多格式 API 响应自动适配和标准化
|
||||
* - 表格数据提取和转换
|
||||
* - 分页信息自动更新和校验
|
||||
* - 智能防抖函数(支持取消和立即执行)
|
||||
* - 统一的错误处理机制
|
||||
* - 嵌套数据结构解析
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - useTable 组合式函数的底层工具
|
||||
* - 适配各种后端接口响应格式
|
||||
* - 表格数据的标准化处理
|
||||
* - 请求防抖和性能优化
|
||||
* - 错误统一处理和日志记录
|
||||
*
|
||||
* ## 支持的响应格式
|
||||
*
|
||||
* 1. 直接数组: [item1, item2, ...]
|
||||
* 2. 标准对象: { records: [], total: 100 }
|
||||
* 3. 嵌套data: { data: { list: [], total: 100 } }
|
||||
* 4. 多种字段名: list/data/records/items/result/rows
|
||||
*
|
||||
* ## 核心功能
|
||||
*
|
||||
* - defaultResponseAdapter: 智能识别和转换响应格式
|
||||
* - extractTableData: 提取表格数据数组
|
||||
* - updatePaginationFromResponse: 更新分页信息
|
||||
* - createSmartDebounce: 创建可控的防抖函数
|
||||
* - createErrorHandler: 生成错误处理器
|
||||
*
|
||||
* @module utils/table/tableUtils
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import type { ApiResponse } from './tableCache'
|
||||
import { tableConfig } from './tableConfig'
|
||||
|
||||
// 请求参数基础接口,扩展分页参数
|
||||
export interface BaseRequestParams extends Api.Common.PaginationParams {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// 错误处理接口
|
||||
export interface TableError {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
// 辅助函数:从对象中提取记录数组
|
||||
function extractRecords<T>(obj: Record<string, unknown>, fields: string[]): T[] {
|
||||
for (const field of fields) {
|
||||
if (field in obj && Array.isArray(obj[field])) {
|
||||
return obj[field] as T[]
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 辅助函数:从对象中提取总数
|
||||
function extractTotal(obj: Record<string, unknown>, records: unknown[], fields: string[]): number {
|
||||
for (const field of fields) {
|
||||
if (field in obj && typeof obj[field] === 'number') {
|
||||
return obj[field] as number
|
||||
}
|
||||
}
|
||||
return records.length
|
||||
}
|
||||
|
||||
// 辅助函数:提取分页参数
|
||||
function extractPagination(
|
||||
obj: Record<string, unknown>,
|
||||
data?: Record<string, unknown>
|
||||
): Pick<ApiResponse<unknown>, 'current' | 'size'> | undefined {
|
||||
const result: Partial<Pick<ApiResponse<unknown>, 'current' | 'size'>> = {}
|
||||
const sources = [obj, data ?? {}]
|
||||
|
||||
const currentFields = tableConfig.currentFields
|
||||
for (const src of sources) {
|
||||
for (const field of currentFields) {
|
||||
if (field in src && typeof src[field] === 'number') {
|
||||
result.current = src[field] as number
|
||||
break
|
||||
}
|
||||
}
|
||||
if (result.current !== undefined) break
|
||||
}
|
||||
|
||||
const sizeFields = tableConfig.sizeFields
|
||||
for (const src of sources) {
|
||||
for (const field of sizeFields) {
|
||||
if (field in src && typeof src[field] === 'number') {
|
||||
result.size = src[field] as number
|
||||
break
|
||||
}
|
||||
}
|
||||
if (result.size !== undefined) break
|
||||
}
|
||||
|
||||
if (result.current === undefined && result.size === undefined) return undefined
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认响应适配器 - 支持多种常见的API响应格式
|
||||
*/
|
||||
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
|
||||
// 定义支持的字段
|
||||
const recordFields = tableConfig.recordFields
|
||||
|
||||
if (!response) {
|
||||
return { records: [], total: 0 }
|
||||
}
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return { records: response, total: response.length }
|
||||
}
|
||||
|
||||
if (typeof response !== 'object') {
|
||||
console.warn(
|
||||
'[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含' +
|
||||
recordFields.join('/') +
|
||||
'字段的对象、嵌套data对象。当前格式:',
|
||||
response
|
||||
)
|
||||
return { records: [], total: 0 }
|
||||
}
|
||||
|
||||
const res = response as Record<string, unknown>
|
||||
let records: T[] = []
|
||||
let total = 0
|
||||
let pagination: Pick<ApiResponse<unknown>, 'current' | 'size'> | undefined
|
||||
|
||||
// 处理标准格式或直接列表
|
||||
records = extractRecords(res, recordFields)
|
||||
total = extractTotal(res, records, tableConfig.totalFields)
|
||||
pagination = extractPagination(res)
|
||||
|
||||
// 如果没有找到,检查嵌套data
|
||||
if (records.length === 0 && 'data' in res && typeof res.data === 'object') {
|
||||
const data = res.data as Record<string, unknown>
|
||||
records = extractRecords(data, ['list', 'records', 'items'])
|
||||
total = extractTotal(data, records, tableConfig.totalFields)
|
||||
pagination = extractPagination(res, data)
|
||||
|
||||
if (Array.isArray(res.data)) {
|
||||
records = res.data as T[]
|
||||
total = records.length
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordFields.some((field) => field in res) && records.length === 0) {
|
||||
console.warn('[tableUtils] 无法识别的响应格式')
|
||||
console.warn('支持的字段包括: ' + recordFields.join('、'), response)
|
||||
console.warn('扩展字段请到 utils/table/tableConfig 文件配置')
|
||||
}
|
||||
|
||||
const result: ApiResponse<T> = { records, total }
|
||||
if (pagination) {
|
||||
Object.assign(result, pagination)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从标准化的API响应中提取表格数据
|
||||
*/
|
||||
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
|
||||
const data = response.records || response.data || []
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据API响应更新分页信息
|
||||
*/
|
||||
export const updatePaginationFromResponse = <T>(
|
||||
pagination: Api.Common.PaginationParams,
|
||||
response: ApiResponse<T>
|
||||
): void => {
|
||||
pagination.total = response.total ?? pagination.total ?? 0
|
||||
|
||||
if (response.current !== undefined) {
|
||||
pagination.current = response.current
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(pagination.total / (pagination.size || 1)))
|
||||
if (pagination.current > maxPage) {
|
||||
pagination.current = maxPage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建智能防抖函数 - 支持取消和立即执行
|
||||
*/
|
||||
export const createSmartDebounce = <T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): T & { cancel: () => void; flush: () => Promise<any> } => {
|
||||
let timeoutId: NodeJS.Timeout | null = null
|
||||
let lastArgs: Parameters<T> | null = null
|
||||
let lastResolve: ((value: any) => void) | null = null
|
||||
let lastReject: ((reason: any) => void) | null = null
|
||||
|
||||
const debouncedFn = (...args: Parameters<T>): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
lastArgs = args
|
||||
lastResolve = resolve
|
||||
lastReject = reject
|
||||
timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
const result = await fn(...args)
|
||||
resolve(result)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
} finally {
|
||||
timeoutId = null
|
||||
lastArgs = null
|
||||
lastResolve = null
|
||||
lastReject = null
|
||||
}
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
|
||||
debouncedFn.cancel = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
lastArgs = null
|
||||
lastResolve = null
|
||||
lastReject = null
|
||||
}
|
||||
|
||||
debouncedFn.flush = async () => {
|
||||
if (timeoutId && lastArgs && lastResolve && lastReject) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
const args = lastArgs
|
||||
const resolve = lastResolve
|
||||
const reject = lastReject
|
||||
lastArgs = null
|
||||
lastResolve = null
|
||||
lastReject = null
|
||||
try {
|
||||
const result = await fn(...args)
|
||||
resolve(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return debouncedFn as any
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成错误处理函数
|
||||
*/
|
||||
export const createErrorHandler = (
|
||||
onError?: (error: TableError) => void,
|
||||
enableLog: boolean = false
|
||||
) => {
|
||||
const logger = {
|
||||
error: (message: string, ...args: any[]) => {
|
||||
if (enableLog) console.error(`[useTable] ${message}`, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
return (err: unknown, context: string): TableError => {
|
||||
const tableError: TableError = {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: '未知错误',
|
||||
details: err
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
tableError.message = err.message
|
||||
tableError.code = err.name
|
||||
} else if (typeof err === 'string') {
|
||||
tableError.message = err
|
||||
}
|
||||
|
||||
logger.error(`${context}:`, err)
|
||||
onError?.(tableError)
|
||||
return tableError
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user