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
+45
View File
@@ -0,0 +1,45 @@
/**
* useAppMode - 应用模式管理
*
* 提供应用访问模式的判断和管理功能,支持前端和后端两种权限控制模式。
* 根据环境变量 VITE_ACCESS_MODE 自动识别当前运行模式。
*
* ## 主要功能
*
* 1. 模式识别 - 自动识别前端模式或后端模式
* 2. 前端模式 - 权限由前端路由配置控制,适合小型项目或演示环境
* 3. 后端模式 - 权限由后端接口返回的菜单数据控制,适合企业级应用
* 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用
*
* @module useAppMode
* @author Art Design Pro Team
*/
import { computed } from 'vue'
export function useAppMode() {
// 获取访问模式配置
const accessMode = import.meta.env.VITE_ACCESS_MODE
/**
* 是否为前端控制模式
* 前端模式:权限由前端路由配置控制
*/
const isFrontendMode = computed(() => accessMode === 'frontend')
/**
* 是否为后端控制模式
* 后端模式:权限由后端接口返回的菜单数据控制
*/
const isBackendMode = computed(() => accessMode === 'backend')
/**
* 当前应用模式
*/
const currentMode = computed(() => accessMode)
return {
isFrontendMode,
isBackendMode,
currentMode
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* useAuth - 权限验证管理
*
* 提供统一的权限验证功能,支持前端和后端两种权限模式。
* 用于控制页面按钮、操作等功能的显示和访问权限。
*
* ## 主要功能
*
* 1. 权限检查 - 检查用户是否拥有指定的权限标识
* 2. 双模式支持 - 自动适配前端模式和后端模式的权限验证
* 3. 前端模式 - 从用户信息中获取按钮权限列表(如 ['add', 'edit', 'delete'])
* 4. 后端模式 - 从路由 meta 配置中获取权限列表(如 [{ authMark: 'add' }])
*
* ## 使用示例
*
* ```typescript
* const { hasAuth } = useAuth()
*
* // 检查是否有新增权限
* if (hasAuth('add')) {
* // 显示新增按钮
* }
*
* // 在模板中使用
* <el-button v-if="hasAuth('edit')">编辑</el-button>
* <el-button v-if="hasAuth('delete')">删除</el-button>
* ```
*
* @module useAuth
* @author Art Design Pro Team
*/
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/modules/user'
import { useAppMode } from '@/hooks/core/useAppMode'
import type { AppRouteRecord } from '@/types/router'
type AuthItem = NonNullable<AppRouteRecord['meta']['authList']>[number]
const userStore = useUserStore()
export const useAuth = () => {
const route = useRoute()
const { isFrontendMode } = useAppMode()
const { info } = storeToRefs(userStore)
// 前端按钮权限(例如:['add', 'edit'])
const frontendAuthList = info.value?.buttons ?? []
// 后端路由 meta 配置的权限列表(例如:[{ authMark: 'add' }])
const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList)
? (route.meta.authList as AuthItem[])
: []
/**
* 检查是否拥有某权限标识(前后端模式通用)
* @param auth 权限标识
* @returns 是否有权限
*/
const hasAuth = (auth: string): boolean => {
// 前端模式
if (isFrontendMode.value) {
return frontendAuthList.includes(auth)
}
// 后端模式
return backendAuthList.some((item) => item?.authMark === auth)
}
return {
hasAuth
}
}
+184
View File
@@ -0,0 +1,184 @@
/**
* useCeremony - 节日庆祝管理
*
* 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。
* 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。
*
* ## 主要功能
*
* 1. 节日检测 - 自动匹配当前日期与节日配置列表,支持单日和跨日期节日
* 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数
* 3. 祝福文本 - 烟花结束后显示节日祝福文本
* 4. 状态管理 - 记录烟花播放状态,避免重复播放
* 5. 清理机制 - 提供清理方法,支持手动停止和重置
*
* ## 使用示例
*
* ```typescript
* // 在配置文件中定义节日
* // 单日节日
* {
* date: '2024-12-25',
* name: '圣诞节',
* image: christmasImage,
* count: 3 // 可选,不设置则使用默认值 3 次
* scrollText: 'Merry Christmas!',
* }
*
* // 跨日期节日
* {
* date: '2025-11-07',
* endDate: '2025-11-10',
* name: 'v3.0 测试阶段',
* image: '',
* count: 5 // 自定义烟花播放次数
* scrollText: '系统 v3.0 测试阶段正式开启!',
* }
* ```
*
* @module useCeremony
* @author Art Design Pro Team
*/
import { useTimeoutFn, useIntervalFn, useDateFormat } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { mittBus } from '@/utils/sys'
import { festivalConfigList } from '@/config/modules/festival'
/**
* 节日庆祝配置常量
*/
const FESTIVAL_CONFIG = {
/** 初始延迟(毫秒) */
INITIAL_DELAY: 300,
/** 烟花播放间隔(毫秒) */
FIREWORK_INTERVAL: 1000,
/** 文本显示延迟(毫秒) */
TEXT_DELAY: 2000,
/** 默认烟花播放次数 */
DEFAULT_FIREWORKS_COUNT: 3
} as const
/**
* 节日庆祝功能
* 提供节日烟花效果和祝福文本展示
*/
export function useCeremony() {
const settingStore = useSettingStore()
const { holidayFireworksLoaded, isShowFireworks } = storeToRefs(settingStore)
let fireworksInterval: { pause: () => void } | null = null
/**
* 检查日期是否在节日范围内
* @param currentDate 当前日期
* @param festivalDate 节日开始日期
* @param festivalEndDate 节日结束日期(可选)
*/
const isDateInRange = (
currentDate: string,
festivalDate: string,
festivalEndDate?: string
): boolean => {
if (!festivalEndDate) {
// 单日节日
return currentDate === festivalDate
}
// 跨日期节日
const current = new Date(currentDate)
const start = new Date(festivalDate)
const end = new Date(festivalEndDate)
return current >= start && current <= end
}
/**
* 获取当前日期对应的节日数据
*/
const currentFestivalData = computed(() => {
const currentDate = useDateFormat(new Date(), 'YYYY-MM-DD').value
return festivalConfigList.find((item) => isDateInRange(currentDate, item.date, item.endDate))
})
/**
* 更新节日日期到 store
*/
const updateFestivalDate = () => {
settingStore.setFestivalDate(currentFestivalData.value?.date || '')
}
/**
* 触发烟花效果
*/
const triggerFirework = () => {
mittBus.emit('triggerFireworks', currentFestivalData.value?.image)
}
/**
* 完成烟花效果后显示文本
*/
const showFestivalText = () => {
settingStore.setholidayFireworksLoaded(true)
useTimeoutFn(() => {
settingStore.setShowFestivalText(true)
updateFestivalDate()
}, FESTIVAL_CONFIG.TEXT_DELAY)
}
/**
* 启动烟花循环
*/
const startFireworksLoop = () => {
let playedCount = 0
// 使用节日配置的播放次数,如果没有则使用默认值
const count = currentFestivalData.value?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT
const { pause } = useIntervalFn(() => {
triggerFirework()
playedCount++
if (playedCount >= count) {
pause()
showFestivalText()
}
}, FESTIVAL_CONFIG.FIREWORK_INTERVAL)
fireworksInterval = { pause }
}
/**
* 开启节日庆祝
*/
const openFestival = () => {
if (!currentFestivalData.value || !isShowFireworks.value) {
return
}
const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY)
start()
}
/**
* 清理烟花效果
*/
const cleanup = () => {
if (fireworksInterval) {
fireworksInterval.pause()
fireworksInterval = null
}
settingStore.setShowFestivalText(false)
updateFestivalDate()
}
return {
openFestival,
cleanup,
holidayFireworksLoaded,
currentFestivalData,
isShowFireworks
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* useCommon - 通用功能集合
*
* 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。
* 这些功能在多个页面和组件中都会用到,统一封装便于复用。
*
* ## 主要功能
*
* 1. 首页路径 - 获取系统配置的首页路径
* 2. 页面刷新 - 刷新当前页面内容
* 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法
* 4. 平滑滚动 - 支持平滑滚动动画效果
*
* @module useCommon
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import { useMenuStore } from '@/store/modules/menu'
import { useSettingStore } from '@/store/modules/setting'
export function useCommon() {
const menuStore = useMenuStore()
const settingStore = useSettingStore()
/**
* 首页路径
* 从菜单 store 中获取配置的首页路径
*/
const homePath = computed(() => menuStore.getHomePath())
/**
* 刷新当前页面
* 通过切换 setting store 中的 refresh 状态触发页面重新渲染
*/
const refresh = () => {
settingStore.reload()
}
/**
* 滚动到页面顶部
* 查找主内容区域并将其滚动位置重置为顶部
*/
const scrollToTop = () => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTop = 0
}
}
/**
* 平滑滚动到页面顶部
* 使用 smooth 行为实现平滑滚动效果
*/
const smoothScrollToTop = () => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
/**
* 滚动到指定位置
* @param top 目标滚动位置(像素)
* @param smooth 是否使用平滑滚动
*/
const scrollTo = (top: number, smooth: boolean = false) => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTo({
top,
behavior: smooth ? 'smooth' : 'auto'
})
}
}
return {
homePath,
refresh,
scrollTo,
scrollToTop,
smoothScrollToTop
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* useFastEnter - 快速入口管理
*
* 管理顶部栏的快速入口功能,提供应用列表和快速链接的配置和过滤。
* 支持动态启用/禁用、自定义排序、响应式宽度控制等功能。
*
* ## 主要功能
*
* 1. 应用列表管理 - 获取启用的应用列表,自动按排序权重排序
* 2. 快速链接管理 - 获取启用的快速链接,支持自定义排序
* 3. 响应式配置 - 所有配置自动响应变化,无需手动更新
* 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局
*
* @module useFastEnter
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import appConfig from '@/config'
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
export function useFastEnter() {
// 获取快速入口配置
const fastEnterConfig = computed(() => appConfig.fastEnter)
// 获取启用的应用列表(按排序权重排序)
const enabledApplications = computed<FastEnterApplication[]>(() => {
if (!fastEnterConfig.value?.applications) return []
return fastEnterConfig.value.applications
.filter((app) => app.enabled !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0))
})
// 获取启用的快速链接(按排序权重排序)
const enabledQuickLinks = computed<FastEnterQuickLink[]>(() => {
if (!fastEnterConfig.value?.quickLinks) return []
return fastEnterConfig.value.quickLinks
.filter((link) => link.enabled !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0))
})
// 获取最小显示宽度
const minWidth = computed(() => {
return fastEnterConfig.value?.minWidth || 1200
})
return {
fastEnterConfig,
enabledApplications,
enabledQuickLinks,
minWidth
}
}
+201
View File
@@ -0,0 +1,201 @@
/**
* useHeaderBar - 顶部栏功能管理
*
* 统一管理顶部栏各个功能模块的显示状态和配置信息。
* 提供灵活的功能开关控制,支持动态显示/隐藏顶部栏的各个功能按钮。
*
* ## 主要功能
*
* 1. 功能开关控制 - 统一管理菜单按钮、刷新按钮、快速入口等功能的显示状态
* 2. 配置信息获取 - 获取各个功能模块的详细配置信息
* 3. 功能列表查询 - 快速获取所有启用或禁用的功能列表
* 4. 响应式状态 - 所有状态自动响应配置和 store 变化
*
* @module useHeaderBar
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/store/modules/setting'
import { headerBarConfig } from '@/config/modules/headerBar'
import { HeaderBarFeatureConfig } from '@/types'
/**
* 顶部栏功能管理
* @returns 顶部栏功能相关的状态和方法
*/
export function useHeaderBar() {
const settingStore = useSettingStore()
// 获取顶部栏配置
const headerBarConfigRef = computed<HeaderBarFeatureConfig>(() => headerBarConfig)
// 从store中获取相关状态
const { showMenuButton, showFastEnter, showRefreshButton, showCrumbs, showLanguage } =
storeToRefs(settingStore)
/**
* 检查特定功能是否启用
* @param feature 功能名称
* @returns 是否启用
*/
const isFeatureEnabled = (feature: keyof HeaderBarFeatureConfig): boolean => {
return headerBarConfigRef.value[feature]?.enabled ?? false
}
/**
* 获取功能配置信息
* @param feature 功能名称
* @returns 功能配置信息
*/
const getFeatureConfig = (feature: keyof HeaderBarFeatureConfig) => {
return headerBarConfigRef.value[feature]
}
// 检查菜单按钮是否显示
const shouldShowMenuButton = computed(() => {
return isFeatureEnabled('menuButton') && showMenuButton.value
})
// 检查刷新按钮是否显示
const shouldShowRefreshButton = computed(() => {
return isFeatureEnabled('refreshButton') && showRefreshButton.value
})
// 检查快速入口是否显示
const shouldShowFastEnter = computed(() => {
return isFeatureEnabled('fastEnter') && showFastEnter.value
})
// 检查面包屑是否显示
const shouldShowBreadcrumb = computed(() => {
return isFeatureEnabled('breadcrumb') && showCrumbs.value
})
// 检查全局搜索是否显示
const shouldShowGlobalSearch = computed(() => {
return isFeatureEnabled('globalSearch')
})
// 检查全屏按钮是否显示
const shouldShowFullscreen = computed(() => {
return isFeatureEnabled('fullscreen')
})
// 检查通知中心是否显示
const shouldShowNotification = computed(() => {
return isFeatureEnabled('notification')
})
// 检查聊天功能是否显示
const shouldShowChat = computed(() => {
return isFeatureEnabled('chat')
})
// 检查语言切换是否显示
const shouldShowLanguage = computed(() => {
return isFeatureEnabled('language') && showLanguage.value
})
// 检查设置面板是否显示
const shouldShowSettings = computed(() => {
return isFeatureEnabled('settings')
})
// 检查主题切换是否显示
const shouldShowThemeToggle = computed(() => {
return isFeatureEnabled('themeToggle')
})
// 获取快速入口的最小宽度
const fastEnterMinWidth = computed(() => {
const config = getFeatureConfig('fastEnter')
return (config as any)?.minWidth || 1200
})
/**
* 检查功能是否启用(别名)
* @param feature 功能名称
* @returns 是否启用
*/
const isFeatureActive = (feature: keyof HeaderBarFeatureConfig): boolean => {
return isFeatureEnabled(feature)
}
/**
* 获取功能配置(别名)
* @param feature 功能名称
* @returns 功能配置
*/
const getFeatureInfo = (feature: keyof HeaderBarFeatureConfig) => {
return getFeatureConfig(feature)
}
/**
* 获取所有启用的功能列表
* @returns 启用的功能名称数组
*/
const getEnabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => {
return Object.keys(headerBarConfigRef.value).filter(
(key) => headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled
) as (keyof HeaderBarFeatureConfig)[]
}
/**
* 获取所有禁用的功能列表
* @returns 禁用的功能名称数组
*/
const getDisabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => {
return Object.keys(headerBarConfigRef.value).filter(
(key) => !headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled
) as (keyof HeaderBarFeatureConfig)[]
}
/**
* 获取所有启用的功能(别名)
* @returns 启用的功能列表
*/
const getActiveFeatures = () => {
return getEnabledFeatures()
}
/**
* 获取所有禁用的功能(别名)
* @returns 禁用的功能列表
*/
const getInactiveFeatures = () => {
return getDisabledFeatures()
}
return {
// 配置
headerBarConfig: headerBarConfigRef,
// 显示状态计算属性
shouldShowMenuButton, // 是否显示菜单按钮
shouldShowRefreshButton, // 是否显示刷新按钮
shouldShowFastEnter, // 是否显示快速入口
shouldShowBreadcrumb, // 是否显示面包屑
shouldShowGlobalSearch, // 是否显示全局搜索
shouldShowFullscreen, // 是否显示全屏按钮
shouldShowNotification, // 是否显示通知中心
shouldShowChat, // 是否显示聊天功能
shouldShowLanguage, // 是否显示语言切换
shouldShowSettings, // 是否显示设置面板
shouldShowThemeToggle, // 是否显示主题切换
// 配置相关
fastEnterMinWidth, // 快速入口最小宽度
// 方法
isFeatureEnabled, // 检查功能是否启用
isFeatureActive, // 检查功能是否启用(别名)
getFeatureConfig, // 获取功能配置
getFeatureInfo, // 获取功能配置(别名)
getEnabledFeatures, // 获取所有启用的功能
getDisabledFeatures, // 获取所有禁用的功能
getActiveFeatures, // 获取所有启用的功能(别名)
getInactiveFeatures // 获取所有禁用的功能(别名)
}
}
@@ -0,0 +1,148 @@
/**
* useLayoutHeight - 页面布局高度管理
*
* 自动计算和管理页面内容区域的高度,确保内容区域能够正确填充剩余空间。
* 监听头部元素高度变化,动态调整内容区域高度,避免出现滚动条或布局错乱。
*
* ## 主要功能
*
* 1. 动态高度计算 - 根据头部元素高度自动计算内容区域高度
* 2. 响应式监听 - 自动监听元素尺寸变化并更新高度
* 3. CSS 变量同步 - 自动更新 CSS 变量,方便全局使用
* 4. 灵活配置 - 支持自定义间距、CSS 变量名等
* 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式
*
* @module useLayoutHeight
* @author Art Design Pro Team
*/
import { ref, computed, watch, onMounted } from 'vue'
import { useElementSize } from '@vueuse/core'
/**
* 页面容器高度配置
*/
interface LayoutHeightOptions {
/** 额外的间距(默认 15px) */
extraSpacing?: number
/** 是否自动更新 CSS 变量(默认 true) */
updateCssVar?: boolean
/** CSS 变量名称(默认 '--art-full-height') */
cssVarName?: string
}
export function useLayoutHeight(options: LayoutHeightOptions = {}) {
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
// 元素引用
const headerRef = ref<HTMLElement>()
const contentHeaderRef = ref<HTMLElement>()
// 使用 VueUse 自动监听元素尺寸变化
const { height: headerHeight } = useElementSize(headerRef)
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
// 计算容器最小高度(响应式)
const containerMinHeight = computed(() => {
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
return `calc(100vh - ${totalHeight}px)`
})
if (updateCssVar) {
watch(
containerMinHeight,
(newHeight) => {
requestAnimationFrame(() => {
document.documentElement.style.setProperty(cssVarName, newHeight)
})
},
{ immediate: true }
)
}
return {
/** 容器最小高度(响应式) */
containerMinHeight,
/** 头部元素引用 */
headerRef,
/** 内容头部元素引用 */
contentHeaderRef,
/** 头部高度(响应式) */
headerHeight,
/** 内容头部高度(响应式) */
contentHeaderHeight
}
}
/**
* 通过 ID 自动查找元素的布局高度管理
* 适用于无法直接获取元素引用的场景
*
* @param headerIds 头部元素的 ID 数组
* @param options 配置选项
*
* ```
*/
export function useAutoLayoutHeight(
headerIds: string[] = ['app-header', 'app-content-header'],
options: LayoutHeightOptions = {}
) {
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
// 创建元素引用
const headerRef = ref<HTMLElement>()
const contentHeaderRef = ref<HTMLElement>()
// 使用 VueUse 自动监听元素尺寸变化
const { height: headerHeight } = useElementSize(headerRef)
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
// 计算容器最小高度(响应式)
const containerMinHeight = computed(() => {
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
return `calc(100vh - ${totalHeight}px)`
})
if (updateCssVar) {
watch(
containerMinHeight,
(newHeight) => {
requestAnimationFrame(() => {
document.documentElement.style.setProperty(cssVarName, newHeight)
})
},
{ immediate: true }
)
}
// 在 DOM 挂载后查找元素
onMounted(() => {
if (typeof document !== 'undefined') {
// 使用 nextTick 确保 DOM 完全渲染
requestAnimationFrame(() => {
const header = document.getElementById(headerIds[0])
const contentHeader = document.getElementById(headerIds[1])
if (header) {
headerRef.value = header
}
if (contentHeader) {
contentHeaderRef.value = contentHeader
}
})
}
})
return {
/** 容器最小高度(响应式) */
containerMinHeight,
/** 头部元素引用 */
headerRef,
/** 内容头部元素引用 */
contentHeaderRef,
/** 头部高度(响应式) */
headerHeight,
/** 内容头部高度(响应式) */
contentHeaderHeight
}
}
+736
View File
@@ -0,0 +1,736 @@
/**
* useTable - 企业级表格数据管理方案
*
* 功能完整的表格数据管理解决方案,专为后台管理系统设计。
* 封装了表格开发中的所有常见需求,让你专注于业务逻辑。
*
* ## 主要功能
*
* 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理
* 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理
* 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤
* 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息
* 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时)
* 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选)
*
* @module useTable
* @author Art Design Pro Team
*/
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { useTableColumns } from './useTableColumns'
import type { ColumnOption } from '@/types/component'
import {
TableCache,
CacheInvalidationStrategy,
type ApiResponse
} from '../../utils/table/tableCache'
import {
type TableError,
defaultResponseAdapter,
extractTableData,
updatePaginationFromResponse,
createSmartDebounce,
createErrorHandler
} from '../../utils/table/tableUtils'
import { tableConfig } from '../../utils/table/tableConfig'
// 类型推导工具类型
type InferApiParams<T> = T extends (params: infer P) => any ? P : never
type InferApiResponse<T> = T extends (params: any) => Promise<infer R> ? R : never
type InferRecordType<T> = T extends Api.Common.PaginatedResponse<infer U> ? U : never
// 优化的配置接口 - 支持自动类型推导
export interface UseTableConfig<
TApiFn extends (params: any) => Promise<any> = (params: any) => Promise<any>,
TRecord = InferRecordType<InferApiResponse<TApiFn>>,
TParams = InferApiParams<TApiFn>,
TResponse = InferApiResponse<TApiFn>
> {
// 核心配置
core: {
/** API 请求函数 */
apiFn: TApiFn
/** 默认请求参数 */
apiParams?: Partial<TParams>
/** 排除 apiParams 中的属性 */
excludeParams?: string[]
/** 是否立即加载数据 */
immediate?: boolean
/** 列配置工厂函数 */
columnsFactory?: () => ColumnOption<TRecord>[]
/** 自定义分页字段映射 */
paginationKey?: {
/** 当前页码字段名,默认为 'current' */
current?: string
/** 每页条数字段名,默认为 'size' */
size?: string
}
}
// 数据处理
transform?: {
/** 数据转换函数 */
dataTransformer?: (data: TRecord[]) => TRecord[]
/** 响应数据适配器 */
responseAdapter?: (response: TResponse) => ApiResponse<TRecord>
}
// 性能优化
performance?: {
/** 是否启用缓存 */
enableCache?: boolean
/** 缓存时间(毫秒) */
cacheTime?: number
/** 防抖延迟时间(毫秒) */
debounceTime?: number
/** 最大缓存条数限制 */
maxCacheSize?: number
}
// 生命周期钩子
hooks?: {
/** 数据加载成功回调(仅网络请求成功时触发) */
onSuccess?: (data: TRecord[], response: ApiResponse<TRecord>) => void
/** 错误处理回调 */
onError?: (error: TableError) => void
/** 缓存命中回调(从缓存获取数据时触发) */
onCacheHit?: (data: TRecord[], response: ApiResponse<TRecord>) => void
/** 加载状态变化回调 */
onLoading?: (loading: boolean) => void
/** 重置表单回调函数 */
resetFormCallback?: () => void
}
// 调试配置
debug?: {
/** 是否启用日志输出 */
enableLog?: boolean
/** 日志级别 */
logLevel?: 'info' | 'warn' | 'error'
}
}
export function useTable<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
return useTableImpl(config)
}
/**
* useTable 的核心实现 - 强大的表格数据管理 Hook
*
* 提供完整的表格解决方案,包括:
* - 数据获取与缓存
* - 分页控制
* - 搜索功能
* - 智能刷新策略
* - 错误处理
* - 列配置管理
*/
function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
type TRecord = InferRecordType<InferApiResponse<TApiFn>>
type TParams = InferApiParams<TApiFn>
const {
core: {
apiFn,
apiParams = {} as Partial<TParams>,
excludeParams = [],
immediate = true,
columnsFactory,
paginationKey
},
transform: { dataTransformer, responseAdapter = defaultResponseAdapter } = {},
performance: {
enableCache = false,
cacheTime = 5 * 60 * 1000,
debounceTime = 300,
maxCacheSize = 50
} = {},
hooks: { onSuccess, onError, onCacheHit, resetFormCallback } = {},
debug: { enableLog = false } = {}
} = config
// 分页字段名配置:优先使用传入的配置,否则使用全局配置
const pageKey = paginationKey?.current || tableConfig.paginationKey.current
const sizeKey = paginationKey?.size || tableConfig.paginationKey.size
// 响应式触发器,用于手动更新缓存统计信息
const cacheUpdateTrigger = ref(0)
// 日志工具函数
const logger = {
log: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.log(`[useTable] ${message}`, ...args)
}
},
warn: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.warn(`[useTable] ${message}`, ...args)
}
},
error: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.error(`[useTable] ${message}`, ...args)
}
}
}
// 缓存实例
const cache = enableCache ? new TableCache<TRecord>(cacheTime, maxCacheSize, enableLog) : null
// 加载状态机
type LoadingState = 'idle' | 'loading' | 'success' | 'error'
const loadingState = ref<LoadingState>('idle')
const loading = computed(() => loadingState.value === 'loading')
// 错误状态
const error = ref<TableError | null>(null)
// 表格数据
const data = ref<TRecord[]>([])
// 请求取消控制器
let abortController: AbortController | null = null
// 缓存清理定时器
let cacheCleanupTimer: NodeJS.Timeout | null = null
// 搜索参数
const searchParams = reactive(
Object.assign(
{
[pageKey]: 1,
[sizeKey]: 10
},
apiParams || {}
) as TParams
)
// 分页配置
const pagination = reactive<Api.Common.PaginationParams>({
current: ((searchParams as Record<string, unknown>)[pageKey] as number) || 1,
size: ((searchParams as Record<string, unknown>)[sizeKey] as number) || 10,
total: 0
})
// 移动端分页 (响应式)
const { width } = useWindowSize()
const mobilePagination = computed(() => ({
...pagination,
small: width.value < 768
}))
// 列配置
const columnConfig = columnsFactory ? useTableColumns<TRecord>(columnsFactory) : null
const columns = columnConfig?.columns
const columnChecks = columnConfig?.columnChecks
// 是否有数据
const hasData = computed(() => data.value.length > 0)
// 缓存统计信息
const cacheInfo = computed(() => {
// 依赖触发器,确保缓存变化时重新计算
void cacheUpdateTrigger.value
if (!cache) return { total: 0, size: '0KB', hitRate: '0 avg hits' }
return cache.getStats()
})
// 错误处理函数
const handleError = createErrorHandler(onError, enableLog)
// 清理缓存,根据不同的业务场景选择性地清理缓存
const clearCache = (strategy: CacheInvalidationStrategy, context?: string): void => {
if (!cache) return
let clearedCount = 0
switch (strategy) {
case CacheInvalidationStrategy.CLEAR_ALL:
cache.clear()
logger.log(`清空所有缓存 - ${context || ''}`)
break
case CacheInvalidationStrategy.CLEAR_CURRENT:
clearedCount = cache.clearCurrentSearch(searchParams)
logger.log(`清空当前搜索缓存 ${clearedCount} 条 - ${context || ''}`)
break
case CacheInvalidationStrategy.CLEAR_PAGINATION:
clearedCount = cache.clearPagination()
logger.log(`清空分页缓存 ${clearedCount} 条 - ${context || ''}`)
break
case CacheInvalidationStrategy.KEEP_ALL:
default:
logger.log(`保持缓存不变 - ${context || ''}`)
break
}
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
// 获取数据的核心方法
const fetchData = async (
params?: Partial<TParams>,
useCache = enableCache
): Promise<ApiResponse<TRecord>> => {
// 取消上一个请求
if (abortController) {
abortController.abort()
}
// 创建新的取消控制器
const currentController = new AbortController()
abortController = currentController
// 状态机:进入 loading 状态
loadingState.value = 'loading'
error.value = null
try {
let requestParams = Object.assign(
{},
searchParams,
{
[pageKey]: pagination.current,
[sizeKey]: pagination.size
},
params || {}
) as TParams
// 剔除不需要的参数
if (excludeParams.length > 0) {
const filteredParams = { ...requestParams }
excludeParams.forEach((key) => {
delete (filteredParams as Record<string, unknown>)[key]
})
requestParams = filteredParams as TParams
}
// 检查缓存
if (useCache && cache) {
const cachedItem = cache.get(requestParams)
if (cachedItem) {
data.value = cachedItem.data
updatePaginationFromResponse(pagination, cachedItem.response)
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size
}
// 状态机:缓存命中,进入 success 状态
loadingState.value = 'success'
// 缓存命中时触发专门的回调,而不是 onSuccess
if (onCacheHit) {
onCacheHit(cachedItem.data, cachedItem.response)
}
logger.log(`缓存命中`)
return cachedItem.response
}
}
const response = await apiFn(requestParams)
// 检查请求是否被取消
if (currentController.signal.aborted) {
throw new Error('请求已取消')
}
// 使用响应适配器转换为标准格式
const standardResponse = responseAdapter(response)
// 处理响应数据
let tableData = extractTableData(standardResponse)
// 应用数据转换函数
if (dataTransformer) {
tableData = dataTransformer(tableData)
}
// 更新状态
data.value = tableData
updatePaginationFromResponse(pagination, standardResponse)
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size
}
// 缓存数据
if (useCache && cache) {
cache.set(requestParams, tableData, standardResponse)
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
logger.log(`数据已缓存`)
}
// 状态机:请求成功,进入 success 状态
loadingState.value = 'success'
// 成功回调
if (onSuccess) {
onSuccess(tableData, standardResponse)
}
return standardResponse
} catch (err) {
if (err instanceof Error && err.message === '请求已取消') {
// 请求被取消,回到 idle 状态
loadingState.value = 'idle'
return { records: [], total: 0, current: 1, size: 10 }
}
// 状态机:请求失败,进入 error 状态
loadingState.value = 'error'
data.value = []
const tableError = handleError(err, '获取表格数据失败')
throw tableError
} finally {
// 只有当前控制器是活跃的才清空
if (abortController === currentController) {
abortController = null
}
}
}
// 获取数据 (保持当前页)
const getData = async (params?: Partial<TParams>): Promise<ApiResponse<TRecord> | void> => {
try {
return await fetchData(params)
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve()
}
}
// 分页获取数据 (重置到第一页) - 专门用于搜索场景
const getDataByPage = async (params?: Partial<TParams>): Promise<ApiResponse<TRecord> | void> => {
pagination.current = 1
;(searchParams as Record<string, unknown>)[pageKey] = 1
// 搜索时清空当前搜索条件的缓存,确保获取最新数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据')
try {
return await fetchData(params, false) // 搜索时不使用缓存
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve()
}
}
// 智能防抖搜索函数
const debouncedGetDataByPage = createSmartDebounce(getDataByPage, debounceTime)
// 重置搜索参数
const resetSearchParams = async (): Promise<void> => {
// 取消防抖的搜索
debouncedGetDataByPage.cancel()
// 保存分页相关的默认值
const paramsRecord = searchParams as Record<string, unknown>
const defaultPagination = {
[pageKey]: 1,
[sizeKey]: (paramsRecord[sizeKey] as number) || 10
}
// 清空所有搜索参数
Object.keys(searchParams).forEach((key) => {
delete paramsRecord[key]
})
// 重新设置默认参数
Object.assign(searchParams, apiParams || {}, defaultPagination)
// 重置分页
pagination.current = 1
pagination.size = defaultPagination[sizeKey] as number
// 清空错误状态
error.value = null
// 清空缓存
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '重置搜索')
// 重新获取数据
await getData()
// 执行重置回调
if (resetFormCallback) {
await nextTick()
resetFormCallback()
}
}
// 防重复调用的标志
let isCurrentChanging = false
// 处理分页大小变化
const handleSizeChange = async (newSize: number): Promise<void> => {
if (newSize <= 0) return
debouncedGetDataByPage.cancel()
const paramsRecord = searchParams as Record<string, unknown>
pagination.size = newSize
pagination.current = 1
paramsRecord[sizeKey] = newSize
paramsRecord[pageKey] = 1
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '分页大小变化')
await getData()
}
// 处理当前页变化
const handleCurrentChange = async (newCurrent: number): Promise<void> => {
if (newCurrent <= 0) return
// 修复:防止重复调用
if (isCurrentChanging) {
return
}
// 修复:如果当前页没有变化,不需要重新请求
if (pagination.current === newCurrent) {
logger.log('分页页码未变化,跳过请求')
return
}
try {
isCurrentChanging = true
// 修复:只更新必要的状态
const paramsRecord = searchParams as Record<string, unknown>
pagination.current = newCurrent
// 只有当 searchParams 的分页字段与新值不同时才更新
if (paramsRecord[pageKey] !== newCurrent) {
paramsRecord[pageKey] = newCurrent
}
await getData()
} finally {
isCurrentChanging = false
}
}
// 针对不同业务场景的刷新方法
// 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后)
const refreshCreate = async (): Promise<void> => {
debouncedGetDataByPage.cancel()
pagination.current = 1
;(searchParams as Record<string, unknown>)[pageKey] = 1
clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据')
await getData()
}
// 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后)
const refreshUpdate = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '编辑数据')
await getData()
}
// 删除后刷新:智能处理页码,避免空页面(适用于删除数据后)
const refreshRemove = async (): Promise<void> => {
const { current } = pagination
// 清除缓存并获取最新数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '删除数据')
await getData()
// 如果当前页为空且不是第一页,回到上一页
if (data.value.length === 0 && current > 1) {
pagination.current = current - 1
;(searchParams as Record<string, unknown>)[pageKey] = current - 1
await getData()
}
}
// 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮)
const refreshData = async (): Promise<void> => {
debouncedGetDataByPage.cancel()
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新')
await getData()
}
// 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新)
const refreshSoft = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '软刷新')
await getData()
}
// 取消当前请求
const cancelRequest = (): void => {
if (abortController) {
abortController.abort()
}
debouncedGetDataByPage.cancel()
}
// 清空数据
const clearData = (): void => {
data.value = []
error.value = null
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '清空数据')
}
// 清理已过期的缓存条目,释放内存空间
const clearExpiredCache = (): number => {
if (!cache) return 0
const cleanedCount = cache.cleanupExpired()
if (cleanedCount > 0) {
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
return cleanedCount
}
// 设置定期清理过期缓存
if (enableCache && cache) {
cacheCleanupTimer = setInterval(() => {
const cleanedCount = cache.cleanupExpired()
if (cleanedCount > 0) {
logger.log(`自动清理 ${cleanedCount} 条过期缓存`)
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
}, cacheTime / 2) // 每半个缓存周期清理一次
}
// 挂载时自动加载数据
if (immediate) {
onMounted(async () => {
await getData()
})
}
// 组件卸载时彻底清理
onUnmounted(() => {
cancelRequest()
if (cache) {
cache.clear()
}
if (cacheCleanupTimer) {
clearInterval(cacheCleanupTimer)
}
})
// 优化的返回值结构
return {
// 数据相关
/** 表格数据 */
data,
/** 数据加载状态 */
loading: readonly(loading),
/** 错误状态 */
error: readonly(error),
/** 数据是否为空 */
isEmpty: computed(() => data.value.length === 0),
/** 是否有数据 */
hasData,
// 分页相关
/** 分页状态信息 */
pagination: readonly(pagination),
/** 移动端分页配置 */
paginationMobile: mobilePagination,
/** 页面大小变化处理 */
handleSizeChange,
/** 当前页变化处理 */
handleCurrentChange,
// 搜索相关 - 统一前缀
/** 搜索参数 */
searchParams,
/** 重置搜索参数 */
resetSearchParams,
// 数据操作 - 更明确的操作意图
/** 加载数据 */
fetchData: getData,
/** 获取数据 */
getData: getDataByPage,
/** 获取数据(防抖) */
getDataDebounced: debouncedGetDataByPage,
/** 清空数据 */
clearData,
// 刷新策略
/** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */
refreshData,
/** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */
refreshSoft,
/** 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) */
refreshCreate,
/** 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) */
refreshUpdate,
/** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */
refreshRemove,
// 缓存控制
/** 缓存统计信息 */
cacheInfo,
/** 清除缓存,根据不同的业务场景选择性地清理缓存: */
clearCache,
// 支持4种清理策略
// clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存
// clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存
// clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存
// clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存
/** 清理已过期的缓存条目,释放内存空间 */
clearExpiredCache,
// 请求控制
/** 取消当前请求 */
cancelRequest,
// 列配置 (如果提供了 columnsFactory)
...(columnConfig && {
/** 表格列配置 */
columns,
/** 列显示控制 */
columnChecks,
/** 新增列 */
addColumn: columnConfig.addColumn,
/** 删除列 */
removeColumn: columnConfig.removeColumn,
/** 切换列显示状态 */
toggleColumn: columnConfig.toggleColumn,
/** 更新列配置 */
updateColumn: columnConfig.updateColumn,
/** 批量更新列配置 */
batchUpdateColumns: columnConfig.batchUpdateColumns,
/** 重新排序列 */
reorderColumns: columnConfig.reorderColumns,
/** 获取指定列配置 */
getColumnConfig: columnConfig.getColumnConfig,
/** 获取所有列配置 */
getAllColumns: columnConfig.getAllColumns,
/** 重置所有列配置到默认状态 */
resetColumns: columnConfig.resetColumns
})
}
}
// 重新导出类型和枚举,方便使用
export { CacheInvalidationStrategy } from '../../utils/table/tableCache'
export type { ApiResponse, CacheItem } from '../../utils/table/tableCache'
export type { BaseRequestParams, TableError } from '../../utils/table/tableUtils'
@@ -0,0 +1,312 @@
/**
* useTableColumns - 表格列配置管理
*
* 提供动态的表格列配置管理能力,支持运行时灵活控制列的显示、隐藏、排序等操作。
* 通常与 useTable 配合使用,为表格提供完整的列管理功能。
*
* ## 主要功能
*
* 1. 列显示控制 - 动态显示/隐藏列,支持批量操作
* 2. 列排序 - 拖拽或编程方式重新排列列顺序
* 3. 列配置管理 - 新增、删除、更新列配置
* 4. 特殊列支持 - 自动处理 selection、expand、index 等特殊列
* 5. 状态持久化 - 保持列的显示状态,支持重置到初始状态
*
* ## 使用示例
*
* ```typescript
* const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [
* { prop: 'name', label: '姓名', visible: true },
* { prop: 'email', label: '邮箱', visible: true },
* { prop: 'status', label: '状态', visible: false }
* ])
*
* // 切换列显示
* toggleColumn('email', false)
*
* // 重新排序
* reorderColumns(0, 2)
* ```
*
* @module useTableColumns
* @author Art Design Pro Team
*/
import { ref, computed, watch } from 'vue'
import { $t } from '@/locales'
import type { ColumnOption } from '@/types/component'
/**
* 特殊列类型
*/
const SPECIAL_COLUMNS: Record<string, { prop: string; label: string }> = {
selection: { prop: '__selection__', label: $t('table.column.selection') },
expand: { prop: '__expand__', label: $t('table.column.expand') },
index: { prop: '__index__', label: $t('table.column.index') }
}
/**
* 获取列的唯一标识
*/
export const getColumnKey = <T>(col: ColumnOption<T>) =>
SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string)
/**
* 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段
*/
export const getColumnVisibility = <T>(col: ColumnOption<T>): boolean => {
// visible 优先级高于 checked
if (col.visible !== undefined) {
return col.visible
}
// 如果 visible 未定义,使用 checked,默认为 true
return col.checked ?? true
}
/**
* 获取列的检查状态
*/
export const getColumnChecks = <T>(columns: ColumnOption<T>[]) =>
columns.map((col) => {
const special = col.type && SPECIAL_COLUMNS[col.type]
const visibility = getColumnVisibility(col)
if (special) {
return { ...col, prop: special.prop, label: special.label, checked: true, visible: true }
}
return { ...col, checked: visibility, visible: visibility }
})
/**
* 动态列配置接口
*/
export interface DynamicColumnConfig<T = any> {
/**
* 新增列(支持单个或批量)
* @param column 列配置或列配置数组
* @param index 可选的插入位置,默认末尾(批量时为第一个列的位置)
*/
addColumn: (column: ColumnOption<T> | ColumnOption<T>[], index?: number) => void
/**
* 删除列(支持单个或批量)
* @param prop 列的唯一标识或标识数组
*/
removeColumn: (prop: string | string[]) => void
/**
* 切换列显示状态(支持单个或批量)
* @param prop 列的唯一标识或标识数组
* @param visible 可选的显示状态,默认取反
*/
toggleColumn: (prop: string | string[], visible?: boolean) => void
/**
* 更新列(支持单个或批量)
* @param prop 列的唯一标识或更新配置数组
* @param updates 列配置更新(当 prop 为字符串时使用)
*/
updateColumn: (
prop: string | Array<{ prop: string; updates: Partial<ColumnOption<T>> }>,
updates?: Partial<ColumnOption<T>>
) => void
/**
* 批量更新列(兼容旧版本,推荐使用 updateColumn 的数组模式)
* @param updates 列更新配置
* @deprecated 推荐使用 updateColumn 的数组模式
*/
batchUpdateColumns: (updates: Array<{ prop: string; updates: Partial<ColumnOption<T>> }>) => void
/**
* 重新排序列
* @param fromIndex 源索引
* @param toIndex 目标索引
*/
reorderColumns: (fromIndex: number, toIndex: number) => void
/**
* 获取列配置
* @param prop 列的唯一标识
* @returns 列配置
*/
getColumnConfig: (prop: string) => ColumnOption<T> | undefined
/**
* 获取所有列配置
* @returns 所有列配置
*/
getAllColumns: () => ColumnOption<T>[]
/**
* 重置所有列
*/
resetColumns: () => void
}
export function useTableColumns<T = any>(
columnsFactory: () => ColumnOption<T>[]
): {
columns: any
columnChecks: any
} & DynamicColumnConfig<T> {
const dynamicColumns = ref<ColumnOption<T>[]>(columnsFactory())
const columnChecks = ref<ColumnOption<T>[]>(getColumnChecks(dynamicColumns.value))
// 当 dynamicColumns 变动时,重新生成 columnChecks 且保留已存在的显示状态
watch(
dynamicColumns,
(newCols) => {
const visibilityMap = new Map(
columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)])
)
const newChecks = getColumnChecks(newCols).map((c) => {
const key = getColumnKey(c)
const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c)
return {
...c,
checked: visibility,
visible: visibility
}
})
columnChecks.value = newChecks
},
{ deep: true }
)
// 当前显示列(基于 columnChecks 的 checked 或 visible)
const columns = computed(() => {
const colMap = new Map(dynamicColumns.value.map((c) => [getColumnKey(c), c]))
return columnChecks.value
.filter((c) => getColumnVisibility(c))
.map((c) => colMap.get(getColumnKey(c)))
.filter(Boolean) as ColumnOption<T>[]
})
// 支持 updater 返回新数组或直接在传入数组上 mutate
const setDynamicColumns = (updater: (cols: ColumnOption<T>[]) => void | ColumnOption<T>[]) => {
const copy = [...dynamicColumns.value]
const result = updater(copy)
dynamicColumns.value = Array.isArray(result) ? result : copy
}
return {
columns,
columnChecks,
/**
* 新增列(支持单个或批量)
*/
addColumn: (column: ColumnOption<T> | ColumnOption<T>[], index?: number) =>
setDynamicColumns((cols) => {
const next = [...cols]
const columnsToAdd = Array.isArray(column) ? column : [column]
const insertIndex =
typeof index === 'number' && index >= 0 && index <= next.length ? index : next.length
// 批量插入
next.splice(insertIndex, 0, ...columnsToAdd)
return next
}),
/**
* 删除列(支持单个或批量)
*/
removeColumn: (prop: string | string[]) =>
setDynamicColumns((cols) => {
const propsToRemove = Array.isArray(prop) ? prop : [prop]
return cols.filter((c) => !propsToRemove.includes(getColumnKey(c)))
}),
/**
* 更新列(支持单个或批量)
*/
updateColumn: (
prop: string | Array<{ prop: string; updates: Partial<ColumnOption<T>> }>,
updates?: Partial<ColumnOption<T>>
) => {
// 批量模式:prop 是数组
if (Array.isArray(prop)) {
setDynamicColumns((cols) => {
const map = new Map(prop.map((u) => [u.prop, u.updates]))
return cols.map((c) => {
const key = getColumnKey(c)
const upd = map.get(key)
return upd ? { ...c, ...upd } : c
})
})
}
// 单个模式:prop 是字符串
else if (updates) {
setDynamicColumns((cols) =>
cols.map((c) => (getColumnKey(c) === prop ? { ...c, ...updates } : c))
)
}
},
/**
* 切换列显示状态(支持单个或批量)
*/
toggleColumn: (prop: string | string[], visible?: boolean) => {
const propsToToggle = Array.isArray(prop) ? prop : [prop]
const next = [...columnChecks.value]
propsToToggle.forEach((p) => {
const i = next.findIndex((c) => getColumnKey(c) === p)
if (i > -1) {
const currentVisibility = getColumnVisibility(next[i])
const newVisibility = visible ?? !currentVisibility
// 同时更新 checked 和 visible 以保持兼容性
next[i] = { ...next[i], checked: newVisibility, visible: newVisibility }
}
})
columnChecks.value = next
},
/**
* 重置所有列
*/
resetColumns: () => {
dynamicColumns.value = columnsFactory()
},
/**
* 批量更新列(兼容旧版本)
* @deprecated 推荐使用 updateColumn 的数组模式
*/
batchUpdateColumns: (updates) =>
setDynamicColumns((cols) => {
const map = new Map(updates.map((u) => [u.prop, u.updates]))
return cols.map((c) => {
const key = getColumnKey(c)
const upd = map.get(key)
return upd ? { ...c, ...upd } : c
})
}),
/**
* 重新排序列
*/
reorderColumns: (fromIndex: number, toIndex: number) =>
setDynamicColumns((cols) => {
if (
fromIndex < 0 ||
fromIndex >= cols.length ||
toIndex < 0 ||
toIndex >= cols.length ||
fromIndex === toIndex
) {
return cols
}
const next = [...cols]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}),
/**
* 获取列配置
*/
getColumnConfig: (prop: string) => dynamicColumns.value.find((c) => getColumnKey(c) === prop),
/**
* 获取所有列配置
*/
getAllColumns: () => [...dynamicColumns.value]
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* useTableHeight - 表格高度自动计算
*
* 自动计算表格容器的最佳高度,确保表格在不同布局场景下都能正确显示。
* 根据表格头部、分页器等元素的高度动态调整容器高度,避免出现滚动条或布局错乱。
*
* ## 主要功能
*
* 1. 动态高度计算 - 根据表格头部、分页器高度自动计算容器高度
* 2. 响应式更新 - 配置变化时自动重新计算高度
* 3. 灵活配置 - 支持自定义各部分高度和间距
* 4. 智能适配 - 无额外元素时自动使用 100% 高度
*
* @module useTableHeight
* @author Art Design Pro Team
*/
import { computed, type Ref } from 'vue'
/**
* 表格高度计算器配置接口
*/
interface TableHeightOptions {
/** 是否显示表格头部 */
showTableHeader: Ref<boolean>
/** 分页器高度 */
paginationHeight: Ref<number>
/** 表格头部高度 */
tableHeaderHeight: Ref<number>
/** 分页器间距 */
paginationSpacing: Ref<number>
}
/**
* 表格高度计算器类
*/
class TableHeightCalculator {
// 常量配置
private static readonly DEFAULT_TABLE_HEADER_HEIGHT = 44
private static readonly TABLE_HEADER_SPACING = 12
constructor(private options: TableHeightOptions) {}
/**
* 计算容器高度
*/
calculate(): { height: string } {
const offset = this.calculateOffset()
return {
height: offset === 0 ? '100%' : `calc(100% - ${offset}px)`
}
}
/**
* 计算偏移量
*/
private calculateOffset(): number {
if (!this.options.showTableHeader.value) {
return this.calculatePaginationOffset()
}
const headerHeight = this.getHeaderHeight()
const paginationOffset = this.calculatePaginationOffset()
return headerHeight + paginationOffset + TableHeightCalculator.TABLE_HEADER_SPACING
}
/**
* 获取表格头部高度
*/
private getHeaderHeight(): number {
return this.options.tableHeaderHeight.value || TableHeightCalculator.DEFAULT_TABLE_HEADER_HEIGHT
}
/**
* 计算分页器偏移量
*/
private calculatePaginationOffset(): number {
const { paginationHeight, paginationSpacing } = this.options
return paginationHeight.value === 0 ? 0 : paginationHeight.value + paginationSpacing.value
}
}
/**
* 表格高度计算 Hook
*
* 提供表格容器高度的自动计算功能,支持:
* - 表格头部高度
* - 分页器高度
* - 动态间距计算
*
* @param options 配置选项
* @returns 容器高度计算结果
*/
export function useTableHeight(options: TableHeightOptions) {
const containerHeight = computed(() => {
const calculator = new TableHeightCalculator(options)
return calculator.calculate()
})
return {
/** 容器高度样式对象 */
containerHeight
}
}
+174
View File
@@ -0,0 +1,174 @@
/**
* useTheme - 系统主题管理
*
* 提供完整的主题切换和管理功能,支持亮色、暗色和自动模式。
* 自动处理主题切换时的过渡效果,确保切换流畅无闪烁。
*
* ## 主要功能
*
* 1. 主题切换 - 支持亮色、暗色、自动三种主题模式
* 2. 自动模式 - 根据系统偏好自动切换主题
* 3. 颜色适配 - 自动调整主题色的明暗变体(9 个层级)
* 4. 过渡优化 - 切换时临时禁用过渡效果,避免闪烁
* 5. 状态持久化 - 主题设置自动保存到 store
*
* ## 使用示例
*
* ```typescript
* const { switchThemeStyles } = useTheme()
*
* // 切换到暗色主题
* switchThemeStyles(SystemThemeEnum.DARK)
*
* // 切换到亮色主题
* switchThemeStyles(SystemThemeEnum.LIGHT)
*
* // 切换到自动模式(跟随系统)
* switchThemeStyles(SystemThemeEnum.AUTO)
* ```
*
* @module useTheme
* @author Art Design Pro Team
*/
import { useSettingStore } from '@/store/modules/setting'
import { SystemThemeEnum } from '@/enums/appEnum'
import AppConfig from '@/config'
import { SystemThemeTypes } from '@/types/store'
import { getDarkColor, getLightColor, setElementThemeColor } from '@/utils/ui'
import { usePreferredDark } from '@vueuse/core'
import { watch } from 'vue'
export function useTheme() {
const settingStore = useSettingStore()
// 禁用过渡效果
const disableTransitions = () => {
const style = document.createElement('style')
style.setAttribute('id', 'disable-transitions')
style.textContent = '* { transition: none !important; }'
document.head.appendChild(style)
}
// 启用过渡效果
const enableTransitions = () => {
const style = document.getElementById('disable-transitions')
if (style) {
style.remove()
}
}
// 设置系统主题
const setSystemTheme = (theme: SystemThemeEnum, themeMode?: SystemThemeEnum) => {
// 临时禁用过渡效果
disableTransitions()
const el = document.getElementsByTagName('html')[0]
const isDark = theme === SystemThemeEnum.DARK
if (!themeMode) {
themeMode = theme
}
const currentTheme = AppConfig.systemThemeStyles[theme as keyof SystemThemeTypes]
if (currentTheme) {
el.setAttribute('class', currentTheme.className)
}
// 设置按钮颜色加深或变浅
const primary = settingStore.systemThemeColor
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
isDark ? `${getDarkColor(primary, i / 10)}` : `${getLightColor(primary, i / 10)}`
)
}
// 更新store中的主题设置
settingStore.setGlopTheme(theme, themeMode)
// 使用 requestAnimationFrame 确保在下一帧恢复过渡效果
requestAnimationFrame(() => {
requestAnimationFrame(() => {
enableTransitions()
})
})
}
// 使用 VueUse 的 usePreferredDark 检测系统主题偏好
const prefersDark = usePreferredDark()
// 自动设置系统主题
const setSystemAutoTheme = () => {
const theme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT
setSystemTheme(theme, SystemThemeEnum.AUTO)
}
// 切换主题
const switchThemeStyles = (theme: SystemThemeEnum) => {
if (theme === SystemThemeEnum.AUTO) {
setSystemAutoTheme()
} else {
setSystemTheme(theme)
}
}
return {
setSystemTheme,
setSystemAutoTheme,
switchThemeStyles,
prefersDark
}
}
/**
* 初始化主题系统
*/
export function initializeTheme() {
const settingStore = useSettingStore()
const prefersDark = usePreferredDark()
// 根据系统偏好应用主题
const applyThemeByMode = () => {
const el = document.getElementsByTagName('html')[0]
let actualTheme = settingStore.systemThemeType
// 如果是 AUTO 模式,检测系统偏好
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
actualTheme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT
// 更新实际应用的主题类型
settingStore.systemThemeType = actualTheme
}
// 设置主题 class
const currentTheme = AppConfig.systemThemeStyles[actualTheme as keyof SystemThemeTypes]
if (currentTheme) {
el.setAttribute('class', currentTheme.className)
}
// 设置主题颜色
setElementThemeColor(settingStore.systemThemeColor)
// 设置圆角
document.documentElement.style.setProperty('--custom-radius', `${settingStore.customRadius}rem`)
}
// 应用主题
applyThemeByMode()
// 如果是 AUTO 模式,监听系统主题变化(使用 VueUse 的响应式特性)
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
watch(
prefersDark,
() => {
// 只有在 AUTO 模式下才响应系统主题变化
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
applyThemeByMode()
}
},
{ immediate: false }
)
}
}
+29
View File
@@ -0,0 +1,29 @@
// 通用功能集合
export { useCommon } from './core/useCommon'
// 应用模式
export { useAppMode } from './core/useAppMode'
// 权限控制
export { useAuth } from './core/useAuth'
// 表格数据管理方案
export { useTable } from './core/useTable'
// 表格列配置管理
export { useTableColumns } from './core/useTableColumns'
// 主题相关
export { useTheme } from './core/useTheme'
// 礼花+文字滚动
export { useCeremony } from './core/useCeremony'
// 顶栏快速入口
export { useFastEnter } from './core/useFastEnter'
// 顶栏功能管理
export { useHeaderBar } from './core/useHeaderBar'
// 布局高度
export { useLayoutHeight, useAutoLayoutHeight } from './core/useLayoutHeight'