mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-06 01:28:01 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user