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
+266
View File
@@ -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
}
}
+55
View File
@@ -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'
}
}
+297
View File
@@ -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
}
}