feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
+217
View File
@@ -0,0 +1,217 @@
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, onBeforeUnmount, nextTick } from "vue";
import AiChatAPI from "@/api/module_ai/chat";
import type { UseAiActionOptions } from "@/types/ai";
/**
* AI 操作 Composable
*
* 统一处理 AI 助手传递的操作,支持:
* - 自动搜索(通过 keywords + autoSearch 参数)
* - 执行 AI 操作(通过 aiAction 参数)
* - 配置化的操作处理器
*/
export function useAiAction(options: UseAiActionOptions = {}) {
const route = useRoute();
const { actionHandlers = {}, onRefresh, onAutoSearch } = options;
// 用于跟踪是否已卸载,防止在卸载后执行回调
let isUnmounted = false;
/**
* 执行 AI 操作(统一处理确认、执行、反馈流程)
*/
async function executeAiAction(action: any) {
if (isUnmounted) return;
// 兼容两种入参:{ functionName, arguments } 或 { functionCall: { name, arguments } }
const fnCall = action.functionCall ?? {
name: action.functionName,
arguments: action.arguments,
};
if (!fnCall?.name) {
ElMessage.warning("未识别的 AI 操作");
return;
}
// 查找对应的处理器
const handler = actionHandlers[fnCall.name];
if (!handler) {
ElMessage.warning(`暂不支持操作: ${fnCall.name}`);
return;
}
try {
// 判断处理器类型(函数 or 配置对象)
const isSimpleFunction = typeof handler === "function";
if (isSimpleFunction) {
// 简单函数形式:直接执行
await handler(fnCall.arguments);
} else {
// 配置对象形式:统一处理确认、执行、反馈
const config = handler;
// 1. 确认阶段(默认需要确认)
if (config.needConfirm !== false) {
const confirmMsg =
typeof config.confirmMessage === "function"
? config.confirmMessage(fnCall.arguments)
: config.confirmMessage || "确认执行此操作吗?";
await ElMessageBox.confirm(confirmMsg, "AI 助手操作确认", {
confirmButtonText: "确认执行",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
});
}
// 2. 执行阶段
if (config.callBackendApi) {
// 自动调用后端 API
await AiChatAPI.chat({
message: fnCall.arguments,
});
} else {
// 执行自定义函数
await config.execute(fnCall.arguments);
}
// 3. 成功反馈
const successMsg =
typeof config.successMessage === "function"
? config.successMessage(fnCall.arguments)
: config.successMessage || "操作执行成功";
ElMessage.success(successMsg);
}
// 4. 刷新数据
if (onRefresh) {
await onRefresh();
}
} catch (error: any) {
// 处理取消操作
if (error === "cancel") {
ElMessage.info("已取消操作");
return;
}
console.error("AI 操作执行失败:", error);
ElMessage.error(error.message || "操作执行失败");
}
}
/**
* 执行后端命令(通用方法)
*/
async function executeCommand(
functionName: string,
args: any,
options: {
originalCommand?: string;
confirmMode?: "auto" | "manual";
needConfirm?: boolean;
confirmMessage?: string;
} = {}
) {
const { originalCommand = "", needConfirm = false, confirmMessage } = options;
// 如果需要确认,先显示确认对话框
if (needConfirm && confirmMessage) {
try {
await ElMessageBox.confirm(confirmMessage, "AI 助手操作确认", {
confirmButtonText: "确认执行",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
});
} catch {
ElMessage.info("已取消操作");
return;
}
}
try {
await AiChatAPI.chat({
message: originalCommand,
});
ElMessage.success("操作执行成功");
} catch (error: any) {
if (error !== "cancel") {
throw error;
}
}
}
/**
* 处理自动搜索
*/
function handleAutoSearch(keywords: string) {
if (onAutoSearch) {
onAutoSearch(keywords);
} else {
ElMessage.info(`AI 助手已为您自动搜索:${keywords}`);
}
}
/**
* 初始化:处理 URL 参数中的 AI 操作
*
* 注意:此方法只处理 AI 相关参数,不负责页面数据的初始加载
* 页面数据加载应由组件的 onMounted 钩子自行处理
*/
async function init() {
if (isUnmounted) return;
// 检查是否有 AI 助手传递的参数
const keywords = route.query.keywords as string;
const autoSearch = route.query.autoSearch as string;
const aiActionParam = route.query.aiAction as string;
// 如果没有任何 AI 参数,直接返回
if (!keywords && !autoSearch && !aiActionParam) {
return;
}
// 在 nextTick 中执行,确保页面数据已加载
nextTick(async () => {
if (isUnmounted) return;
// 1. 处理自动搜索
if (autoSearch === "true" && keywords) {
handleAutoSearch(keywords);
}
// 2. 处理 AI 操作
if (aiActionParam) {
try {
const aiAction = JSON.parse(decodeURIComponent(aiActionParam));
await executeAiAction(aiAction);
} catch (error) {
console.error("解析 AI 操作失败:", error);
ElMessage.error("AI 操作参数解析失败");
}
}
});
}
// 组件挂载时自动初始化
onMounted(() => {
init();
});
// 组件卸载时清理
onBeforeUnmount(() => {
isUnmounted = true;
});
return {
executeAiAction,
executeCommand,
handleAutoSearch,
};
}
+39
View File
@@ -0,0 +1,39 @@
/**
* useAppMode - 应用模式管理
*
* 提供应用访问模式的判断和管理功能,支持前端和后端两种权限控制模式。
* 根据环境变量 VITE_ACCESS_MODE 自动识别当前运行模式:
* frontend | backend | mixed(后端菜单 + 前端 builtinFrontendRoutes 合并展示)
*
* ## 主要功能
*
* 1. 模式识别 - 自动识别前端模式或后端模式
* 2. 前端模式 - 权限由前端路由配置控制,适合小型项目或演示环境
* 3. 后端模式 - 权限由后端接口返回的菜单数据控制,适合企业级应用
* 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用
*
* @module useAppMode
* @author FastapiAdmin Team
*/
import { computed } from "vue";
export function useAppMode() {
const accessMode = (import.meta.env.VITE_ACCESS_MODE || "").trim();
/** 仅前端 builtinFrontendRoutes(默认为空,壳层菜单由 mergeShellRoutesIntoMenu 补全) */
const isFrontendMode = computed(() => accessMode === "frontend");
/** 仅后端菜单接口 */
const isBackendMode = computed(() => accessMode === "backend");
/** 后端菜单与前端路由模块合并(同名路由以前端模块注册顺序中后端为先,见 MenuProcessor) */
const isMixedMenuMode = computed(() => accessMode === "mixed");
const currentMode = computed(() => accessMode);
return {
isFrontendMode,
isBackendMode,
isMixedMenuMode,
currentMode,
};
}
+85
View File
@@ -0,0 +1,85 @@
/**
* 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 FastapiAdmin Team
*/
import { useRoute } from "vue-router";
import { storeToRefs } from "pinia";
import { useUserStore } from "@stores/modules/user.store";
import { useAppMode } from "@/hooks/core/useAppMode";
import type { AppRouteRecord } from "@/types/router";
import { ROLE_ROOT } from "@/constants";
type AuthItem = NonNullable<AppRouteRecord["meta"]["authList"]>[number];
export const useAuth = () => {
const route = useRoute();
const userStore = useUserStore();
const { isFrontendMode } = useAppMode();
const { info } = storeToRefs(userStore);
// 前端按钮权限(例如:['add', 'edit'])
const frontendAuthList: string[] = ((info.value as any)?.permissions as string[]) ?? [];
// 后端路由 meta 配置的权限列表(例如:[{ authMark: 'add' }])
const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList)
? (route.meta.authList as AuthItem[])
: [];
/**
* 检查是否拥有某权限标识(与 v-hasPerm / 表格内 ArtButtonMore 全码一致)
* @param auth 权限标识(多为后端菜单 permission 全码,如 module_system:role:update)
*/
const hasAuth = (auth: string): boolean => {
if (!auth) return true;
const userPrems = userStore.prems;
const roles = userStore.basicInfo?.roles as { code?: string }[] | undefined;
// 与 directives/permission hasPerm 一致:超级管理员、通配
if (roles?.some((r) => r.code === ROLE_ROOT)) return true;
if (userPrems.includes("*:*:*")) return true;
/** 登录后由菜单树汇总的全局权限码(与 v-hasPerm 同源) */
if (userPrems.includes(auth)) return true;
// 前端模式:兼容 info.permissions
if (isFrontendMode.value) {
return frontendAuthList.includes(auth);
}
// 后端模式:当前路由 meta 短标识(与 v-auth 一致)
return backendAuthList.some((item) => item?.authMark === auth);
};
return {
hasAuth,
};
};
+308
View File
@@ -0,0 +1,308 @@
/**
* useCeremony - 节日庆祝管理
*
* 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。
* 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。
*
* ## 主要功能
*
* 1. 节日检测 - 「内置 + 手动」节日优先;其次周六/日周末提示;最后 `isResidentBanner` 常驻。同级节日区间更短优先,同长度手动优先于内置
* 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数
* 3. 祝福文本 - 烟花结束后显示节日祝福文本
* 4. 状态管理 - 记录烟花播放状态,避免重复播放
* 5. 清理机制 - 提供清理方法,支持手动停止和重置
*
* ## 手动礼花快捷键
*
* 全局 **Ctrl+Shift+P** / **⌘+Shift+P** 由 `art-fireworks-effect` 监听;礼花图与当天节日配置一致,
* 详见 `src/config/modules/festival.ts` 文件头说明。
*
* ## 使用示例
*
* ```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 FastapiAdmin Team
*/
import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { mittBus } from "@utils/sys";
import { festivalConfigList } from "@/config/modules/festival";
import { buildBuiltinSolarFestivals } from "@/config/modules/festival.builtin";
import type { FestivalConfig } from "@/types/config";
import { formatToDate } from "@utils/common";
/** 手动配置 + 内置公历节日合并项(内部排序用) */
type TaggedFestival = FestivalConfig & { _origin: "manual" | "builtin" };
/**
* 节日庆祝配置常量
*/
const FESTIVAL_CONFIG = {
/** 初始延迟(毫秒) */
INITIAL_DELAY: 300,
/** 烟花播放间隔(毫秒) */
FIREWORK_INTERVAL: 1000,
/** 文本显示延迟(毫秒) */
TEXT_DELAY: 2000,
/** 默认烟花播放次数 */
DEFAULT_FIREWORKS_COUNT: 3,
} as const;
/**
* 节日庆祝功能
* 提供节日烟花效果和祝福文本展示
*/
export function useCeremony() {
const settingStore = useSettingsStore();
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 festivalRangeSpanDays = (item: FestivalConfig): number => {
if (!item.endDate) return 1;
const s = new Date(item.date).getTime();
const e = new Date(item.endDate).getTime();
return Math.max(1, Math.round((e - s) / 86400000) + 1);
};
/** 是否为周六、周日(本地时区) */
const isWeekendDate = (dateStr: string): boolean => {
const w = new Date(dateStr).getDay();
return w === 0 || w === 6;
};
/** 无其它节日命中时的周末顶栏(仅文案,与常驻同为 skipFireworks) */
const buildWeekendPrompt = (dateStr: string): FestivalConfig => {
const w = new Date(dateStr).getDay();
const isSat = w === 6;
return {
name: isSat ? "周六愉快" : "周日愉快",
date: dateStr,
image: "",
scrollText: isSat
? "🌤️ 周末到啦,适当放松,记得分支与构建状态也要照顾好~"
: "🌅 周日休整好,新一周迭代加油。",
skipFireworks: true,
count: 3,
};
};
const pickFestivalWinner = (candidates: TaggedFestival[]): TaggedFestival | undefined => {
if (!candidates.length) return undefined;
const sorted = [...candidates];
sorted.sort((a, b) => {
const spanDiff = festivalRangeSpanDays(a) - festivalRangeSpanDays(b);
if (spanDiff !== 0) return spanDiff;
if (a._origin !== b._origin) return a._origin === "manual" ? -1 : 1;
return 0;
});
return sorted[0];
};
/**
* 内置公历 + 手动配置(当年)
*/
const mergedFestivalEntries = computed<TaggedFestival[]>(() => {
const year = new Date().getFullYear();
const builtin = buildBuiltinSolarFestivals(year).map((f) => ({
...f,
_origin: "builtin" as const,
}));
const manual = festivalConfigList.map((f) => ({
...f,
_origin: "manual" as const,
}));
return [...builtin, ...manual];
});
/**
* 当前展示配置:节日(非常驻)> 周末提示 > 常驻 `isResidentBanner`
*/
const currentFestivalData = computed((): FestivalConfig | undefined => {
const currentDate = useDateFormat(new Date(), "YYYY-MM-DD").value;
const matches = mergedFestivalEntries.value.filter((item) =>
isDateInRange(currentDate, item.date, item.endDate)
);
const nonResident = matches.filter((m) => !m.isResidentBanner);
const winnerTagged = pickFestivalWinner(nonResident);
if (winnerTagged) {
const row = { ...winnerTagged };
delete (row as Partial<TaggedFestival>)._origin;
return row as FestivalConfig;
}
if (isWeekendDate(currentDate)) {
return buildWeekendPrompt(currentDate);
}
const residents = matches.filter((m) => m.isResidentBanner);
const residentWinner = pickFestivalWinner(residents);
if (residentWinner) {
const row = { ...residentWinner };
delete (row as Partial<TaggedFestival>)._origin;
return row as FestivalConfig;
}
return undefined;
});
/**
* 标记「今日已完成烟花流程」,用于 store 按自然日防抖(与节日配置的 date 字段无关)
*/
const updateFestivalDate = () => {
settingStore.setFestivalDate(formatToDate(new 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 = () => {
const cur = currentFestivalData.value;
let playedCount = 0;
const count = cur?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT;
const intervalMs = cur?.fireworkInterval ?? FESTIVAL_CONFIG.FIREWORK_INTERVAL;
const { pause } = useIntervalFn(() => {
triggerFirework();
playedCount++;
if (playedCount >= count) {
pause();
showFestivalText();
}
}, intervalMs);
fireworksInterval = { pause };
};
/**
* 顶栏纯文案关闭(仅 skipFireworks)持久化 key
*/
const festivalScrollDismissKey = (cur: FestivalConfig): string =>
`festival_scroll_dismissed_${cur.date}_${cur.endDate ?? ""}`;
/**
* 开启节日庆祝:skipFireworks 时直接显示滚动条;否则走烟花再出字
*/
const openFestival = () => {
const cur = currentFestivalData.value;
if (!cur) return;
if (cur.skipFireworks) {
if (
typeof localStorage !== "undefined" &&
localStorage.getItem(festivalScrollDismissKey(cur)) === "1"
) {
return;
}
settingStore.setShowFestivalText(true);
return;
}
if (!isShowFireworks.value) {
return;
}
const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY);
start();
};
/**
* 清理烟花效果
*/
const cleanup = () => {
if (fireworksInterval) {
fireworksInterval.pause();
fireworksInterval = null;
}
settingStore.setShowFestivalText(false);
};
/** 关闭顶栏滚动:skipFireworks 时写入 localStorage,下次不再自动展开 */
const closeFestivalScroll = () => {
const cur = currentFestivalData.value;
settingStore.setShowFestivalText(false);
if (cur?.skipFireworks && typeof localStorage !== "undefined") {
localStorage.setItem(festivalScrollDismissKey(cur), "1");
}
};
return {
openFestival,
cleanup,
holidayFireworksLoaded,
currentFestivalData,
isShowFireworks,
festivalScrollDismissKey,
closeFestivalScroll,
};
}
+745
View File
@@ -0,0 +1,745 @@
/**
* useChart - ECharts 图表管理
*
* 提供完整的 ECharts 图表生命周期管理和配置能力,简化图表开发流程。
* 自动处理图表初始化、更新、销毁、主题切换、响应式调整等复杂逻辑。
*
* ## 核心功能
*
* 1. 图表生命周期管理 - 自动处理初始化、更新、销毁,支持延迟加载和可见性检测
* 2. 主题自动适配 - 响应系统主题变化,自动更新图表样式和配色
* 3. 响应式调整 - 监听窗口大小、菜单展开等变化,自动调整图表尺寸
* 4. 空状态处理 - 优雅的空数据展示,自动显示"暂无数据"提示
* 5. 样式配置统一 - 提供坐标轴、图例、提示框等统一的样式配置方法
* 6. 性能优化 - 防抖处理、样式缓存、requestAnimationFrame 优化
* 7. 高级组件抽象 - useChartComponent 提供更高层次的图表组件封装
*
* ## 使用示例
*
* ```typescript
* // 基础用法
* const {
* chartRef,
* initChart,
* updateChart,
* getAxisLineStyle,
* getTooltipStyle
* } = useChart()
*
* onMounted(() => {
* initChart({
* xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
* yAxis: { type: 'value' },
* series: [{ data: [120, 200, 150], type: 'bar' }]
* })
* })
*
* // 高级用法 - 组件抽象
* const chart = useChartComponent({
* props,
* generateOptions: () => ({
* // ECharts 配置
* }),
* checkEmpty: () => data.value.length === 0,
* watchSources: [() => props.data]
* })
* ```
*
* @module useChart
* @author FastapiAdmin Team
*/
import { echarts, type EChartsOption } from "@/plugins/echarts";
import { storeToRefs } from "pinia";
import { useSettingsStore } from "@stores/modules/setting.store";
import { getCssVar } from "@utils/ui";
import type { BaseChartProps, ChartThemeConfig, UseChartOptions } from "@/types/component/chart";
// 图表主题配置
export const useChartOps = (): ChartThemeConfig => ({
/** */
chartHeight: "16rem",
/** 字体大小 */
fontSize: 13,
/** 字体颜色 */
fontColor: "#999",
/** 主题颜色 */
themeColor: getCssVar("--el-color-primary-light-1"),
/** 颜色组 */
colors: [
getCssVar("--el-color-primary-light-1"),
"#4ABEFF",
"#EDF2FF",
"#14DEBA",
"#FFAF20",
"#FA8A6C",
"#FFAF20",
],
});
// 常量定义
const RESIZE_DELAYS = [50, 100, 200, 350] as const;
const MENU_RESIZE_DELAYS = [50, 100, 200] as const;
const RESIZE_DEBOUNCE_DELAY = 100;
export function useChart(options: UseChartOptions = {}) {
const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options;
const settingStore = useSettingsStore();
const { isDark, menuOpen, menuType } = storeToRefs(settingStore);
const chartRef = ref<HTMLElement>();
let chart: echarts.ECharts | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let pendingOptions: EChartsOption | null = null;
let resizeTimeoutId: number | null = null;
let resizeFrameId: number | null = null;
let isDestroyed = false;
let emptyStateDiv: HTMLElement | null = null;
// 清理定时器的统一方法
const clearTimers = () => {
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
resizeTimeoutId = null;
}
if (resizeFrameId) {
cancelAnimationFrame(resizeFrameId);
resizeFrameId = null;
}
};
// 使用 requestAnimationFrame 优化 resize 处理
const requestAnimationResize = () => {
if (resizeFrameId) {
cancelAnimationFrame(resizeFrameId);
}
resizeFrameId = requestAnimationFrame(() => {
handleResize();
resizeFrameId = null;
});
};
// 防抖的resize处理(用于窗口resize事件)
const debouncedResize = () => {
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
}
resizeTimeoutId = window.setTimeout(() => {
requestAnimationResize();
resizeTimeoutId = null;
}, RESIZE_DEBOUNCE_DELAY);
};
// 多延迟resize处理 - 统一方法
const multiDelayResize = (delays: readonly number[]) => {
// 立即调用一次,快速响应
nextTick(requestAnimationResize);
// 使用延迟时间,确保图表正确适应变化
delays.forEach((delay) => {
setTimeout(requestAnimationResize, delay);
});
};
// 收缩菜单时,重新计算图表大小(仅在图表存在时监听)
let menuOpenStopHandle: (() => void) | null = null;
let menuTypeStopHandle: (() => void) | null = null;
const setupMenuWatchers = () => {
menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS));
menuTypeStopHandle = watch(menuType, () => {
nextTick(requestAnimationResize);
setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0);
});
};
const cleanupMenuWatchers = () => {
menuOpenStopHandle?.();
menuTypeStopHandle?.();
menuOpenStopHandle = null;
menuTypeStopHandle = null;
};
// 主题变化时重新设置图表选项
let themeStopHandle: (() => void) | null = null;
const setupThemeWatcher = () => {
if (autoTheme) {
themeStopHandle = watch(isDark, () => {
// 更新空状态样式
emptyStateManager.updateStyle();
if (chart && !isDestroyed) {
// 使用 requestAnimationFrame 优化主题更新
requestAnimationFrame(() => {
if (chart && !isDestroyed) {
const currentOptions = chart.getOption();
if (currentOptions) {
updateChart(currentOptions as EChartsOption);
}
}
});
}
});
}
};
const cleanupThemeWatcher = () => {
themeStopHandle?.();
themeStopHandle = null;
};
// 样式生成器 - 统一的样式配置
const createLineStyle = (color: string, width = 1, type?: "solid" | "dashed") => ({
color,
width,
...(type && { type }),
});
// 缓存样式配置以减少重复计算
const styleCache = {
axisLine: null as any,
splitLine: null as any,
axisLabel: null as any,
lastDarkValue: isDark.value,
};
const clearStyleCache = () => {
styleCache.axisLine = null;
styleCache.splitLine = null;
styleCache.axisLabel = null;
styleCache.lastDarkValue = isDark.value;
};
// 坐标轴线样式
const getAxisLineStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache();
}
if (!styleCache.axisLine) {
styleCache.axisLine = {
show,
lineStyle: createLineStyle(isDark.value ? "#444" : "#EDEDED"),
};
}
return styleCache.axisLine;
};
// 分割线样式
const getSplitLineStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache();
}
if (!styleCache.splitLine) {
styleCache.splitLine = {
show,
lineStyle: createLineStyle(isDark.value ? "#444" : "#EDEDED", 1, "dashed"),
};
}
return styleCache.splitLine;
};
// 坐标轴标签样式
const getAxisLabelStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache();
}
if (!styleCache.axisLabel) {
const { fontColor, fontSize } = useChartOps();
styleCache.axisLabel = {
show,
color: fontColor,
fontSize,
};
}
return styleCache.axisLabel;
};
// 坐标轴刻度样式(静态配置,无需缓存)
const getAxisTickStyle = () => ({
show: false,
});
// 获取动画配置
const getAnimationConfig = (animationDelay: number = 50, animationDuration: number = 1500) => ({
animationDelay: (idx: number) => idx * animationDelay + 200,
animationDuration: (idx: number) => animationDuration - idx * 50,
animationEasing: "quarticOut" as const,
});
// 获取统一的 tooltip 配置
const getTooltipStyle = (trigger: "item" | "axis" = "axis", customOptions: any = {}) => ({
trigger,
backgroundColor: isDark.value ? "rgba(0, 0, 0, 0.8)" : "rgba(255, 255, 255, 0.9)",
borderColor: isDark.value ? "#333" : "#ddd",
borderWidth: 1,
textStyle: {
color: isDark.value ? "#fff" : "#333",
},
...customOptions,
});
// 获取统一的图例配置
const getLegendStyle = (
position: "bottom" | "top" | "left" | "right" = "bottom",
customOptions: any = {}
) => {
const baseConfig = {
textStyle: {
color: isDark.value ? "#fff" : "#333",
},
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
...customOptions,
};
// 根据位置设置不同的配置
switch (position) {
case "bottom":
return {
...baseConfig,
bottom: 0,
left: "center",
orient: "horizontal",
icon: "roundRect",
};
case "top":
return {
...baseConfig,
top: 0,
left: "center",
orient: "horizontal",
icon: "roundRect",
};
case "left":
return {
...baseConfig,
left: 0,
top: "center",
orient: "vertical",
icon: "roundRect",
};
case "right":
return {
...baseConfig,
right: 0,
top: "center",
orient: "vertical",
icon: "roundRect",
};
default:
return baseConfig;
}
};
// 根据图例位置计算 grid 配置
const getGridWithLegend = (
showLegend: boolean,
legendPosition: "bottom" | "top" | "left" | "right" = "bottom",
baseGrid: any = {}
) => {
const defaultGrid = {
top: 15,
right: 15,
bottom: 8,
left: 0,
containLabel: true,
...baseGrid,
};
if (!showLegend) {
return defaultGrid;
}
// 根据图例位置调整 grid
switch (legendPosition) {
case "bottom":
return {
...defaultGrid,
bottom: 40,
};
case "top":
return {
...defaultGrid,
top: 40,
};
case "left":
return {
...defaultGrid,
left: 120,
};
case "right":
return {
...defaultGrid,
right: 120,
};
default:
return defaultGrid;
}
};
// 创建IntersectionObserver
const createIntersectionObserver = () => {
if (intersectionObserver || !chartRef.value) return;
intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && pendingOptions && !isDestroyed) {
// 使用 requestAnimationFrame 确保在下一帧初始化图表
requestAnimationFrame(() => {
if (!isDestroyed && pendingOptions) {
try {
// 元素变为可见,初始化图表
if (!chart) {
chart = echarts.init(entry.target as HTMLElement);
}
// 触发自定义事件,让组件处理动画逻辑
const event = new CustomEvent("chartVisible", {
detail: { options: pendingOptions },
});
entry.target.dispatchEvent(event);
pendingOptions = null;
cleanupIntersectionObserver();
} catch (error) {
console.error("图表初始化失败:", error);
}
}
});
}
});
},
{ threshold }
);
intersectionObserver.observe(chartRef.value);
};
// 清理IntersectionObserver
const cleanupIntersectionObserver = () => {
if (intersectionObserver) {
intersectionObserver.disconnect();
intersectionObserver = null;
}
};
// 检查容器是否可见
const isContainerVisible = (element: HTMLElement): boolean => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0;
};
// 图表初始化核心逻辑
const performChartInit = (options: EChartsOption) => {
if (!chart && chartRef.value && !isDestroyed) {
chart = echarts.init(chartRef.value);
// 图表创建后立即设置监听器
setupMenuWatchers();
setupThemeWatcher();
}
if (chart && !isDestroyed) {
chart.setOption(options);
pendingOptions = null;
}
};
// 空状态管理器
const emptyStateManager = {
create: () => {
if (!chartRef.value || emptyStateDiv) return;
emptyStateDiv = document.createElement("div");
emptyStateDiv.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
color: ${isDark.value ? "#555555" : "#B3B2B2"};
background: transparent;
z-index: 10;
`;
emptyStateDiv.innerHTML = `<span>暂无数据</span>`;
// 确保父容器有相对定位
if (
chartRef.value.style.position !== "relative" &&
chartRef.value.style.position !== "absolute"
) {
chartRef.value.style.position = "relative";
}
chartRef.value.appendChild(emptyStateDiv);
},
remove: () => {
if (emptyStateDiv && chartRef.value) {
chartRef.value.removeChild(emptyStateDiv);
emptyStateDiv = null;
}
},
updateStyle: () => {
if (emptyStateDiv) {
emptyStateDiv.style.color = isDark.value ? "#666" : "#999";
}
},
};
// 初始化图表
const initChart = (options: EChartsOption = {}, isEmpty: boolean = false) => {
if (!chartRef.value || isDestroyed) return;
const mergedOptions = { ...initOptions, ...options };
try {
if (isEmpty) {
// 处理空数据情况 - 显示自定义空状态div
if (chart) {
chart.clear();
}
emptyStateManager.create();
return;
} else {
// 有数据时移除空状态div
emptyStateManager.remove();
}
if (isContainerVisible(chartRef.value)) {
// 容器可见,正常初始化
if (initDelay > 0) {
setTimeout(() => performChartInit(mergedOptions), initDelay);
} else {
performChartInit(mergedOptions);
}
} else {
// 容器不可见,保存选项并设置监听器
pendingOptions = mergedOptions;
createIntersectionObserver();
}
} catch (error) {
console.error("图表初始化失败:", error);
}
};
// 更新图表
const updateChart = (options: EChartsOption) => {
if (isDestroyed) return;
try {
if (!chart) {
// 如果图表不存在,先初始化
initChart(options);
return;
}
chart.setOption(options);
} catch (error) {
console.error("图表更新失败:", error);
}
};
// 处理窗口大小变化
const handleResize = () => {
if (chart && !isDestroyed) {
try {
chart.resize();
} catch (error) {
console.error("图表resize失败:", error);
}
}
};
// 销毁图表
const destroyChart = () => {
isDestroyed = true;
if (chart) {
try {
chart.dispose();
} catch (error) {
console.error("图表销毁失败:", error);
} finally {
chart = null;
}
}
// 清理所有监听器和资源
cleanupMenuWatchers();
cleanupThemeWatcher();
emptyStateManager.remove();
cleanupIntersectionObserver();
clearTimers();
clearStyleCache();
pendingOptions = null;
};
// 获取图表实例
const getChartInstance = () => chart;
// 获取图表是否已初始化
const isChartInitialized = () => chart !== null;
onMounted(() => {
window.addEventListener("resize", debouncedResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", debouncedResize);
});
onUnmounted(() => {
destroyChart();
});
return {
isDark,
chartRef,
initChart,
updateChart,
handleResize,
destroyChart,
getChartInstance,
isChartInitialized,
emptyStateManager,
getAxisLineStyle,
getSplitLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
useChartOps,
getGridWithLegend,
};
}
// 高级图表组件抽象
interface UseChartComponentOptions<T extends BaseChartProps> {
/** Props响应式对象 */
props: T;
/** 图表配置生成函数 */
generateOptions: () => EChartsOption;
/** 空数据检查函数 */
checkEmpty?: () => boolean;
/** 自定义监听的响应式数据 */
watchSources?: (() => any)[];
/** 自定义可视事件处理 */
onVisible?: () => void;
/** useChart选项 */
chartOptions?: UseChartOptions;
}
export function useChartComponent<T extends BaseChartProps>(options: UseChartComponentOptions<T>) {
const {
props,
generateOptions,
checkEmpty,
watchSources = [],
onVisible,
chartOptions = {},
} = options;
const chart = useChart(chartOptions);
const { chartRef, initChart, isDark, emptyStateManager } = chart;
// 检查是否为空数据
const isEmpty = computed(() => {
if (props.isEmpty) return true;
if (checkEmpty) return checkEmpty();
return false;
});
// 更新图表
const updateChart = () => {
nextTick(() => {
if (isEmpty.value) {
// 处理空数据情况 - 显示自定义空状态div
if (chart.getChartInstance()) {
chart.getChartInstance()?.clear();
}
emptyStateManager.create();
} else {
// 有数据时移除空状态div并初始化图表
emptyStateManager.remove();
initChart(generateOptions());
}
});
};
// 处理图表进入可视区域时的逻辑
const handleChartVisible = () => {
if (onVisible) {
onVisible();
} else {
updateChart();
}
};
// 存储监听器停止函数
const stopHandles: (() => void)[] = [];
// 设置数据监听
const setupWatchers = () => {
// 监听自定义数据源
if (watchSources.length > 0) {
const stopHandle = watch(watchSources, updateChart, { deep: true });
stopHandles.push(stopHandle);
}
// 监听主题变化
const themeStopHandle = watch(isDark, () => {
emptyStateManager.updateStyle();
updateChart();
});
stopHandles.push(themeStopHandle);
};
// 清理所有监听器
const cleanupWatchers = () => {
stopHandles.forEach((stop) => stop());
stopHandles.length = 0;
};
// 设置生命周期
const setupLifecycle = () => {
onMounted(() => {
updateChart();
// 监听图表可见事件
if (chartRef.value) {
chartRef.value.addEventListener("chartVisible", handleChartVisible);
}
});
onBeforeUnmount(() => {
// 清理事件监听器
if (chartRef.value) {
chartRef.value.removeEventListener("chartVisible", handleChartVisible);
}
// 清理所有监听器
cleanupWatchers();
// 清理空状态div
emptyStateManager.remove();
});
};
// 初始化
setupWatchers();
setupLifecycle();
return {
...chart,
isEmpty,
updateChart,
handleChartVisible,
};
}
+91
View File
@@ -0,0 +1,91 @@
/**
* useCommon - 通用功能集合
*
* 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。
* 这些功能在多个页面和组件中都会用到,统一封装便于复用。
*
* ## 主要功能
*
* 1. 首页路径 - 获取系统配置的首页路径
* 2. 页面刷新 - 刷新当前页面内容
* 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法
* 4. 平滑滚动 - 支持平滑滚动动画效果
*
* @module useCommon
* @author FastapiAdmin Team
*/
import { computed } from "vue";
import { useMenuStore } from "@stores/modules/menu.store";
import { useSettingsStore } from "@stores/modules/setting.store";
export function useCommon() {
const menuStore = useMenuStore();
const settingStore = useSettingsStore();
/**
* 首页路径
* 从菜单 store 中获取配置的首页路径
*/
const homePath = computed(() => menuStore.getHomePath());
/**
* 刷新当前页面
* 通过切换 setting store 中的 refresh 状态触发页面重新渲染
*/
const refresh = () => {
settingStore.reload();
};
/** 主内容区滚动容器(顶栏、标签页固定,仅此处纵向滚动) */
const getMainScrollEl = (): HTMLElement | null =>
document.getElementById("app-scroll-main") ?? document.getElementById("app-main");
/**
* 滚动到页面顶部
* 查找主内容区域并将其滚动位置重置为顶部
*/
const scrollToTop = () => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
};
/**
* 平滑滚动到页面顶部
* 使用 smooth 行为实现平滑滚动效果
*/
const smoothScrollToTop = () => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
behavior: "smooth",
});
}
};
/**
* 滚动到指定位置
* @param top 目标滚动位置(像素)
* @param smooth 是否使用平滑滚动
*/
const scrollTo = (top: number, smooth: boolean = false) => {
const scrollContainer = getMainScrollEl();
if (scrollContainer) {
scrollContainer.scrollTo({
top,
behavior: smooth ? "smooth" : "auto",
});
}
};
return {
homePath,
refresh,
scrollTo,
scrollToTop,
smoothScrollToTop,
};
}
@@ -0,0 +1,55 @@
/**
* useFastEnter - 快速入口管理
*
* 管理顶部栏的快速入口功能,提供应用列表和快速链接的配置和过滤。
* 支持动态启用/禁用、自定义排序、响应式宽度控制等功能。
*
* ## 主要功能
*
* 1. 应用列表管理 - 获取启用的应用列表,自动按排序权重排序
* 2. 快速链接管理 - 获取启用的快速链接,支持自定义排序
* 3. 响应式配置 - 所有配置自动响应变化,无需手动更新
* 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局
*
* @module useFastEnter
* @author FastapiAdmin 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,
};
}
+214
View File
@@ -0,0 +1,214 @@
/**
* useHeaderBar - 顶部栏功能管理
*
* 统一管理顶部栏各个功能模块的显示状态和配置信息。
* 提供灵活的功能开关控制,支持动态显示/隐藏顶部栏的各个功能按钮。
*
* ## 主要功能
*
* 1. 功能开关控制 - 统一管理菜单按钮、刷新按钮、快速入口等功能的显示状态
* 2. 配置信息获取 - 获取各个功能模块的详细配置信息
* 3. 功能列表查询 - 快速获取所有启用或禁用的功能列表
* 4. 响应式状态 - 所有状态自动响应配置和 store 变化
*
* @module useHeaderBar
* @author FastapiAdmin Team
*/
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useSettingsStore } from "@stores/modules/setting.store";
import { headerBarConfig } from "@/config/modules/headerBar";
import { HeaderBarFeatureConfig } from "@/types";
/**
* 顶部栏功能管理
* @returns 顶部栏功能相关的状态和方法
*/
export function useHeaderBar() {
const settingStore = useSettingsStore();
// 获取顶部栏配置
const headerBarConfigRef = computed<HeaderBarFeatureConfig>(() => headerBarConfig);
// 从 store 获取相关状态(含旧版持久化开关:菜单搜索、全屏、字号、通知等)
const {
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
showLanguage,
showMenuSearch,
showFullscreen,
showSizeSelect,
showNotification,
} = 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;
});
// 全局搜索:新版 ArtGlobalSearch 体验更好,显示条件 = 配置开启 ∧ 旧版「菜单搜索」开关
const shouldShowGlobalSearch = computed(() => {
return isFeatureEnabled("globalSearch") && showMenuSearch.value;
});
const shouldShowFullscreen = computed(() => {
return isFeatureEnabled("fullscreen") && showFullscreen.value;
});
const shouldShowNotification = computed(() => {
return isFeatureEnabled("notification") && showNotification.value;
});
// 检查聊天功能是否显示
const shouldShowChat = computed(() => {
return isFeatureEnabled("chat");
});
/** 语言:以新版设置里的 showLanguage 为准(优于旧版单独的 showLangSelect 双开关,避免两处含义重叠) */
const shouldShowLanguage = computed(() => {
return isFeatureEnabled("language") && showLanguage.value;
});
/** 布局组件尺寸(旧版顶栏独立入口) */
const shouldShowSizeSelect = computed(() => {
return isFeatureEnabled("sizeSelect") && showSizeSelect.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, // 是否显示语言切换
shouldShowSizeSelect, // 是否显示组件尺寸切换
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 FastapiAdmin 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,
};
}
+783
View File
@@ -0,0 +1,783 @@
/**
* useTable - 企业级表格数据管理方案
*
* 功能完整的表格数据管理解决方案,专为后台管理系统设计。
* 封装了表格开发中的所有常见需求,让你专注于业务逻辑。
*
* ## 主要功能
*
* 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理
* 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理
* 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤
* 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息
* 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时)
* 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选)
*
* @module useTable
* @author FastapiAdmin Team
*/
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from "vue";
import { useWindowSize } from "@vueuse/core";
import type { AxiosResponse } from "axios";
import { useTableColumns } from "./useTableColumns";
import type { ColumnOption } from "@/types/component";
import {
TableCache,
CacheInvalidationStrategy,
type ApiResponse as TableApiResponse,
} from "@utils/table";
import {
type TableError,
defaultResponseAdapter,
extractTableData,
updatePaginationFromResponse,
createSmartDebounce,
createErrorHandler,
} from "@utils/table";
import { tableConfig } from "@utils/table";
// 类型推导工具类型
type InferApiParams<T> = T extends (params: infer P) => any ? P : never;
type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T;
type InferApiResponse<T> = T extends (params: any) => Promise<infer R>
? UnwrapAxiosResponse<R>
: never;
type InferRecordType<T> =
T extends ApiResponse<PageResult<infer U>>
? U
: T extends ApiResponse<(infer U)[]>
? U
: T extends PageResult<infer U>
? U
: T extends (infer U)[]
? U
: never;
type PaginationState = {
current: number;
size: number;
total: number;
};
// 优化的配置接口 - 支持自动类型推导
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) => TableApiResponse<TRecord>;
};
// 性能优化
performance?: {
/** 是否启用缓存 */
enableCache?: boolean;
/** 缓存时间(毫秒) */
cacheTime?: number;
/** 防抖延迟时间(毫秒) */
debounceTime?: number;
/** 最大缓存条数限制 */
maxCacheSize?: number;
};
// 生命周期钩子
hooks?: {
/** 数据加载成功回调(仅网络请求成功时触发) */
onSuccess?: (data: TRecord[], response: TableApiResponse<TRecord>) => void;
/** 错误处理回调 */
onError?: (error: TableError) => void;
/** 缓存命中回调(从缓存获取数据时触发) */
onCacheHit?: (data: TRecord[], response: TableApiResponse<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<PaginationState>({
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<TableApiResponse<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<TableApiResponse<TRecord> | void> => {
try {
return await fetchData(params);
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve();
}
};
// 分页获取数据 (重置到第一页) - 专门用于搜索场景
const getDataByPage = async (
params?: Partial<TParams>
): Promise<TableApiResponse<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();
}
};
// 替换搜索参数:适用于表单查询,避免旧字段残留
const replaceSearchParams = (params?: Partial<TParams>): void => {
const paramsRecord = searchParams as Record<string, unknown>;
const currentSize = pagination.size || ((paramsRecord[sizeKey] as number) ?? 10);
Object.keys(searchParams).forEach((key) => {
if (key !== pageKey && key !== sizeKey) {
delete paramsRecord[key];
}
});
Object.assign(
searchParams,
{
[pageKey]: 1,
[sizeKey]: currentSize,
},
params || {}
);
pagination.current = 1;
pagination.size = currentSize;
};
// 防重复调用的标志
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,
/** 替换搜索参数(适用于表单查询,避免旧字段残留) */
replaceSearchParams,
/** 重置搜索参数 */
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";
export type { ApiResponse, CacheItem } from "@utils/table";
export type { BaseRequestParams, TableError } from "@utils/table";
@@ -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 FastapiAdmin 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],
};
}
@@ -0,0 +1,107 @@
/**
* useTableHeight - 表格高度自动计算
*
* 自动计算表格容器的最佳高度,确保表格在不同布局场景下都能正确显示。
* 根据表格头部、分页器等元素的高度动态调整容器高度,避免出现滚动条或布局错乱。
*
* ## 主要功能
*
* 1. 动态高度计算 - 根据表格头部、分页器高度自动计算容器高度
* 2. 响应式更新 - 配置变化时自动重新计算高度
* 3. 灵活配置 - 支持自定义各部分高度和间距
* 4. 智能适配 - 无额外元素时自动使用 100% 高度
*
* @module useTableHeight
* @author FastapiAdmin 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,
};
}
+177
View File
@@ -0,0 +1,177 @@
/**
* useTheme - 系统主题管理
*
* 提供完整的主题切换和管理功能,支持亮色、暗色和自动模式。
* 自动处理主题切换时的过渡效果,确保切换流畅无闪烁。
*
* ## 主要功能
*
* 1. 主题切换 - 支持亮色、暗色、自动三种主题模式
* 2. 自动模式 - 根据系统偏好自动切换主题
* 3. 颜色适配 - 自动调整主题色的明暗变体(9 个层级)
* 4. 过渡优化 - 切换时临时禁用过渡效果,避免闪烁
* 5. 状态持久化 - 主题设置自动保存到 store
*
* ## 使用示例
*
* ```typescript
* const { switchThemeStyles } = useTheme()
*
* // 切换到暗色主题
* switchThemeStyles(SystemThemeEnum.DARK)
*
* // 切换到亮色主题
* switchThemeStyles(SystemThemeEnum.LIGHT)
*
* // 切换到自动模式(跟随系统)
* switchThemeStyles(SystemThemeEnum.AUTO)
* ```
*
* @module useTheme
* @author FastapiAdmin Team
*/
import { useSettingsStore } from "@stores/modules/setting.store";
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 = useSettingsStore();
// 禁用过渡效果
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 = useSettingsStore();
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 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
// 通用功能集合
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 { useChart, useChartComponent, useChartOps } from "./core/useChart";
// 布局高度
export { useLayoutHeight, useAutoLayoutHeight } from "./core/useLayoutHeight";
// AI 相关
export { useAiAction } from "./ai/useAiAction";