refactor(layouts): 将布局组件从layouts目录迁移到components/layouts目录

重构项目结构,将原本位于src/layouts下的所有布局相关组件迁移到src/components/layouts目录中,包括:
- 主布局容器index.vue
- 设置面板相关组件
- 水平菜单组件
- 快速入口组件
- 全局组件

同时更新了相关路由配置和组件引用路径,确保功能不受影响。此重构旨在使项目结构更加清晰,遵循组件化开发规范。
This commit is contained in:
zhangtao
2026-05-11 09:41:30 +08:00
parent 5a561a7374
commit 1ac2362945
42 changed files with 8 additions and 8 deletions
+144
View File
@@ -0,0 +1,144 @@
<!-- 面包屑导航 -->
<template>
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
<ul class="flex-c h-full">
<li
v-for="(item, index) in breadcrumbItems"
:key="item.path"
class="box-border flex-c h-7 text-sm leading-7"
>
<div
:class="
isClickable(item, index)
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
: ''
"
@click="handleBreadcrumbClick(item, index)"
>
<span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
>
{{ formatMenuTitle(item.meta?.title as string) }}
</span>
</div>
<div
v-if="!isLastItem(index) && item.meta?.title"
class="mx-1 text-sm not-italic text-g-500"
aria-hidden="true"
>
/
</div>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import type { RouteLocationMatched, RouteRecordRaw } from "vue-router";
import { formatMenuTitle } from "@utils/navigation";
defineOptions({ name: "FaBreadcrumb" });
export interface BreadcrumbItem {
path: string;
meta: RouteRecordRaw["meta"];
}
const route = useRoute();
const router = useRouter();
// 使用computed替代watch,提高性能
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const { matched } = route;
const matchedLength = matched.length;
// 首页:看叶子路由(matched[0] 始终是壳层 RootLayout,不能用它判断首页)
if (!matchedLength) {
return [];
}
const leafRoute = matched[matchedLength - 1];
if (leafRoute.name === "Home") {
return [];
}
// 处理一级菜单和普通路由
const firstRoute = matched[0];
const isFirstLevel = firstRoute.meta?.isFirstLevel;
const lastIndex = matchedLength - 1;
const currentRoute = matched[lastIndex];
const currentRouteMeta = currentRoute.meta;
let items = isFirstLevel
? [createBreadcrumbItem(currentRoute)]
: matched.map(createBreadcrumbItem);
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
if (items.length > 1 && isWrapperContainer(items[0])) {
items = items.slice(1);
}
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
return [createBreadcrumbItem(currentRoute)];
}
return items;
});
// 辅助函数:判断是否为包裹容器路由
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
item.path === "/outside" && !!item.meta?.isIframe;
// 辅助函数:创建面包屑项目
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
path: route.path,
meta: route.meta,
});
// 辅助函数:判断是否为最后一项
const isLastItem = (index: number): boolean => {
const itemsLength = breadcrumbItems.value.length;
return index === itemsLength - 1;
};
// 辅助函数:判断是否可点击
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
item.path !== "/outside" && !isLastItem(index);
// 辅助函数:查找路由的第一个有效子路由
const findFirstValidChild = (route: RouteRecordRaw) =>
route.children?.find((child) => !child.redirect && !child.meta?.isHide);
// 辅助函数:构建完整路径
const buildFullPath = (childPath: string): string => `/${childPath}`.replace("//", "/");
// 处理面包屑点击事件
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
// 如果是最后一项或外部链接,不处理
if (isLastItem(index) || item.path === "/outside") {
return;
}
try {
// 缓存路由表查找结果
const routes = router.getRoutes();
const targetRoute = routes.find((route) => route.path === item.path);
if (!targetRoute?.children?.length) {
await router.push(item.path);
return;
}
const firstValidChild = findFirstValidChild(targetRoute);
if (firstValidChild) {
await router.push(buildFullPath(firstValidChild.path));
} else {
await router.push(item.path);
}
} catch (error) {
console.error("导航失败:", error);
}
}
</script>
@@ -0,0 +1,263 @@
<!-- 系统聊天窗口 -->
<template>
<div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
<div class="mb-5 flex-cb">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="mt-1.5 flex-c gap-1">
<div
class="h-2 w-2 rounded-full"
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
></div>
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
</div>
</div>
<div>
<ElIcon class="c-p" :size="20" @click="closeChat">
<Close />
</ElIcon>
</div>
</div>
<div class="flex h-[calc(100%-70px)] flex-col">
<!-- 聊天消息区域 -->
<div
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
ref="messageContainer"
>
<template v-for="(message, index) in messages" :key="index">
<div
:class="[
'mb-7.5 flex w-full items-start gap-2',
message.isMe ? 'flex-row-reverse' : 'flex-row',
]"
>
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
<div
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
>
<div
:class="[
'mb-1 flex gap-2 text-xs',
message.isMe ? 'flex-row-reverse' : 'flex-row',
]"
>
<span class="font-medium">{{ message.sender }}</span>
<span class="text-g-600">{{ message.time }}</span>
</div>
<div
:class="[
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50',
]"
>
{{ message.content }}
</div>
</div>
</div>
</template>
</div>
<!-- 聊天输入区域 -->
<div class="px-4 pt-4">
<ElInput
v-model="messageText"
type="textarea"
:rows="3"
placeholder="输入消息"
resize="none"
@keyup.enter.prevent="sendMessage"
>
<template #append>
<div class="flex gap-2 py-2">
<ElButton :icon="Paperclip" circle plain />
<ElButton :icon="Picture" circle plain />
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
</div>
</template>
</ElInput>
<div class="mt-3 flex-cb">
<div class="flex-c">
<FaSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
<FaSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
</div>
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
</div>
</div>
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { Picture, Paperclip, Close } from "@element-plus/icons-vue";
import { mittBus } from "@utils/sys";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
defineOptions({ name: "FaChatWindow" });
// 类型定义
interface ChatMessage {
id: number;
sender: string;
content: string;
time: string;
isMe: boolean;
avatar: string;
}
// 常量定义
const MOBILE_BREAKPOINT = 640;
const SCROLL_DELAY = 100;
const BOT_NAME = "Art Bot";
const USER_NAME = "Ricky";
// 响应式布局
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// 组件状态
const isDrawerVisible = ref(false);
const isOnline = ref(true);
// 消息相关状态
const messageText = ref("");
const messageId = ref(10);
const messageContainer = ref<HTMLElement | null>(null);
// 初始化聊天消息数据
const initializeMessages = (): ChatMessage[] => [
{
id: 1,
sender: BOT_NAME,
content: "你好!我是你的AI助手,有什么我可以帮你的吗?",
time: "10:00",
isMe: false,
avatar: aiAvatar,
},
{
id: 2,
sender: USER_NAME,
content: "我想了解一下系统的使用方法。",
time: "10:01",
isMe: true,
avatar: meAvatar,
},
{
id: 3,
sender: BOT_NAME,
content: "好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...",
time: "10:02",
isMe: false,
avatar: aiAvatar,
},
{
id: 4,
sender: USER_NAME,
content: "听起来很不错,能具体讲讲数据分析部分吗?",
time: "10:05",
isMe: true,
avatar: meAvatar,
},
{
id: 5,
sender: BOT_NAME,
content: "当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...",
time: "10:06",
isMe: false,
avatar: aiAvatar,
},
{
id: 6,
sender: USER_NAME,
content: "太好了,那我如何开始使用呢?",
time: "10:08",
isMe: true,
avatar: meAvatar,
},
{
id: 7,
sender: BOT_NAME,
content: "您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。",
time: "10:09",
isMe: false,
avatar: aiAvatar,
},
{
id: 8,
sender: USER_NAME,
content: "明白了,谢谢你的帮助!",
time: "10:10",
isMe: true,
avatar: meAvatar,
},
{
id: 9,
sender: BOT_NAME,
content: "不客气,有任何问题随时联系我。",
time: "10:11",
isMe: false,
avatar: aiAvatar,
},
];
const messages = ref<ChatMessage[]>(initializeMessages());
// 工具函数
const formatCurrentTime = (): string => {
return new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};
const scrollToBottom = (): void => {
nextTick(() => {
setTimeout(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
}, SCROLL_DELAY);
});
};
// 消息处理方法
const sendMessage = (): void => {
const text = messageText.value.trim();
if (!text) return;
const newMessage: ChatMessage = {
id: messageId.value++,
sender: USER_NAME,
content: text,
time: formatCurrentTime(),
isMe: true,
avatar: meAvatar,
};
messages.value.push(newMessage);
messageText.value = "";
scrollToBottom();
};
// 聊天窗口控制方法
const openChat = (): void => {
isDrawerVisible.value = true;
scrollToBottom();
};
const closeChat = (): void => {
isDrawerVisible.value = false;
};
// 生命周期
onMounted(() => {
scrollToBottom();
mittBus.on("openChat", openChat);
});
onUnmounted(() => {
mittBus.off("openChat", openChat);
});
</script>
@@ -0,0 +1,117 @@
<!-- 顶部快速入口面板 -->
<template>
<ElPopover
ref="popoverRef"
:width="700"
:offset="0"
:show-arrow="false"
trigger="hover"
placement="bottom-start"
popper-class="fast-enter-popover"
:popper-style="{
border: '1px solid var(--default-border)',
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)',
}"
>
<template #reference>
<div class="flex-c gap-2">
<slot />
</div>
</template>
<div class="grid grid-cols-[2fr_0.8fr]">
<div>
<div class="grid grid-cols-2 gap-1.5">
<!-- 应用列表 -->
<div
v-for="application in enabledApplications"
:key="application.name"
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
@click="handleApplicationClick(application)"
>
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
<FaSvgIcon
class="text-xl"
:icon="application.icon"
:style="{ color: application.iconColor }"
/>
</div>
<div>
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3>
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
</div>
</div>
</div>
</div>
<div class="border-l-d pl-6 pt-2">
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
<ul>
<li
v-for="quickLink in enabledQuickLinks"
:key="quickLink.name"
class="c-p py-2 hover:[&_span]:text-theme"
@click="handleQuickLinkClick(quickLink)"
>
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
</li>
</ul>
</div>
</div>
</ElPopover>
</template>
<script setup lang="ts">
import { useFastEnter } from "@/hooks/core/useFastEnter";
import type { FastEnterApplication, FastEnterQuickLink } from "@/types/config";
defineOptions({ name: "FaFastEnter" });
const router = useRouter();
const popoverRef = ref();
// 使用快速入口配置
const { enabledApplications, enabledQuickLinks } = useFastEnter();
/**
* 处理导航跳转
* @param routeName 路由名称
* @param link 外部链接
*/
const handleNavigate = (
routeName?: string,
link?: string,
routeQuery?: Record<string, string>
): void => {
const targetPath = routeName || link;
if (!targetPath) {
console.warn("导航配置无效:缺少路由名称或链接");
return;
}
if (targetPath.startsWith("http")) {
window.open(targetPath, "_blank");
} else {
router.push({ name: targetPath, query: routeQuery ?? {} });
}
popoverRef.value?.hide();
};
/**
* 处理应用项点击
* @param application 应用配置对象
*/
const handleApplicationClick = (application: FastEnterApplication): void => {
handleNavigate(application.routeName, application.link, application.routeQuery);
};
/**
* 处理快速链接点击
* @param quickLink 快速链接配置对象
*/
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
handleNavigate(quickLink.routeName, quickLink.link, quickLink.routeQuery);
};
</script>
@@ -0,0 +1,649 @@
<!-- 烟花效果 | 礼花效果(快捷键 Ctrl+Shift+P / ⌘+Shift+P 说明见 src/config/modules/festival.ts) -->
<template>
<canvas
ref="canvasRef"
class="fixed top-0 left-0 z-[9999] w-full h-full pointer-events-none"
></canvas>
</template>
<script setup lang="ts">
import { useEventListener } from "@vueuse/core";
import { mittBus } from "@utils/sys";
import type { Handler } from "mitt";
import { festivalConfigList } from "@/config/modules/festival";
import { buildBuiltinSolarFestivals } from "@/config/modules/festival.builtin";
import { useCeremony } from "@/hooks/core/useCeremony";
import bp from "@imgs/ceremony/hb.png";
import sd from "@imgs/ceremony/sd.png";
import yd from "@imgs/ceremony/yd.png";
defineOptions({ name: "FaFireworksEffect" });
const { currentFestivalData } = useCeremony();
/** 与快捷键、mitt 共用:当天命中「带礼花」的节日时使用其图片,否则几何粒子 */
const resolveContextFireworkImage = (): string | undefined => {
const cur = currentFestivalData.value;
if (!cur || cur.skipFireworks) return undefined;
const raw = cur.image;
if (typeof raw !== "string" || !raw.trim()) return undefined;
return raw;
};
/**
* 烟花系统配置接口
* 定义了烟花效果的所有可配置参数
*/
interface FireworkConfig {
/** 对象池大小 - 预先创建的粒子对象数量 */
readonly POOL_SIZE: number;
/** 每次爆炸产生的粒子数量 */
readonly PARTICLES_PER_BURST: number;
/** 各种形状的尺寸配置 */
readonly SIZES: {
/** 矩形粒子的宽高 */
readonly RECTANGLE: { readonly WIDTH: number; readonly HEIGHT: number };
/** 正方形粒子的边长 */
readonly SQUARE: { readonly SIZE: number };
/** 圆形粒子的直径 */
readonly CIRCLE: { readonly SIZE: number };
/** 三角形粒子的边长 */
readonly TRIANGLE: { readonly SIZE: number };
/** 椭圆粒子的宽高 */
readonly OVAL: { readonly WIDTH: number; readonly HEIGHT: number };
/** 图片粒子的宽高 */
readonly IMAGE: { readonly WIDTH: number; readonly HEIGHT: number };
};
/** 旋转相关参数 */
readonly ROTATION: {
/** 基础旋转速度 */
readonly BASE_SPEED: number;
/** 随机旋转速度增量 */
readonly RANDOM_SPEED: number;
/** 旋转衰减系数 - 控制旋转速度递减 */
readonly DECAY: number;
};
/** 物理效果参数 */
readonly PHYSICS: {
/** 重力加速度 */
readonly GRAVITY: number;
/** 下落速度阈值 - 超过此值开始淡出 */
readonly VELOCITY_THRESHOLD: number;
/** 透明度衰减速度 */
readonly OPACITY_DECAY: number;
};
/** 烟花粒子颜色配置(带透明度) */
readonly COLORS: readonly string[];
/** 烟花粒子形状配置(矩形出现概率更高) */
readonly SHAPES: readonly string[];
}
/**
* 单个烟花粒子对象
* 包含粒子的位置、速度、外观等所有属性
*/
interface Firework {
/** X坐标位置 */
x: number;
/** Y坐标位置 */
y: number;
/** X方向速度 */
vx: number;
/** Y方向速度 */
vy: number;
/** 粒子颜色 (RGBA格式) */
color: string;
/** 当前旋转角度 */
rotation: number;
/** 旋转速度 */
rotationSpeed: number;
/** 缩放比例 */
scale: number;
/** 粒子形状类型 */
shape: string;
/** 透明度 (0-1) */
opacity: number;
/** 是否处于活动状态 */
active: boolean;
/** 图片URL (当shape为image时使用) */
imageUrl?: string;
}
/**
* 图片缓存接口
* 用于缓存预加载的图片资源
*/
interface ImageCache {
[url: string]: HTMLImageElement;
}
/**
* 烟花效果的全局配置
* 使用 as const 确保配置的不可变性
*/
const CONFIG: FireworkConfig = {
// 性能相关配置
POOL_SIZE: 600, // 对象池大小,影响同时存在的最大粒子数
PARTICLES_PER_BURST: 200, // 每次爆炸的粒子数量,影响视觉效果密度
// 粒子尺寸配置
SIZES: {
RECTANGLE: { WIDTH: 24, HEIGHT: 12 }, // 矩形粒子尺寸
SQUARE: { SIZE: 12 }, // 正方形粒子尺寸
CIRCLE: { SIZE: 12 }, // 圆形粒子尺寸
TRIANGLE: { SIZE: 10 }, // 三角形粒子尺寸
OVAL: { WIDTH: 24, HEIGHT: 12 }, // 椭圆粒子尺寸
IMAGE: { WIDTH: 30, HEIGHT: 30 }, // 图片粒子尺寸
},
// 旋转动画配置
ROTATION: {
BASE_SPEED: 2, // 基础旋转速度
RANDOM_SPEED: 3, // 额外随机旋转速度范围
DECAY: 0.98, // 旋转速度衰减系数 (越小衰减越快)
},
// 物理效果配置
PHYSICS: {
GRAVITY: 0.525, // 重力加速度,影响粒子下落速度
VELOCITY_THRESHOLD: 10, // 速度阈值,超过时开始透明度衰减
OPACITY_DECAY: 0.02, // 透明度衰减速度,影响粒子消失快慢
},
// 粒子颜色配置 - 使用RGBA格式支持透明度
COLORS: [
"rgba(255, 68, 68, 1)", // 红色系
"rgba(255, 68, 68, 0.9)",
"rgba(255, 68, 68, 0.8)",
"rgba(255, 116, 188, 1)", // 粉色系
"rgba(255, 116, 188, 0.9)",
"rgba(255, 116, 188, 0.8)",
"rgba(68, 68, 255, 0.8)", // 蓝色系
"rgba(92, 202, 56, 0.7)", // 绿色系
"rgba(255, 68, 255, 0.8)", // 紫色系
"rgba(68, 255, 255, 0.7)", // 青色系
"rgba(255, 136, 68, 0.7)", // 橙色系
"rgba(68, 136, 255, 1)", // 蓝色系
"rgba(250, 198, 122, 0.8)", // 金色系
],
// 粒子形状配置 - 矩形出现概率更高,营造更丰富的视觉效果
SHAPES: [
"rectangle",
"rectangle",
"rectangle",
"rectangle",
"rectangle",
"rectangle",
"rectangle",
"circle",
"triangle",
"oval",
],
} as const;
/** Canvas DOM 元素引用 */
const canvasRef = ref<HTMLCanvasElement>();
/** Canvas 2D 绘制上下文 */
const ctx = ref<CanvasRenderingContext2D | null>(null);
/**
* 烟花系统核心类
* 负责管理粒子生命周期、渲染和动画
*/
class FireworkSystem {
/** 粒子对象池 - 预先创建的粒子对象数组 */
private particlePool: Firework[] = [];
/** 当前活动的粒子数组 */
private activeParticles: Firework[] = [];
/** 对象池索引指针 - 用于循环分配粒子 */
private poolIndex = 0;
/** 图片资源缓存 */
private imageCache: ImageCache = {};
/** 动画帧ID - 用于取消动画 */
private animationId = 0;
/** 画布宽度缓存 */
private canvasWidth = 0;
/** 画布高度缓存 */
private canvasHeight = 0;
constructor() {
this.initializePool();
}
/**
* 初始化对象池
* 预先创建指定数量的粒子对象,避免运行时频繁创建
*/
private initializePool(): void {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
this.particlePool.push(this.createParticle());
}
}
/**
* 创建一个新的粒子对象
* 返回初始化状态的粒子
*/
private createParticle(): Firework {
return {
x: 0,
y: 0,
vx: 0,
vy: 0,
color: "",
rotation: 0,
rotationSpeed: 0,
scale: 1,
shape: "circle",
opacity: 1,
active: false,
};
}
/**
* 从对象池获取可用粒子 (性能优化版本)
* 使用循环索引而非Array.find(),时间复杂度从O(n)降至O(1)
* @returns 可用的粒子对象或null
*/
private getAvailableParticle(): Firework | null {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
const index = (this.poolIndex + i) % CONFIG.POOL_SIZE;
const particle = this.particlePool[index];
if (!particle.active) {
this.poolIndex = (index + 1) % CONFIG.POOL_SIZE;
particle.active = true;
return particle;
}
}
return null;
}
/**
* 预加载单个图片资源
* @param url 图片URL
* @returns Promise<HTMLImageElement>
*/
async preloadImage(url: string): Promise<HTMLImageElement> {
// 如果已缓存,直接返回
if (this.imageCache[url]) {
return this.imageCache[url];
}
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // 处理跨域问题
img.onload = () => {
this.imageCache[url] = img;
resolve(img);
};
img.onerror = reject;
img.src = url;
});
}
/**
* 预加载所有需要的图片资源
* 在组件初始化时调用,确保图片ready
*/
async preloadAllImages(): Promise<void> {
const year = new Date().getFullYear();
const fromFestival = [...festivalConfigList, ...buildBuiltinSolarFestivals(year)]
.map((f) => f.image)
.filter((u): u is string => typeof u === "string" && u.length > 0);
const imageUrls = [...new Set([bp, sd, yd, ...fromFestival])];
try {
await Promise.all(imageUrls.map((url) => this.preloadImage(url)));
} catch (error) {
console.error("Image preloading failed:", error);
}
}
/**
* 创建烟花爆炸效果
* @param imageUrl 可选的图片URL,如果提供则使用图片粒子
*/
createFirework(imageUrl?: string): void {
// 随机确定爆炸起始位置
const startX = Math.random() * this.canvasWidth;
const startY = this.canvasHeight;
// 根据是否有图片确定可用形状
const availableShapes = imageUrl && this.imageCache[imageUrl] ? ["image"] : CONFIG.SHAPES;
// 批量创建粒子数组,减少频繁的数组操作
const particles: Firework[] = [];
for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) {
const particle = this.getAvailableParticle();
if (!particle) continue;
// 计算粒子发射角度和速度 (恢复原始算法)
const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2); // 扇形分布
const speed = (12 + Math.random() * 6) * 1.5; // 随机速度
const spread = Math.random() * Math.PI * 2; // 360度随机扩散
// 直接属性赋值,避免Object.assign的性能开销
particle.x = startX;
particle.y = startY;
// 复杂的速度计算,模拟真实烟花爆炸轨迹
particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5);
particle.vy = Math.sin(angle) * speed - 15; // 向上初始速度
particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)];
particle.rotation = Math.random() * 360;
particle.rotationSpeed =
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
(Math.random() > 0.5 ? 1 : -1); // 随机旋转方向
particle.scale = 0.8 + Math.random() * 0.4; // 随机缩放
particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)];
particle.opacity = 1;
particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : undefined;
particles.push(particle);
}
// 批量添加到活动粒子数组,减少多次数组操作
this.activeParticles.push(...particles);
}
/**
* 更新所有粒子的物理状态 (性能优化版本)
* 包括位置、速度、旋转、透明度等
*/
private updateParticles(): void {
const { GRAVITY, VELOCITY_THRESHOLD, OPACITY_DECAY } = CONFIG.PHYSICS;
const { DECAY } = CONFIG.ROTATION;
// 使用倒序遍历,避免删除元素时的索引混乱问题
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
const particle = this.activeParticles[i];
// 更新粒子位置 (匀加速运动)
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += GRAVITY; // 重力影响
// 更新旋转状态
particle.rotation += particle.rotationSpeed;
particle.rotationSpeed *= DECAY; // 旋转速度衰减
// 透明度衰减逻辑 - 当粒子下落速度超过阈值时开始淡出
if (particle.vy > VELOCITY_THRESHOLD) {
particle.opacity -= OPACITY_DECAY;
if (particle.opacity <= 0) {
this.recycleParticle(i);
continue;
}
}
// 边界检查 - 移除超出屏幕范围的粒子
if (this.isOutOfBounds(particle)) {
this.recycleParticle(i);
}
}
}
/**
* 回收粒子到对象池
* @param index 要回收的粒子在活动数组中的索引
*/
private recycleParticle(index: number): void {
const particle = this.activeParticles[index];
particle.active = false; // 标记为非活动状态
this.activeParticles.splice(index, 1); // 从活动数组中移除
}
/**
* 检查粒子是否超出屏幕边界
* @param particle 要检查的粒子
* @returns 是否超出边界
*/
private isOutOfBounds(particle: Firework): boolean {
const margin = 100; // 边界缓冲区
return (
particle.x < -margin ||
particle.x > this.canvasWidth + margin ||
particle.y < -margin ||
particle.y > this.canvasHeight + margin
);
}
/**
* 绘制单个粒子
* @param particle 要绘制的粒子对象
*/
private drawParticle(particle: Firework): void {
if (!ctx.value) return;
// 保存当前画布状态
ctx.value.save();
ctx.value.globalAlpha = particle.opacity; // 设置透明度
ctx.value.translate(particle.x, particle.y); // 移动到粒子位置
ctx.value.rotate((particle.rotation * Math.PI) / 180); // 应用旋转
ctx.value.scale(particle.scale, particle.scale); // 应用缩放
// 渲染粒子形状
this.renderShape(particle);
// 恢复画布状态
ctx.value.restore();
}
/**
* 根据粒子类型渲染对应的形状
* @param particle 要渲染的粒子
*/
private renderShape(particle: Firework): void {
if (!ctx.value) return;
const { SIZES } = CONFIG;
ctx.value.fillStyle = particle.color;
switch (particle.shape) {
case "rectangle":
// 绘制矩形
ctx.value.fillRect(
-SIZES.RECTANGLE.WIDTH / 2,
-SIZES.RECTANGLE.HEIGHT / 2,
SIZES.RECTANGLE.WIDTH,
SIZES.RECTANGLE.HEIGHT
);
break;
case "square":
// 绘制正方形
ctx.value.fillRect(
-SIZES.SQUARE.SIZE / 2,
-SIZES.SQUARE.SIZE / 2,
SIZES.SQUARE.SIZE,
SIZES.SQUARE.SIZE
);
break;
case "circle":
// 绘制圆形
ctx.value.beginPath();
ctx.value.arc(0, 0, SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2);
ctx.value.fill();
break;
case "triangle":
// 绘制三角形
ctx.value.beginPath();
ctx.value.moveTo(0, -SIZES.TRIANGLE.SIZE);
ctx.value.lineTo(SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE);
ctx.value.lineTo(-SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE);
ctx.value.closePath();
ctx.value.fill();
break;
case "oval":
// 绘制椭圆
ctx.value.beginPath();
ctx.value.ellipse(0, 0, SIZES.OVAL.WIDTH / 2, SIZES.OVAL.HEIGHT / 2, 0, 0, Math.PI * 2);
ctx.value.fill();
break;
case "image":
// 绘制图片
this.renderImage(particle);
break;
}
}
/**
* 渲染图片类型的粒子
* @param particle 包含图片URL的粒子对象
*/
private renderImage(particle: Firework): void {
if (!ctx.value || !particle.imageUrl) return;
const img = this.imageCache[particle.imageUrl];
if (img?.complete) {
const { WIDTH, HEIGHT } = CONFIG.SIZES.IMAGE;
ctx.value.drawImage(img, -WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT);
}
}
/**
* 渲染所有活动粒子到画布
* 清除画布并重新绘制所有粒子
*/
private render(): void {
if (!ctx.value || !canvasRef.value) return;
// 清除整个画布
ctx.value.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
// 设置混合模式为"变亮",增强视觉效果
ctx.value.globalCompositeOperation = "lighter";
// 渲染所有活动粒子
for (const particle of this.activeParticles) {
this.drawParticle(particle);
}
}
/**
* 动画主循环
* 使用箭头函数保持this绑定
*/
private animate = (): void => {
this.updateParticles(); // 更新粒子状态
this.render(); // 渲染粒子
this.animationId = requestAnimationFrame(this.animate); // 请求下一帧
};
/**
* 更新画布尺寸缓存
* 在窗口大小改变时调用
* @param width 新的画布宽度
* @param height 新的画布高度
*/
updateCanvasSize(width: number, height: number): void {
this.canvasWidth = width;
this.canvasHeight = height;
}
/**
* 启动动画循环
*/
start(): void {
this.animate();
}
/**
* 停止动画循环
* 在组件卸载时调用,避免内存泄漏
*/
stop(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = 0;
}
}
/**
* 获取当前活动粒子数量
* 用于调试和性能监控
* @returns 活动粒子数量
*/
getActiveParticleCount(): number {
return this.activeParticles.length;
}
}
/** 烟花系统实例 */
const fireworkSystem = new FireworkSystem();
/**
* 处理键盘快捷键:Ctrl+Shift+P / ⌘+Shift+P,礼花素材与当前节日配置一致(见 festival.ts 说明)
*/
const handleKeyPress = (event: KeyboardEvent): void => {
const isFireworkShortcut =
(event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "p") ||
(event.metaKey && event.shiftKey && event.key.toLowerCase() === "p");
if (isFireworkShortcut) {
event.preventDefault();
fireworkSystem.createFirework(resolveContextFireworkImage());
}
};
/**
* 调整Canvas画布大小
* 响应窗口大小变化,确保画布始终覆盖整个视口
*/
const resizeCanvas = (): void => {
if (!canvasRef.value) return;
const { innerWidth, innerHeight } = window;
canvasRef.value.width = innerWidth;
canvasRef.value.height = innerHeight;
fireworkSystem.updateCanvasSize(innerWidth, innerHeight);
};
/**
* 处理外部触发的烟花事件
* 通过 mittBus 事件总线接收触发指令
* @param event 事件数据,可能包含图片URL
*/
const handleFireworkTrigger: Handler<unknown> = (event: unknown) => {
const imageUrl = event as string | undefined;
fireworkSystem.createFirework(imageUrl);
};
/**
* 组件挂载时的初始化逻辑
*/
onMounted(async () => {
if (!canvasRef.value) return;
// 获取Canvas 2D绘制上下文
ctx.value = canvasRef.value.getContext("2d");
if (!ctx.value) return;
// 设置初始画布大小
resizeCanvas();
// 预加载所有图片资源
await fireworkSystem.preloadAllImages();
// 启动动画循环
fireworkSystem.start();
// 注册事件监听器
useEventListener(window, "keydown", handleKeyPress); // 键盘快捷键
useEventListener(window, "resize", resizeCanvas); // 窗口大小变化
mittBus.on("triggerFireworks", handleFireworkTrigger); // 外部触发事件
});
/**
* 组件卸载时的清理逻辑
* 停止动画循环并移除事件监听器,防止内存泄漏
*/
onUnmounted(() => {
fireworkSystem.stop();
mittBus.off("triggerFireworks", handleFireworkTrigger);
});
</script>
@@ -0,0 +1,14 @@
<!-- 全局组件 -->
<template>
<component
v-for="componentConfig in enabledComponents"
:key="componentConfig.key"
:is="componentConfig.component"
/>
</template>
<script setup lang="ts">
import { getEnabledGlobalComponents } from "@/config/modules/component";
defineOptions({ name: "FaGlobalComponent" });
const enabledComponents = computed(() => getEnabledGlobalComponents());
</script>
@@ -0,0 +1,431 @@
<!-- 全局搜索组件 -->
<template>
<div class="layout-search">
<ElDialog
v-model="showSearchDialog"
width="600"
:show-close="false"
:lock-scroll="false"
modal-class="search-modal"
@close="closeSearchDialog"
>
<ElInput
v-model.trim="searchVal"
:placeholder="$t('search.placeholder')"
@input="search"
@blur="searchBlur"
ref="searchInput"
:prefix-icon="Search"
class="h-12"
>
<template #suffix>
<div
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
>
<FaSvgIcon icon="fluent:arrow-enter-left-20-filled" />
</div>
</template>
</ElInput>
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
<div class="result w-full" v-show="searchResult.length">
<div
class="box !mt-0 c-p text-base leading-none"
v-for="(item, index) in searchResult"
:key="index"
>
<div
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
:class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''"
@click="searchGoPage(item)"
@mouseenter="highlightOnHover(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<FaSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
</div>
</div>
</div>
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
<p class="text-xs text-g-500">{{ $t("search.historyTitle") }}</p>
<div class="mt-1.5 w-full">
<div
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
v-for="(item, index) in historyResult"
:key="index"
:class="
historyHIndex === index
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
: ''
"
@click="searchGoPage(item)"
@mouseenter="highlightOnHoverHistory(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<div
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
@click.stop="deleteHistory(index)"
>
<FaSvgIcon icon="ri:close-large-fill" class="text-xs" />
</div>
</div>
</div>
</div>
</ElScrollbar>
<template #footer>
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
<div class="flex-cc">
<FaSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.selectKeydown") }}</span>
</div>
<div class="flex-c">
<FaSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
<FaSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.switchKeydown") }}</span>
</div>
<div class="flex-c">
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.exitKeydown") }}</span>
</div>
</div>
</template>
</ElDialog>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from "@stores/modules/user.store";
import { AppRouteRecord } from "@/types/router";
import { Search } from "@element-plus/icons-vue";
import { mittBus } from "@utils/sys";
import { useMenuStore } from "@stores/modules/menu.store";
import { formatMenuTitle } from "@utils/navigation";
import { handleMenuJump } from "@utils/navigation";
import { type ScrollbarInstance } from "element-plus";
defineOptions({ name: "FaGlobalSearch" });
const userStore = useUserStore();
const { menuList } = storeToRefs(useMenuStore());
const showSearchDialog = ref(false);
const searchVal = ref("");
const searchResult = ref<AppRouteRecord[]>([]);
const historyMaxLength = 10;
const { searchHistory: historyResult } = storeToRefs(userStore);
const searchInput = ref<HTMLInputElement | null>(null);
const highlightedIndex = ref(0);
const historyHIndex = ref(0);
const searchResultScrollbar = ref<ScrollbarInstance>();
const isKeyboardNavigating = ref(false); // 新增状态:是否正在使用键盘导航
// 生命周期钩子
onMounted(() => {
mittBus.on("openSearchDialog", openSearchDialog);
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
// 键盘快捷键处理
const handleKeydown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const isCommandKey = isMac ? event.metaKey : event.ctrlKey;
if (isCommandKey && event.key.toLowerCase() === "k") {
event.preventDefault();
showSearchDialog.value = true;
focusInput();
}
// 当搜索对话框打开时,处理方向键和回车键
if (showSearchDialog.value) {
if (event.key === "ArrowUp") {
event.preventDefault();
highlightPrevious();
} else if (event.key === "ArrowDown") {
event.preventDefault();
highlightNext();
} else if (event.key === "Enter") {
event.preventDefault();
selectHighlighted();
} else if (event.key === "Escape") {
event.preventDefault();
showSearchDialog.value = false;
}
}
};
const focusInput = () => {
setTimeout(() => {
searchInput.value?.focus();
}, 100);
};
// 搜索逻辑
const search = (val: string) => {
if (val) {
searchResult.value = flattenAndFilterMenuItems(menuList.value, val);
} else {
searchResult.value = [];
}
};
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
const lowerVal = val.toLowerCase();
const result: AppRouteRecord[] = [];
const flattenAndMatch = (item: AppRouteRecord) => {
if (item.meta?.isHide) return;
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase();
if (item.children && item.children.length > 0) {
item.children.forEach(flattenAndMatch);
return;
}
if (
lowerItemTitle.includes(lowerVal) &&
((item.path && item.path.trim()) || item.meta.link || item.meta.isIframe)
) {
result.push({ ...item, children: undefined });
}
};
items.forEach(flattenAndMatch);
return result;
};
// 高亮控制并实现滚动条跟随
const highlightPrevious = () => {
isKeyboardNavigating.value = true;
if (searchVal.value) {
highlightedIndex.value =
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length;
scrollToHighlightedItem();
} else {
historyHIndex.value =
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length;
scrollToHighlightedHistoryItem();
}
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
setTimeout(() => {
isKeyboardNavigating.value = false;
}, 100);
};
const highlightNext = () => {
isKeyboardNavigating.value = true;
if (searchVal.value) {
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length;
scrollToHighlightedItem();
} else {
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length;
scrollToHighlightedHistoryItem();
}
setTimeout(() => {
isKeyboardNavigating.value = false;
}, 100);
};
const scrollToHighlightedItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !searchResult.value.length) return;
const scrollWrapper = searchResultScrollbar.value.wrapRef;
if (!scrollWrapper) return;
const highlightedElements = scrollWrapper.querySelectorAll(".result .box");
if (!highlightedElements[highlightedIndex.value]) return;
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement;
const itemHeight = highlightedElement.offsetHeight;
const scrollTop = scrollWrapper.scrollTop;
const containerHeight = scrollWrapper.clientHeight;
const itemTop = highlightedElement.offsetTop;
const itemBottom = itemTop + itemHeight;
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop);
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight);
}
});
};
const scrollToHighlightedHistoryItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !historyResult.value.length) return;
const scrollWrapper = searchResultScrollbar.value.wrapRef;
if (!scrollWrapper) return;
const historyItems = scrollWrapper.querySelectorAll(".history-result .box");
if (!historyItems[historyHIndex.value]) return;
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement;
const itemHeight = highlightedElement.offsetHeight;
const scrollTop = scrollWrapper.scrollTop;
const containerHeight = scrollWrapper.clientHeight;
const itemTop = highlightedElement.offsetTop;
const itemBottom = itemTop + itemHeight;
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop);
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight);
}
});
};
const selectHighlighted = () => {
if (searchVal.value && searchResult.value.length) {
searchGoPage(searchResult.value[highlightedIndex.value]);
} else if (!searchVal.value && historyResult.value.length) {
searchGoPage(historyResult.value[historyHIndex.value]);
}
};
const isHighlighted = (index: number) => {
return highlightedIndex.value === index;
};
const searchBlur = () => {
highlightedIndex.value = 0;
};
const searchGoPage = (item: AppRouteRecord) => {
showSearchDialog.value = false;
addHistory(item);
handleMenuJump(item);
searchVal.value = "";
searchResult.value = [];
};
// 历史记录管理
const updateHistory = () => {
if (Array.isArray(historyResult.value)) {
userStore.setSearchHistory(historyResult.value);
}
};
const addHistory = (item: AppRouteRecord) => {
const itemKey = item.path || String(item.meta.link || "");
const hasItemIndex = historyResult.value.findIndex(
(historyItem: AppRouteRecord) =>
(historyItem.path || String(historyItem.meta.link || "")) === itemKey
);
if (hasItemIndex !== -1) {
historyResult.value.splice(hasItemIndex, 1);
} else if (historyResult.value.length >= historyMaxLength) {
historyResult.value.pop();
}
const cleanedItem = { ...item };
delete cleanedItem.children;
delete cleanedItem.meta.authList;
historyResult.value.unshift(cleanedItem);
updateHistory();
};
const deleteHistory = (index: number) => {
historyResult.value.splice(index, 1);
updateHistory();
};
// 对话框控制
const openSearchDialog = () => {
showSearchDialog.value = true;
focusInput();
};
const closeSearchDialog = () => {
searchVal.value = "";
searchResult.value = [];
highlightedIndex.value = 0;
historyHIndex.value = 0;
};
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
const highlightOnHover = (index: number) => {
if (!isKeyboardNavigating.value && searchVal.value) {
highlightedIndex.value = index;
}
};
const highlightOnHoverHistory = (index: number) => {
if (!isKeyboardNavigating.value && !searchVal.value) {
historyHIndex.value = index;
}
};
</script>
<style lang="scss" scoped>
.layout-search {
:deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
}
:deep(.el-dialog__body) {
padding: 5px 0 0 !important;
}
:deep(.el-dialog__header) {
padding: 0;
}
.el-input {
:deep(.el-input__wrapper) {
background-color: var(--fa-gray-200);
border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none;
}
:deep(.el-input__inner) {
color: var(--fa-gray-800) !important;
}
}
}
.dark .layout-search {
.el-input {
:deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
}
}
:deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
backdrop-filter: none;
}
:deep(.el-dialog) {
background-color: #252526;
}
}
</style>
<style scoped>
@reference '@styles/core/tailwind.css';
.keyboard {
@apply mr-2
box-border
h-5
w-5.5
rounded
border
border-g-400
px-1
text-g-500
shadow-[0_2px_0_var(--default-border-dashed)]
last-of-type:mr-1.5;
}
</style>
+515
View File
@@ -0,0 +1,515 @@
<!-- 顶部栏 -->
<template>
<div
class="w-full bg-[var(--default-bg-color)]"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' || tabStyle === 'tab-default'
? 'max-sm:mb-3 !bg-box'
: '',
]"
>
<div
class="relative box-border flex-b h-15 leading-15 select-none"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' || tabStyle === 'tab-default'
? 'border-b border-[var(--fa-card-border)]'
: '',
]"
>
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
<!-- 系统信息:Logo + 标题一并受「显示应用 Logo」控制 -->
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu && showAppLogo">
<FaLogo class="pl-4.5" :src="headerLogoSrc" />
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ headerSystemName }}</p>
</div>
<FaLogo
v-if="showAppLogo"
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
:src="headerLogoSrc"
@click="toHome"
/>
<!-- 菜单按钮 -->
<FaIconButton
v-if="isLeftMenu && shouldShowMenuButton"
icon="ri:menu-2-fill"
class="ml-3 max-sm:ml-[7px]"
@click="visibleMenu"
/>
<!-- 刷新按钮 -->
<FaIconButton
v-if="shouldShowRefreshButton"
icon="ri:refresh-line"
class="!ml-3 refresh-btn max-sm:!hidden"
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
@click="reload"
/>
<!-- 快速入口 -->
<FaFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
<FaIconButton icon="ri:function-line" class="ml-3" />
</FaFastEnter>
<!-- 面包屑 -->
<FaBreadcrumb
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
/>
<!-- 顶部菜单 -->
<FaHorizontalMenu v-if="isTopMenu" :list="menuList" />
<!-- 混合菜单-顶部 -->
<FaMixedMenu v-if="isTopLeftMenu" :list="menuList" />
</div>
<div id="app-header-toolbar" class="flex-c gap-2.5">
<!-- 搜索 -->
<div
v-if="shouldShowGlobalSearch"
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden tad-300 hover:-translate-y-0.5 hover:shadow-md"
@click="openSearchDialog"
>
<div class="flex-c">
<FaSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
<span class="ml-1 text-xs font-normal text-g-500">{{ $t("topBar.search.title") }}</span>
</div>
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
<FaSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
<FaSvgIcon v-else icon="ri:command-fill" class="text-xs" />
<span class="ml-0.5 text-xs">k</span>
</div>
</div>
<!-- 全屏按钮 -->
<FaIconButton
v-if="shouldShowFullscreen"
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<!-- 组件尺寸 default/large/small(沿用旧版持久化开关 showSizeSelect) -->
<div v-if="shouldShowSizeSelect" class="flex-cc ml-1 max-md:!hidden">
<FaSizeSelect />
</div>
<!-- 国际化按钮 -->
<ElDropdown
@command="changeLanguage"
popper-class="langDropDownStyle"
v-if="shouldShowLanguage"
>
<FaIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
<template #dropdown>
<ElDropdownMenu>
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
<ElDropdownItem
:command="item.value"
:class="{ 'is-selected': locale === item.value }"
>
<span class="menu-txt">{{ item.label }}</span>
<FaSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 通知按钮 -->
<FaIconButton
v-if="shouldShowNotification"
icon="ri:notification-2-line"
class="notice-button relative"
@click="visibleNotice"
>
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
</FaIconButton>
<!-- 聊天按钮 -->
<FaIconButton
v-if="shouldShowChat"
icon="ri:message-3-line"
class="chat-button relative"
@click="openChat"
>
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div>
</FaIconButton>
<!-- 设置按钮 -->
<div v-if="shouldShowSettings">
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
<template #reference>
<div class="flex-cc">
<FaIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
</div>
</template>
<template #default>
<p>
{{ $t("topBar.guide.title") }}
<span :style="{ color: systemThemeColor }">{{ $t("topBar.guide.theme") }}</span>
、
<span :style="{ color: systemThemeColor }">{{ $t("topBar.guide.menu") }}</span>
{{ $t("topBar.guide.description") }}
</p>
</template>
</ElPopover>
</div>
<!-- 主题切换按钮 -->
<FaIconButton
v-if="shouldShowThemeToggle"
@click="themeAnimation"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
<!-- 用户头像、菜单 -->
<FaUserMenu />
</div>
</div>
<!-- 标签页 -->
<FaWorkTab />
<!-- 通知 -->
<FaNotification v-model:value="showNotice" ref="notice" />
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useFullscreen, useWindowSize } from "@vueuse/core";
import { LanguageEnum, MenuTypeEnum } from "@/enums/appEnum";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useUserStore } from "@stores/modules/user.store";
import { useMenuStore } from "@stores/modules/menu.store";
import AppConfig from "@/config";
import { useConfigStore } from "@stores/modules/config.store";
import { languageOptions } from "@/locales";
import { mittBus } from "@utils/sys";
import { themeAnimation } from "@utils/ui";
import { useCommon } from "@/hooks/core/useCommon";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import FaUserMenu from "./widget/FaUserMenu.vue";
import FaSizeSelect from "@/components/others/fa-size-select/index.vue";
defineOptions({ name: "FaHeaderBar" });
// 检测操作系统类型
const isWindows = navigator.userAgent.includes("Windows");
const router = useRouter();
const { locale } = useI18n();
const { width } = useWindowSize();
const settingStore = useSettingsStore();
const userStore = useUserStore();
const menuStore = useMenuStore();
const configStore = useConfigStore();
/** 与侧栏一致:参数配置 sys_web_logo / sys_web_title */
const headerLogoSrc = computed(() => {
const raw = configStore.configData.sys_web_logo?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const headerSystemName = computed(() => {
const raw = configStore.configData.sys_web_title?.config_value;
if (typeof raw === "string" && raw.trim()) return raw.trim();
return AppConfig.systemInfo.name;
});
// 顶部栏功能配置
const {
shouldShowMenuButton,
shouldShowRefreshButton,
shouldShowFastEnter,
shouldShowBreadcrumb,
shouldShowGlobalSearch,
shouldShowFullscreen,
shouldShowNotification,
shouldShowChat,
shouldShowLanguage,
shouldShowSettings,
shouldShowThemeToggle,
shouldShowSizeSelect,
fastEnterMinWidth: headerBarFastEnterMinWidth,
} = useHeaderBar();
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle, showAppLogo } =
storeToRefs(settingStore);
const { language } = storeToRefs(userStore);
const { menuList } = storeToRefs(menuStore);
const showNotice = ref(false);
const notice = ref(null);
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT);
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU);
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP);
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT);
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen();
onMounted(() => {
initLanguage();
document.addEventListener("click", bodyCloseNotice);
});
onUnmounted(() => {
document.removeEventListener("click", bodyCloseNotice);
});
/**
* 切换全屏状态
*/
const toggleFullScreen = (): void => {
toggleFullscreen();
};
/**
* 切换菜单显示/隐藏状态
*/
const visibleMenu = (): void => {
settingStore.setMenuOpen(!menuOpen.value);
};
const { homePath } = useCommon();
const { refresh } = useCommon();
/**
* 跳转到首页
*/
const toHome = (): void => {
router.push(homePath.value);
};
/**
* 刷新页面
* @param {number} time - 延迟时间,默认为0毫秒
*/
const reload = (time: number = 0): void => {
setTimeout(() => {
refresh();
}, time);
};
/**
* 初始化语言设置
*/
const initLanguage = (): void => {
locale.value = language.value;
};
/**
* 切换系统语言
* @param {LanguageEnum} lang - 目标语言类型
*/
const changeLanguage = (lang: LanguageEnum): void => {
if (locale.value === lang) return;
locale.value = lang;
userStore.setLanguage(lang);
reload(50);
};
/**
* 打开设置面板
*/
const openSetting = (): void => {
mittBus.emit("openSetting");
// 隐藏设置引导提示
if (showSettingGuide.value) {
settingStore.hideSettingGuide();
}
};
/**
* 打开全局搜索对话框
*/
const openSearchDialog = (): void => {
mittBus.emit("openSearchDialog");
};
/**
* 点击页面其他区域关闭通知面板
* @param {Event} e - 点击事件对象
*/
const bodyCloseNotice = (e: any): void => {
if (!showNotice.value) return;
const target = e.target as HTMLElement;
// 检查是否点击了通知按钮或通知面板内部
const isNoticeButton = target.closest(".notice-button");
const isNoticePanel = target.closest(".fa-notification-panel");
if (!isNoticeButton && !isNoticePanel) {
showNotice.value = false;
}
};
/**
* 切换通知面板显示状态
*/
const visibleNotice = (): void => {
showNotice.value = !showNotice.value;
};
/**
* 打开聊天窗口
*/
const openChat = (): void => {
mittBus.emit("openChat");
};
</script>
<style lang="scss" scoped>
/* Custom animations */
@keyframes rotate180 {
0% {
transform: rotate(0);
}
100% {
transform: rotate(180deg);
}
}
@keyframes shake {
0% {
transform: rotate(0);
}
25% {
transform: rotate(-5deg);
}
50% {
transform: rotate(5deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0);
}
}
@keyframes expand {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes shrink {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
@keyframes moveUp {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
100% {
transform: translateY(0);
}
}
@keyframes breathing {
0% {
opacity: 0.4;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.4;
transform: scale(0.9);
}
}
/* Hover animation classes */
.refresh-btn:hover :deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.language-btn:hover :deep(.fa-svg-icon) {
animation: moveUp 0.4s;
}
.setting-btn:hover :deep(.fa-svg-icon) {
animation: rotate180 0.5s;
}
.full-screen-btn:hover :deep(.fa-svg-icon) {
animation: expand 0.6s forwards;
}
:deep(.size-select-btn:hover .fa-svg-icon) {
animation: expand 0.6s forwards;
}
.exit-full-screen-btn:hover :deep(.fa-svg-icon) {
animation: shrink 0.6s forwards;
}
.notice-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
/* Breathing animation for chat dot */
.breathing-dot {
animation: breathing 1.5s ease-in-out infinite;
}
/* iPad breakpoint adjustments */
@media screen and (width <= 768px) {
.logo2 {
display: block !important;
}
}
@media screen and (width <= 640px) {
.btn-box {
width: 40px;
}
}
</style>
@@ -0,0 +1,274 @@
<!-- 用户菜单:合并旧版顶栏(配置中心、Gitee、引导)+ 新版 Popover 与链接结构 -->
<template>
<!-- inline-flex + items-center:与顶栏 FaIconButton 同一中线对齐,避免 Popover 触发层基线偏移 -->
<div class="fa-user-menu inline-flex shrink-0 items-center leading-none">
<ElPopover
ref="userMenuPopover"
placement="bottom-end"
:width="240"
:hide-after="0"
:offset="10"
trigger="hover"
:show-arrow="false"
popper-class="user-menu-popover"
popper-style="padding: 5px 16px;"
>
<template #reference>
<div
class="fa-user-menu__avatar-ref mr-5 max-sm:mr-[16px] c-p flex size-8.5 max-sm:w-6.5 max-sm:h-6.5 shrink-0 items-center justify-center"
>
<img
v-if="userAvatar"
class="size-full rounded-full object-cover block"
:src="userAvatar"
alt="avatar"
/>
<img
v-else
class="size-full rounded-full block"
src="@imgs/user/avatar.webp"
alt="avatar"
/>
<!-- 与旧版 NavbarActions.user-profile__online-indicator 一致 -->
<span class="fa-user-menu__online-dot" aria-hidden="true" />
</div>
</template>
<template #default>
<div class="pt-3">
<div class="flex-c pb-1 px-0">
<img
v-if="userAvatar"
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left object-cover"
:src="userAvatar"
alt=""
/>
<img
v-else
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
src="@imgs/user/avatar.webp"
alt=""
/>
<div class="w-[calc(100%-60px)] h-full">
<span class="block text-sm font-medium text-g-800 truncate">
{{ displayName }}
</span>
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ displayEmail }}</span>
</div>
</div>
<ul class="py-4 mt-3 border-t border-g-300/80">
<li class="btn-item" @click="goPage('/profile')">
<FaSvgIcon icon="ri:user-3-line" />
<span>{{ $t("topBar.user.userCenter") }}</span>
</li>
<li class="btn-item" @click="goChangeLog">
<FaSvgIcon icon="ri:draft-line" />
<span>{{ $t("topBar.user.changeLog") }}</span>
</li>
<li class="btn-item" @click="openParamConfig">
<FaSvgIcon icon="ri:settings-3-line" />
<span>{{ $t("topBar.user.paramConfig") }}</span>
</li>
<li class="btn-item" @click="toDocs()">
<FaSvgIcon icon="ri:book-2-line" />
<span>{{ $t("topBar.user.docs") }}</span>
</li>
<li class="btn-item" @click="toGithub()">
<FaSvgIcon icon="ri:github-line" />
<span>{{ $t("topBar.user.github") }}</span>
</li>
<li class="btn-item" @click="toGitee">
<FaSvgIcon icon="ri:git-branch-line" />
<span>{{ $t("topBar.user.gitee") }}</span>
</li>
<li class="btn-item" @click="startTour">
<FaSvgIcon icon="ri:compass-3-line" />
<span>{{ $t("topBar.user.tour") }}</span>
</li>
<li class="btn-item" @click="lockScreen()">
<FaSvgIcon icon="ri:lock-line" />
<span>{{ $t("topBar.user.lockScreen") }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<li class="btn-item btn-item--logout" @click="handleLogout">
{{ $t("topBar.user.logout") }}
</li>
</ul>
</div>
</template>
</ElPopover>
<ConfigInfoDrawer v-model="paramDrawerVisible" />
</div>
</template>
<script setup lang="ts">
import { watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import ConfigInfoDrawer from "@views/module_system/param/components/ConfigInfoDrawer.vue";
import { useAppStore } from "@stores/modules/app.store";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useUserStore } from "@stores/modules/user.store";
import { WEB_LINKS } from "@utils/constants";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { mittBus } from "@utils/sys";
defineOptions({ name: "FaUserMenu" });
const router = useRouter();
const { t } = useI18n();
const userStore = useUserStore();
const appStore = useAppStore();
const settingStore = useSettingsStore();
const { info: userInfo } = storeToRefs(userStore);
const userMenuPopover = ref();
const paramDrawerVisible = ref(false);
const userAvatar = computed(() => {
const a = (userInfo.value as { avatar?: string })?.avatar?.trim();
return a || "";
});
const displayName = computed(
() =>
(userInfo.value as { name?: string; username?: string })?.name ||
(userInfo.value as { username?: string })?.username ||
"—"
);
const displayEmail = computed(() => (userInfo.value as { email?: string })?.email || "");
/** 与旧版 NavbarActions 一致:桌面浮动引导,移动端进引导页 */
const guideVisible = computed({
get: () => appStore.guideVisible,
set: (v: boolean) => appStore.showGuide(v),
});
function openParamConfig(): void {
closeUserMenu();
paramDrawerVisible.value = true;
}
function goPage(path: string): void {
router.push(path);
}
function goChangeLog(): void {
closeUserMenu();
router.push({ name: "ChangeLog" }).catch(() => {});
}
function toDocs(): void {
window.open(WEB_LINKS.DOCS);
}
function toGithub(): void {
window.open(WEB_LINKS.GITHUB);
}
function toGitee(): void {
window.open(WEB_LINKS.GITEE);
}
function lockScreen(): void {
mittBus.emit("openLockScreen");
}
function startTour(): void {
closeUserMenu();
if (appStore.device === DeviceEnum.MOBILE) {
router.push({ name: "Guide" });
} else {
guideVisible.value = true;
}
}
watch(
() => guideVisible.value,
(val, oldVal) => {
if (oldVal && !val) {
settingStore.updateSetting("showGuide", false);
}
}
);
function handleLogout(): void {
closeUserMenu();
setTimeout(() => {
ElMessageBox.confirm(t("common.logoutTips"), t("common.tips"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
customClass: "login-out-dialog",
}).then(() => {
userStore.logout();
});
}, 200);
}
function closeUserMenu(): void {
setTimeout(() => {
userMenuPopover.value?.hide?.();
}, 100);
}
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
@layer components {
.btn-item {
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
span {
@apply text-sm;
}
.fa-svg-icon {
@apply mr-2 text-base;
}
&:hover {
background-color: var(--fa-gray-200);
}
}
/** 退出:沿用菜单项 hover 底纹,并加重边框/文字,避免仅 shadow 几乎无反馈 */
.btn-item.btn-item--logout {
@apply justify-center mt-5 mb-0 py-1.5 text-xs border border-g-400;
&:hover {
color: var(--el-color-danger);
background-color: var(--fa-gray-200);
border-color: var(--el-color-danger-light-3);
}
}
}
/* ElPopover 基于 Tooltip:触发层默认 inline-block,与顶栏 flex 图标中线对齐 */
.fa-user-menu :deep(.el-tooltip__trigger) {
display: inline-flex !important;
align-items: center;
line-height: 1;
}
/* 顶栏头像右下角在线状态(对齐旧版顶栏);占位与 FaIconButton size-8.5 一致 */
.fa-user-menu__avatar-ref {
position: relative;
box-sizing: border-box;
}
.fa-user-menu__online-dot {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: 8px;
height: 8px;
pointer-events: none;
background-color: var(--el-color-success);
border-radius: 50%;
box-shadow: 0 0 2px rgb(0 0 0 / 20%);
}
</style>
@@ -0,0 +1,110 @@
<!-- 水平菜单 -->
<template>
<div id="app-menu-top" class="flex-1 overflow-hidden">
<ElMenu
:ellipsis="true"
mode="horizontal"
:default-active="routerPath"
:text-color="isDark ? 'var(--fa-gray-800)' : 'var(--fa-gray-700)'"
:popper-offset="-6"
background-color="transparent"
:show-timeout="50"
:hide-timeout="50"
popper-class="horizontal-menu-popper"
class="w-full border-none"
>
<HorizontalSubmenu
v-for="item in filteredMenuItems"
:key="item.path"
:item="item"
:isMobile="false"
:level="0"
/>
</ElMenu>
</div>
</template>
<script setup lang="ts">
import type { AppRouteRecord } from "@/types/router";
import HorizontalSubmenu from "./widget/HorizontalSubmenu.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
defineOptions({ name: "FaHorizontalMenu" });
const settingStore = useSettingsStore();
const { isDark } = storeToRefs(settingStore);
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[];
}
const route = useRoute();
const props = withDefaults(defineProps<Props>(), {
list: () => [],
});
/**
* 过滤后的菜单项列表
* 只显示未隐藏的菜单项
*/
const filteredMenuItems = computed(() => {
return filterMenuItems(props.list);
});
/**
* 当前激活的路由路径
* 用于菜单高亮显示
*/
const routerPath = computed(() => String(route.meta.activePath || route.path));
/**
* 递归过滤菜单项,移除隐藏的菜单
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组
* @returns 过滤后的菜单项数组
*/
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items
.filter((item) => {
// 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) {
return false;
}
// 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = filterMenuItems(item.children);
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0;
}
// 叶子节点且未被隐藏,保留
return true;
})
.map((item) => ({
...item,
children: item.children ? filterMenuItems(item.children) : undefined,
}));
};
</script>
<style scoped>
/* Remove el-menu bottom border */
:deep(.el-menu) {
border-bottom: none !important;
}
/* Remove default styles for first-level menu items */
:deep(.el-menu-item[tabindex="0"]) {
background-color: transparent !important;
border: none !important;
}
/* Remove bottom border from submenu titles */
:deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
</style>
@@ -0,0 +1,111 @@
<template>
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
<template #title>
<FaMenuRouteIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="fa-badge fa-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="fa-text-badge">
{{ item.meta.showTextBadge }}
</div>
</template>
<!-- 递归调用自身处理子菜单 -->
<HorizontalSubmenu
v-for="child in filteredChildren"
:key="child.path"
:item="child"
:theme="theme"
:is-mobile="isMobile"
:level="level + 1"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else-if="isNavigableRoute"
:index="item.path || item.meta.title"
@click="goPage(item)"
>
<MenuRouteIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
class="mr-1 text-lg"
:style="{ color: theme.iconColor }"
/>
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div
v-if="item.meta.showBadge"
class="fa-badge"
:style="{ right: level === 0 ? '10px' : '20px' }"
/>
<div v-if="item.meta.showTextBadge && level !== 0" class="fa-text-badge">
{{ item.meta.showTextBadge }}
</div>
</ElMenuItem>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { AppRouteRecord } from "@/types/router";
import { handleMenuJump } from "@utils/navigation";
import { formatMenuTitle } from "@utils/navigation";
const props = defineProps({
item: {
type: Object as PropType<AppRouteRecord>,
required: true,
},
theme: {
type: Object,
default: () => ({}),
},
isMobile: Boolean,
level: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["close"]);
// 过滤后的子菜单项(不包含隐藏的)
const filteredChildren = computed(() => {
return props.item.children?.filter((child) => !child.meta.isHide) || [];
});
// 父菜单如果本身就是页面,则即使没有可见子菜单也应该保留为菜单项。
const isNavigableRoute = computed(() => {
if (props.item.meta?.isHide) {
return false;
}
if (props.item.meta?.shellRoute && props.item.path?.trim()) {
return true;
}
return !!(
((props.item.path && props.item.path.trim()) ||
props.item.meta.link ||
props.item.meta.isIframe === true) &&
(props.item.component || props.item.meta.link || props.item.meta.isIframe === true)
);
});
// 计算当前项是否有可见的子菜单
const hasChildren = computed(() => {
return filteredChildren.value.length > 0;
});
const goPage = (item: AppRouteRecord) => {
closeMenu();
handleMenuJump(item);
};
const closeMenu = () => {
emit("close");
};
</script>
<style scoped>
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important;
}
</style>
@@ -0,0 +1,279 @@
<!-- 混合菜单 -->
<template>
<div id="app-menu-top-left" class="relative box-border flex-c w-full overflow-hidden">
<!-- 左侧滚动按钮 -->
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
<ElIcon>
<ArrowLeft />
</ElIcon>
</div>
<!-- 滚动容器 -->
<ElScrollbar
ref="scrollbarRef"
wrap-class="scrollbar-wrapper"
:horizontal="true"
@scroll="handleScroll"
@wheel="handleWheel"
>
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
<template v-for="item in processedMenuList" :key="item.meta.title">
<div
v-if="!item.meta.isHide"
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
:class="{
'menu-item-active text-theme': item.isActive,
}"
@click="handleMenuJump(item, true)"
>
<FaMenuRouteIcon
:icon="item.meta.icon"
class="text-lg text-g-700 dark:text-g-800 mr-1"
:class="item.isActive && '!text-theme'"
/>
<span
class="text-md text-g-700 dark:text-g-800"
:class="item.isActive && '!text-theme'"
>
{{ item.formattedTitle }}
</span>
<div v-if="item.meta.showBadge" class="fa-badge fa-badge-mixed" />
</div>
</template>
</div>
</ElScrollbar>
<!-- 右侧滚动按钮 -->
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
<ElIcon>
<ArrowRight />
</ElIcon>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
import { useThrottleFn } from "@vueuse/core";
import { formatMenuTitle } from "@utils/navigation";
import { handleMenuJump } from "@utils/navigation";
import type { AppRouteRecord } from "@/types/router";
defineOptions({ name: "FaMixedMenu" });
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[];
}
interface ProcessedMenuItem extends AppRouteRecord {
isActive: boolean;
formattedTitle: string;
}
type ScrollDirection = "left" | "right";
const route = useRoute();
const props = withDefaults(defineProps<Props>(), {
list: () => [],
});
const scrollbarRef = ref<any>();
const showLeftArrow = ref(false);
const showRightArrow = ref(false);
/** 滚动配置 */
const SCROLL_CONFIG = {
/** 点击按钮时的滚动距离 */
BUTTON_SCROLL_DISTANCE: 200,
/** 鼠标滚轮快速滚动时的步长 */
WHEEL_FAST_STEP: 35,
/** 鼠标滚轮慢速滚动时的步长 */
WHEEL_SLOW_STEP: 30,
/** 区分快慢滚动的阈值 */
WHEEL_FAST_THRESHOLD: 100,
};
/**
* 获取当前激活路径
* 使用computed缓存,避免重复计算
*/
const currentActivePath = computed(() => {
return String(route.meta.activePath || route.path);
});
/**
* 判断菜单项是否为激活状态
* 递归检查子菜单中是否包含当前路径
* @param item 菜单项数据
* @returns 是否为激活状态
*/
const isMenuItemActive = (item: AppRouteRecord): boolean => {
const activePath = currentActivePath.value;
// 如果有子菜单,递归检查子菜单
if (item.children?.length) {
return item.children.some((child) => {
if (child.children?.length) {
return isMenuItemActive(child);
}
return child.path === activePath;
});
}
// 直接比较路径
return item.path === activePath;
};
/**
* 预处理菜单列表
* 缓存每个菜单项的激活状态和格式化标题
*/
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
return props.list.map((item) => ({
...item,
isActive: isMenuItemActive(item),
formattedTitle: formatMenuTitle(item.meta.title),
}));
});
/**
* 处理滚动事件的核心逻辑
* 根据滚动位置显示/隐藏滚动按钮
*/
const handleScrollCore = (): void => {
if (!scrollbarRef.value?.wrapRef) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef;
// 判断是否显示左侧滚动按钮
showLeftArrow.value = scrollLeft > 0;
// 判断是否显示右侧滚动按钮
showRightArrow.value = scrollLeft + clientWidth < scrollWidth;
};
/**
* 节流后的滚动事件处理函数
* 调整节流间隔为16ms,约等于60fps
*/
const handleScroll = useThrottleFn(handleScrollCore, 16);
/**
* 滚动菜单容器
* @param direction 滚动方向,left 或 right
*/
const scroll = (direction: ScrollDirection): void => {
if (!scrollbarRef.value?.wrapRef) return;
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft;
const targetScroll =
direction === "left"
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE;
// 平滑滚动到目标位置
scrollbarRef.value.wrapRef.scrollTo({
left: targetScroll,
behavior: "smooth",
});
};
/**
* 处理鼠标滚轮事件
* 优化滚轮响应性能
* @param event 滚轮事件
*/
const handleWheel = (event: WheelEvent): void => {
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
event.preventDefault();
event.stopPropagation();
// 直接处理滚动,提升响应性
if (!scrollbarRef.value?.wrapRef) return;
const { wrapRef } = scrollbarRef.value;
const { scrollLeft, scrollWidth, clientWidth } = wrapRef;
// 使用更小的滚动步长,让滚动更平滑
const scrollStep =
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
? SCROLL_CONFIG.WHEEL_FAST_STEP
: SCROLL_CONFIG.WHEEL_SLOW_STEP;
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep;
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth));
// 立即滚动,无动画
wrapRef.scrollLeft = targetScroll;
// 更新滚动按钮状态
handleScrollCore();
};
/**
* 初始化滚动状态
*/
const initScrollState = (): void => {
nextTick(() => {
handleScrollCore();
});
};
onMounted(initScrollState);
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button-arrow {
@apply absolute
top-1/2
z-2
flex
items-center
justify-center
size-7.5
text-g-600
cursor-pointer
rounded
transition-all
duration-300
-translate-y-1/2
hover:text-g-900
hover:bg-g-200;
}
</style>
<style scoped>
:deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
display: none;
height: 2px;
}
:deep(.scrollbar-wrapper) {
flex: 1;
min-width: 0;
margin: 0 50px 0 30px;
}
.menu-item-active::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 40px;
height: 2px;
margin: auto;
content: "";
background-color: var(--theme-color);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
</style>
@@ -0,0 +1,386 @@
<!-- 左侧菜单 或 双列菜单 -->
<template>
<div
class="layout-sidebar"
v-if="showLeftMenu || isDualMenu"
:class="{ 'no-border': menuList.length === 0 }"
>
<!-- 双列菜单(左侧) -->
<div
v-if="isDualMenu"
class="dual-menu-left"
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }"
>
<FaLogo v-if="showAppLogo" class="logo" :src="sidebarLogoSrc" @click="navigateToHome" />
<ElScrollbar style="height: calc(100% - 135px)">
<ul>
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
<ElTooltip
class="box-item"
effect="dark"
:content="$t(menu.meta.title)"
placement="right"
:offset="15"
:hide-after="0"
:disabled="dualMenuShowText"
>
<div
:class="{
'is-active': menu.meta.isFirstLevel
? menu.path === route.path
: menu.path === firstLevelMenuPath,
}"
:style="{
height: dualMenuShowText ? '60px' : '46px',
}"
>
<FaMenuRouteIcon
class="menu-icon text-g-700 dark:text-g-800"
:icon="menu.meta.icon"
:style="{
marginBottom: dualMenuShowText ? '5px' : '0',
}"
/>
<span v-if="dualMenuShowText" class="text-md text-g-700">
{{ $t(menu.meta.title) }}
</span>
<div v-if="menu.meta.showBadge" class="fa-badge fa-badge-dual" />
</div>
</ElTooltip>
</li>
</ul>
</ElScrollbar>
<FaIconButton
class="switch-btn size-10"
icon="ri:arrow-left-right-fill"
@click="toggleDualMenuMode"
/>
</div>
<!-- 左侧菜单 || 双列菜单(右侧) -->
<div
v-show="menuList.length > 0"
class="menu-left"
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }"
>
<!-- Logo、系统名称(开关同时控制 Logo 与标题) -->
<div
v-if="showAppLogo"
class="header"
@click="navigateToHome"
:style="{
background: getMenuTheme.background,
}"
>
<FaLogo v-if="!isDualMenu" class="logo" :src="sidebarLogoSrc" />
<p
:class="{ 'is-dual-menu-name': isDualMenu }"
:style="{
color: getMenuTheme.systemNameColor,
opacity: !menuOpen ? 0 : 1,
}"
>
{{ sidebarTitle }}
</p>
</div>
<ElScrollbar :style="scrollbarStyle">
<ElMenu
:class="'el-menu-' + getMenuTheme.theme"
:collapse="!menuOpen"
:default-active="routerPath"
:text-color="getMenuTheme.textColor"
:unique-opened="uniqueOpened"
:background-color="getMenuTheme.background"
:default-openeds="defaultOpenedMenus"
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
:show-timeout="50"
:hide-timeout="50"
>
<SidebarSubmenu
:list="menuList"
:isMobile="isMobileMode"
:theme="getMenuTheme"
@close="handleMenuClose"
/>
</ElMenu>
</ElScrollbar>
<!-- 双列菜单右侧折叠按钮 -->
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
<FaSvgIcon
class="text-g-500/70"
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
/>
</div>
<div
class="menu-model"
@click="toggleMenuVisibility"
:style="{
opacity: !menuOpen ? 0 : 1,
transform: showMobileModal ? 'scale(1)' : 'scale(0)',
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from "@/config";
import { useConfigStore } from "@stores/modules/config.store";
import { useSettingsStore } from "@stores/modules/setting.store";
import { MenuTypeEnum, MenuWidth } from "@/enums/appEnum";
import { useMenuStore } from "@stores/modules/menu.store";
import { isIframe } from "@utils/navigation";
import { handleMenuJump } from "@utils/navigation";
import SidebarSubmenu from "./widget/SidebarSubmenu.vue";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
import { useCommon } from "@/hooks/core/useCommon";
import { useWindowSize, useTimeoutFn } from "@vueuse/core";
defineOptions({ name: "FaSidebarMenu" });
const MOBILE_BREAKPOINT = 800;
const ANIMATION_DELAY = 350;
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE;
const route = useRoute();
const router = useRouter();
const settingStore = useSettingsStore();
const configStore = useConfigStore();
/** 与旧版 layouts/old/components/AppLogo 一致:参数 sys_web_logo / sys_web_title */
const sidebarLogoSrc = computed(() => {
const raw = configStore.configData.sys_web_logo?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const sidebarTitle = computed(() => {
const raw = configStore.configData.sys_web_title?.config_value;
if (typeof raw === "string" && raw.trim()) return raw.trim();
return AppConfig.systemInfo.name;
});
const {
getMenuOpenWidth,
menuType,
uniqueOpened,
dualMenuShowText,
menuOpen,
getMenuTheme,
showAppLogo,
} = storeToRefs(settingStore);
// 组件内部状态
const defaultOpenedMenus = ref<string[]>([]);
const isMobileMode = ref(false);
const showMobileModal = ref(false);
// 使用 VueUse 的窗口尺寸监听
const { width } = useWindowSize();
// 菜单宽度相关
const menuopenwidth = computed(() => getMenuOpenWidth.value);
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH);
// 菜单类型判断
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT);
const showLeftMenu = computed(
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
);
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU);
// 移动端屏幕判断(使用 computed 避免重复计算)
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT);
// 路由相关
const firstLevelMenuPath = computed(() => route.matched[0]?.path);
const routerPath = computed(() => String(route.meta.activePath || route.path));
// 菜单数据
const firstLevelMenus = computed(() => {
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide);
});
const menuList = computed(() => {
const menuStore = useMenuStore();
const allMenus = menuStore.menuList;
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
if (!isTopLeftMenu.value && !isDualMenu.value) {
return allMenus;
}
// 处理 iframe 路径
if (isIframe(route.path)) {
return findIframeMenuList(route.path, allMenus);
}
// 处理一级菜单
if (route.meta.isFirstLevel) {
return [];
}
// 返回当前顶级路径对应的子菜单
const currentTopPath = `/${route.path.split("/")[1]}`;
let currentMenu = allMenus.find((menu) => menu.path === currentTopPath);
if (!currentMenu && allMenus.length > 0) {
currentMenu =
allMenus.find((menu) => !menu.meta?.isHide && menu.children?.length) ?? allMenus[0];
}
const sub = currentMenu?.children ?? [];
// 顶部+左侧 / 双列:顶级为叶子(如 /home)时右侧展示自身
if (sub.length === 0 && currentMenu?.path && !currentMenu.meta?.isHide) {
return [currentMenu];
}
return sub;
});
// 双列菜单收起时的滚动条样式
const scrollbarStyle = computed(() => {
const isCollapsed = isDualMenu.value && !menuOpen.value;
return {
transform: isCollapsed ? "translateY(-50px)" : "translateY(0)",
height: isCollapsed ? "calc(100% + 50px)" : "calc(100% - 60px)",
transition: "transform 0.3s ease",
};
});
/**
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
*/
const { start: delayHideMobileModal } = useTimeoutFn(
() => {
showMobileModal.value = false;
},
ANIMATION_DELAY,
{ immediate: false }
);
/**
* 查找 iframe 对应的二级菜单列表
*/
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
// 递归查找包含当前路径的菜单项
const hasPath = (items: any[]): boolean => {
for (const item of items) {
if (item.path === currentPath) {
return true;
}
if (item.children && hasPath(item.children)) {
return true;
}
}
return false;
};
// 遍历一级菜单查找匹配的子菜单
for (const menu of menuList) {
if (menu.children && hasPath(menu.children)) {
return menu.children;
}
}
return [];
};
const { homePath } = useCommon();
/**
* 导航到首页
*/
const navigateToHome = (): void => {
router.push(homePath.value);
};
/**
* 切换菜单显示/隐藏
*/
const toggleMenuVisibility = (): void => {
settingStore.setMenuOpen(!menuOpen.value);
// 移动端模态框控制逻辑
if (isMobileScreen.value) {
if (!menuOpen.value) {
// 菜单即将打开,立即显示模态框
showMobileModal.value = true;
} else {
// 菜单即将关闭,延迟隐藏模态框确保动画完成
delayHideMobileModal();
}
}
};
/**
* 处理菜单关闭(来自子组件)
*/
const handleMenuClose = (): void => {
if (isMobileScreen.value) {
settingStore.setMenuOpen(false);
delayHideMobileModal();
}
};
/**
* 切换双列菜单模式
*/
const toggleDualMenuMode = (): void => {
settingStore.setDualMenuShowText(!dualMenuShowText.value);
};
/**
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false);
if (!menuOpen.value) {
showMobileModal.value = false;
}
} else {
showMobileModal.value = false;
}
});
/**
* 监听菜单开关状态变化
*/
watch(menuOpen, (isMenuOpen: boolean) => {
if (!isMobileScreen.value) {
// 大屏幕设备上,模态框始终隐藏
showMobileModal.value = false;
} else {
// 小屏幕设备上,根据菜单状态控制模态框
if (isMenuOpen) {
// 菜单打开时立即显示模态框
showMobileModal.value = true;
} else {
// 菜单关闭时延迟隐藏模态框,确保动画完成
delayHideMobileModal();
}
}
});
</script>
<style lang="scss" scoped>
@use "./style";
</style>
<style lang="scss">
@use "./theme";
.layout-sidebar {
// 展开的宽度
.el-menu:not(.el-menu--collapse) {
width: v-bind(menuopenwidth);
}
// 折叠后宽度
.el-menu--collapse {
width: v-bind(menuclosewidth);
}
}
</style>
@@ -0,0 +1,251 @@
.layout-sidebar {
display: flex;
height: 100vh;
user-select: none;
scrollbar-width: none;
border-right: 1px solid var(--art-card-border);
&.no-border {
border-right: none !important;
}
// 自定义滚动条宽度
:deep(.el-scrollbar__bar.is-vertical) {
width: 4px;
}
:deep(.el-scrollbar__thumb) {
right: -2px;
background-color: #ccc;
border-radius: 2px;
}
.dual-menu-left {
position: relative;
width: 80px;
height: 100%;
border-right: 1px solid var(--art-card-border) !important;
transition: width 0.25s;
.logo {
margin: auto;
margin-top: 12px;
margin-bottom: 3px;
cursor: pointer;
}
ul {
li {
> div {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 8px;
overflow: hidden;
text-align: center;
cursor: pointer;
border-radius: 5px;
.art-svg-icon {
display: block;
margin: 0 auto;
font-size: 20px;
}
span {
display: -webkit-box;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
line-clamp: 1;
font-size: 12px;
-webkit-box-orient: vertical;
}
&.is-active {
background: var(--el-color-primary-light-9);
.art-svg-icon,
span {
color: var(--theme-color) !important;
}
}
}
}
}
.switch-btn {
position: absolute;
right: 0;
bottom: 15px;
left: 0;
margin: auto;
}
}
.menu-left {
position: relative;
box-sizing: border-box;
height: 100vh;
@media only screen and (width <= 640px) {
height: 100dvh;
}
.el-menu {
height: 100%;
}
&:hover {
.dual-menu-collapse-btn {
opacity: 1 !important;
}
}
.dual-menu-collapse-btn {
position: absolute;
top: 50%;
right: -11px;
z-index: 10;
width: 11px;
height: 50px;
cursor: pointer;
background-color: var(--default-box-color);
border: 1px solid var(--art-card-border);
border-radius: 0 15px 15px 0;
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.2s;
&:hover {
.art-svg-icon {
color: var(--art-gray-800) !important;
}
}
.art-svg-icon {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
margin: auto;
transition: all 0.3s;
}
}
}
.header {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
height: 60px;
overflow: hidden;
line-height: 60px;
cursor: pointer;
.logo {
margin-left: 22px;
}
p {
position: absolute;
top: 0;
bottom: 0;
left: 58px;
box-sizing: border-box;
margin-left: 10px;
font-size: 18px;
&.is-dual-menu-name {
left: 25px;
margin: auto;
}
}
}
.el-menu {
box-sizing: border-box;
// 防止菜单内的滚动影响整个页面滚动
overscroll-behavior: contain;
scrollbar-width: none;
border-right: 0;
-ms-scroll-chaining: contain;
&::-webkit-scrollbar {
width: 0 !important;
}
}
.menu-model {
display: none;
}
}
@media only screen and (width <= 800px) {
.layout-sidebar {
width: 0;
.header {
height: 50px;
line-height: 50px;
}
.el-menu {
height: calc(100vh - 60px);
}
.el-menu--collapse {
width: 0;
}
// 折叠状态下的header样式
.menu-left-close .header {
.logo {
display: none;
}
p {
left: 16px;
font-size: 0;
opacity: 0 !important;
}
}
.menu-model {
position: fixed;
top: 0;
left: 0;
z-index: -1;
display: block;
width: 100%;
height: 100vh;
background: rgba($color: #000, $alpha: 50%);
transition: opacity 0.2s ease-in-out;
}
}
}
@media only screen and (width <= 640px) {
.layout-sidebar {
border-right: 0 !important;
}
}
.dark {
.layout-sidebar {
border-right: 1px solid rgb(255 255 255 / 13%);
:deep(.el-scrollbar__thumb) {
background-color: #777;
}
.dual-menu-left {
border-right: 1px solid rgb(255 255 255 / 9%) !important;
}
}
}
@@ -0,0 +1,258 @@
@use "@styles/core/mixin.scss" as *;
// 菜单样式变量
$menu-height: 42px;
$menu-icon-size: 20px;
$menu-font-size: 14px;
$hover-bg-color: var(--art-gray-200);
$popup-menu-height: 40px;
$popup-menu-padding: 8px;
$popup-menu-margin: 5px;
$popup-menu-radius: 6px;
// 通用菜单项样式
@mixin menu-item-base {
width: calc(100% - 16px);
margin-left: 8px;
border-radius: 6px;
.menu-icon {
margin-left: -7px;
}
}
// 通用 hover 样式
@mixin menu-hover($bg-color) {
.el-sub-menu__title:hover,
.el-menu-item:not(.is-active):hover {
background: $bg-color !important;
}
}
// 通用选中样式
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
.el-menu-item.is-active {
color: $color !important;
background-color: $bg-color;
.menu-icon {
.art-svg-icon {
color: $icon-color !important;
}
}
}
}
// 弹窗菜单项样式
@mixin popup-menu-item {
height: $popup-menu-height;
margin-bottom: $popup-menu-margin;
border-radius: $popup-menu-radius;
.menu-icon {
margin-right: 5px;
}
&:last-of-type {
margin-bottom: 0;
}
}
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
@mixin theme-menu-base {
.el-sub-menu__title,
.el-menu-item {
@include menu-item-base;
}
}
// 弹窗菜单通用样式
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
.el-menu--popup {
padding: $popup-menu-padding;
.el-sub-menu__title:hover,
.el-menu-item:hover {
background-color: $hover-bg !important;
border-radius: $popup-menu-radius;
}
.el-menu-item {
@include popup-menu-item;
&.is-active {
color: $active-color !important;
background-color: $active-bg !important;
}
}
.el-sub-menu {
@include popup-menu-item;
height: $popup-menu-height !important;
.el-sub-menu__title {
height: $popup-menu-height !important;
border-radius: $popup-menu-radius;
}
}
}
}
.layout-sidebar {
// ---------------------- Modify default style ----------------------
// 菜单折叠样式
.menu-left-close {
.header {
.logo {
margin: 0 auto;
}
}
}
// 菜单图标
.menu-icon {
margin-right: 8px;
font-size: $menu-icon-size;
}
// 菜单高度
.el-sub-menu__title,
.el-menu-item {
height: $menu-height !important;
margin-bottom: 4px;
line-height: $menu-height !important;
span {
font-size: $menu-font-size !important;
@include ellipsis();
}
}
// 右侧箭头
.el-sub-menu__icon-arrow {
width: 13px !important;
font-size: 13px !important;
}
// 菜单折叠
.el-menu--collapse {
.el-sub-menu.is-active {
.el-sub-menu__title {
.menu-icon {
.art-svg-icon {
// 选中菜单图标颜色
color: var(--theme-color) !important;
}
}
}
}
}
// ---------------------- Design theme menu ----------------------
.el-menu-design {
@include theme-menu-base;
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
}
}
// ---------------------- Dark theme menu ----------------------
.el-menu-dark {
@include theme-menu-base;
@include menu-active(#fff, #27282d, #fff);
@include menu-hover(#0f1015);
.el-sub-menu__icon-arrow {
color: var(--art-gray-400);
}
}
// ---------------------- Light theme menu ----------------------
.el-menu-light {
.el-sub-menu__title,
.el-menu-item {
.menu-icon {
margin-left: 1px;
}
}
.el-menu-item.is-active {
background-color: var(--el-color-primary-light-9);
.art-svg-icon {
color: var(--theme-color) !important;
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
content: "";
background: var(--theme-color);
}
}
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
}
}
}
@media only screen and (width <= 640px) {
.layout-sidebar {
.el-menu-design {
> .el-sub-menu {
margin-left: 0;
}
.el-sub-menu {
width: 100% !important;
}
}
}
}
// 菜单折叠 hover 弹窗样式(浅色主题)
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
}
// 暗黑模式菜单样式
.dark {
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
}
.layout-sidebar {
// 图标颜色、文字颜色
.menu-icon .art-svg-icon,
.menu-name {
color: var(--art-gray-800) !important;
}
// 选中的文字颜色跟图标颜色
.el-menu-item.is-active {
span,
.menu-icon .art-svg-icon {
color: var(--theme-color) !important;
}
}
// 右侧箭头颜色
.el-sub-menu__icon-arrow {
color: #fff;
}
}
}
@@ -0,0 +1,205 @@
<template>
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
<template #title>
<div class="menu-icon flex-cc">
<FaMenuRouteIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
</div>
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="fa-badge" style="right: 10px" />
</template>
<SidebarSubmenu
:list="item.children"
:is-mobile="isMobile"
:level="level + 1"
:theme="theme"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
:level-item="level + 1"
@click="goPage(item)"
>
<div class="menu-icon flex-cc">
<FaMenuRouteIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
</div>
<div
v-show="item.meta.showBadge && level === 0 && !menuOpen"
class="fa-badge"
style="right: 5px"
/>
<template #title>
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="fa-badge" />
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="fa-text-badge">
{{ item.meta.showTextBadge }}
</div>
</template>
</ElMenuItem>
</template>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { AppRouteRecord } from "@/types/router";
import { formatMenuTitle } from "@utils/navigation";
import { handleMenuJump } from "@utils/navigation";
import { useSettingsStore } from "@stores/modules/setting.store";
import FaMenuRouteIcon from "@/components/others/fa-menu-routeIcon/index.vue";
interface MenuTheme {
iconColor?: string;
}
interface Props {
/** 菜单标题 */
title?: string;
/** 菜单列表 */
list?: AppRouteRecord[];
/** 主题配置 */
theme?: MenuTheme;
/** 是否为移动端模式 */
isMobile?: boolean;
/** 菜单层级 */
level?: number;
}
interface Emits {
/** 关闭菜单事件 */
(e: "close"): void;
}
const props = withDefaults(defineProps<Props>(), {
title: "",
list: () => [],
theme: () => ({}),
isMobile: false,
level: 0,
});
const emit = defineEmits<Emits>();
const settingStore = useSettingsStore();
const { menuOpen } = storeToRefs(settingStore);
/**
* 过滤后的菜单项列表
* 只显示未隐藏的菜单项
*/
const filteredMenuItems = computed(() => filterRoutes(props.list));
/**
* 跳转到指定页面
* @param item 菜单项数据
*/
const goPage = (item: AppRouteRecord): void => {
closeMenu();
handleMenuJump(item);
};
/**
* 关闭菜单
* 触发父组件的关闭事件
*/
const closeMenu = (): void => {
emit("close");
};
/**
* 判断菜单项本身是否可以作为可点击页面保留在菜单中
*/
const isNavigableRoute = (item: AppRouteRecord): boolean => {
if (item.meta?.isHide) {
return false;
}
if (item.meta?.shellRoute && item.path?.trim()) {
return true;
}
return !!(
((item.path && item.path.trim()) || item.meta.link || item.meta.isIframe === true) &&
(item.component || item.meta.link || item.meta.isIframe === true)
);
};
/**
* 递归过滤菜单路由,移除隐藏的菜单项
* 但如果父菜单本身就是可访问页面,则即使子菜单都被隐藏也应该保留
* @param items 菜单项数组
* @returns 过滤后的菜单项数组
*/
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items
.filter((item) => {
// 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) {
return false;
}
// 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = filterRoutes(item.children);
// 目录菜单要求有可见子菜单;页面菜单则允许仅保留自身
return filteredChildren.length > 0 || isNavigableRoute(item);
}
// 叶子节点且未被隐藏,保留
return isNavigableRoute(item);
})
.map((item) => ({
...item,
children: item.children ? filterRoutes(item.children) : undefined,
}));
};
/**
* 判断菜单项是否包含可见的子菜单
* @param item 菜单项数据
* @returns 是否包含可见的子菜单
*/
const hasChildren = (item: AppRouteRecord): boolean => {
if (!item.children || item.children.length === 0) {
return false;
}
// 递归检查是否有可见的子菜单
const filteredChildren = filterRoutes(item.children);
return filteredChildren.length > 0;
};
/**
* 判断是否为外部链接
* @param item 菜单项数据
* @returns 是否为外部链接
*/
const isExternalLink = (item: AppRouteRecord): boolean => {
return !!(item.meta.link && !item.meta.isIframe);
};
/**
* 生成唯一的 key
* 使用 path、title 和 index 组合确保唯一性
* @param item 菜单项数据
* @param index 索引
* @returns 唯一的 key
*/
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
return `${item.path || item.meta.title || "menu"}-${props.level}-${index}`;
};
</script>
@@ -0,0 +1,456 @@
<!-- 通知组件 -->
<template>
<div
class="fa-notification-panel fa-card-sm !shadow-xl"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0,
}"
v-show="visible"
@click.stop
>
<div class="flex-cb px-3.5 mt-3.5">
<span class="text-base font-medium text-g-800">{{ $t("notice.title") }}</span>
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
{{ $t("notice.btnRead") }}
</span>
</div>
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
<li
v-for="(item, index) in barList"
:key="index"
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
:class="{ 'bar-active': barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
</li>
</ul>
<div class="w-full h-[calc(100%-95px)]">
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
<!-- 通知 -->
<ul v-show="barActiveIndex === 0">
<li
v-for="(item, index) in noticeList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<div
class="size-9 leading-9 text-center rounded-lg flex-cc"
:class="[getNoticeStyle(item.type).iconClass]"
>
<FaSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul v-show="barActiveIndex === 1">
<li
v-for="(item, index) in msgList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<div class="w-9 h-9">
<img :src="item.avatar" class="w-full h-full rounded-lg" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul v-show="barActiveIndex === 2">
<li
v-for="(item, index) in pendingList"
:key="index"
class="box-border px-5 py-3.5 last:border-b-0"
>
<h4>{{ item.title }}</h4>
<p class="text-xs text-g-500">{{ item.time }}</p>
</li>
</ul>
<!-- 空状态 -->
<div
v-show="currentTabIsEmpty"
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
>
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs !bg-transparent">
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex].name }}
</p>
</div>
</div>
<div class="relative box-border w-full px-3.5">
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
{{ $t("notice.viewAll") }}
</ElButton>
</div>
</div>
<div class="h-25"></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, type Ref, type ComputedRef } from "vue";
import { useI18n } from "vue-i18n";
// 导入头像图片
import avatar1 from "@imgs/avatar/avatar1.webp";
import avatar2 from "@imgs/avatar/avatar2.webp";
import avatar3 from "@imgs/avatar/avatar3.webp";
import avatar4 from "@imgs/avatar/avatar4.webp";
import avatar5 from "@imgs/avatar/avatar5.webp";
import avatar6 from "@imgs/avatar/avatar6.webp";
defineOptions({ name: "FaNotification" });
interface NoticeItem {
/** 标题 */
title: string;
/** 时间 */
time: string;
/** 类型 */
type: NoticeType;
}
interface MessageItem {
/** 标题 */
title: string;
/** 时间 */
time: string;
/** 头像 */
avatar: string;
}
interface PendingItem {
/** 标题 */
title: string;
/** 时间 */
time: string;
}
interface BarItem {
/** 名称 */
name: ComputedRef<string>;
/** 数量 */
num: number;
}
interface NoticeStyle {
/** 图标 */
icon: string;
/** icon 样式 */
iconClass: string;
}
type NoticeType = "email" | "message" | "collection" | "user" | "notice";
const { t } = useI18n();
const props = defineProps<{
value: boolean;
}>();
const emit = defineEmits<{
"update:value": [value: boolean];
}>();
const show = ref(false);
const visible = ref(false);
const barActiveIndex = ref(0);
const useNotificationData = () => {
// 通知数据
const noticeList = ref<NoticeItem[]>([
{
title: "新增国际化",
time: "2024-6-13 0:10",
type: "notice",
},
{
title: "冷月呆呆给你发了一条消息",
time: "2024-4-21 8:05",
type: "message",
},
{
title: "小肥猪关注了你",
time: "2020-3-17 21:12",
type: "collection",
},
{
title: "新增使用文档",
time: "2024-02-14 0:20",
type: "notice",
},
{
title: "小肥猪给你发了一封邮件",
time: "2024-1-20 0:15",
type: "email",
},
{
title: "菜单mock本地真实数据",
time: "2024-1-17 22:06",
type: "notice",
},
]);
// 消息数据
const msgList = ref<MessageItem[]>([
{
title: "池不胖 关注了你",
time: "2021-2-26 23:50",
avatar: avatar1,
},
{
title: "唐不苦 关注了你",
time: "2021-2-21 8:05",
avatar: avatar2,
},
{
title: "中小鱼 关注了你",
time: "2020-1-17 21:12",
avatar: avatar3,
},
{
title: "何小荷 关注了你",
time: "2021-01-14 0:20",
avatar: avatar4,
},
{
title: "誶誶淰 关注了你",
time: "2020-12-20 0:15",
avatar: avatar5,
},
{
title: "冷月呆呆 关注了你",
time: "2020-12-17 22:06",
avatar: avatar6,
},
]);
// 待办数据
const pendingList = ref<PendingItem[]>([]);
// 标签栏数据
const barList = computed<BarItem[]>(() => [
{
name: computed(() => t("notice.bar[0]")),
num: noticeList.value.length,
},
{
name: computed(() => t("notice.bar[1]")),
num: msgList.value.length,
},
{
name: computed(() => t("notice.bar[2]")),
num: pendingList.value.length,
},
]);
return {
noticeList,
msgList,
pendingList,
barList,
};
};
// 样式管理
const useNotificationStyles = () => {
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
email: {
icon: "ri:mail-line",
iconClass: "bg-warning/12 text-warning",
},
message: {
icon: "ri:volume-down-line",
iconClass: "bg-success/12 text-success",
},
collection: {
icon: "ri:hefa-3-line",
iconClass: "bg-danger/12 text-danger",
},
user: {
icon: "ri:volume-down-line",
iconClass: "bg-info/12 text-info",
},
notice: {
icon: "ri:notification-3-line",
iconClass: "bg-theme/12 text-theme",
},
};
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
const defaultStyle: NoticeStyle = {
icon: "ri:arrow-right-circle-line",
iconClass: "bg-theme/12 text-theme",
};
return noticeStyleMap[type] || defaultStyle;
};
return {
getNoticeStyle,
};
};
// 动画管理
const useNotificationAnimation = () => {
const showNotice = (open: boolean) => {
if (open) {
visible.value = true;
setTimeout(() => {
show.value = true;
}, 5);
} else {
show.value = false;
setTimeout(() => {
visible.value = false;
}, 350);
}
};
return {
showNotice,
};
};
// 标签页管理
const useTabManagement = (
noticeList: Ref<NoticeItem[]>,
msgList: Ref<MessageItem[]>,
pendingList: Ref<PendingItem[]>,
businessHandlers: {
handleNoticeAll: () => void;
handleMsgAll: () => void;
handlePendingAll: () => void;
}
) => {
const changeBar = (index: number) => {
barActiveIndex.value = index;
};
// 检查当前标签页是否为空
const currentTabIsEmpty = computed(() => {
const tabDataMap = [noticeList.value, msgList.value, pendingList.value];
const currentData = tabDataMap[barActiveIndex.value];
return currentData && currentData.length === 0;
});
const handleViewAll = () => {
// 查看全部处理器映射
const viewAllHandlers: Record<number, () => void> = {
0: businessHandlers.handleNoticeAll,
1: businessHandlers.handleMsgAll,
2: businessHandlers.handlePendingAll,
};
const handler = viewAllHandlers[barActiveIndex.value];
handler?.();
// 关闭通知面板
emit("update:value", false);
};
return {
changeBar,
currentTabIsEmpty,
handleViewAll,
};
};
// 业务逻辑处理
const useBusinessLogic = () => {
const handleNoticeAll = () => {
// 处理查看全部通知
console.log("查看全部通知");
};
const handleMsgAll = () => {
// 处理查看全部消息
console.log("查看全部消息");
};
const handlePendingAll = () => {
// 处理查看全部待办
console.log("查看全部待办");
};
return {
handleNoticeAll,
handleMsgAll,
handlePendingAll,
};
};
// 组合所有逻辑
const { noticeList, msgList, pendingList, barList } = useNotificationData();
const { getNoticeStyle } = useNotificationStyles();
const { showNotice } = useNotificationAnimation();
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic();
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
noticeList,
msgList,
pendingList,
{ handleNoticeAll, handleMsgAll, handlePendingAll }
);
// 监听属性变化
watch(
() => props.value,
(newValue) => {
showNotice(newValue);
}
);
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.fa-notification-panel {
@apply absolute
top-14.5
right-5
w-90
h-125
overflow-hidden
transition-all
duration-300
origin-top
will-change-[top,left]
max-[640px]:top-[65px]
max-[640px]:right-0
max-[640px]:w-full
max-[640px]:h-[80vh];
}
.bar-active {
color: var(--theme-color) !important;
border-bottom: 2px solid var(--theme-color);
}
.scrollbar-thin::-webkit-scrollbar {
width: 5px !important;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background-color: var(--default-box-color);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #222 !important;
}
</style>
@@ -0,0 +1,111 @@
<!-- 布局内容 -->
<template>
<div id="app-scroll-main" class="layout-content" :style="containerStyle">
<div id="app-content-header">
<!-- 节日滚动 -->
<FaFestivalTextScroll />
<!-- 路由信息调试 -->
<div
v-if="isOpenRouteInfo === 'true'"
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
>
router meta:{{ route.meta }}
</div>
</div>
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
<Transition :name="actualTransition" mode="out-in" appear>
<div class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
<KeepAlive :max="10" :exclude="keepAliveExclude">
<component
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="router.fullPath"
/>
</KeepAlive>
<component
v-else
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="router.fullPath"
/>
</div>
</Transition>
</RouterView>
<!-- 返回顶部:宽屏 #app-scroll-main;窄屏文档滚动 target 置空 -->
<ElBacktop
:key="backtopTargetKey"
:target="backtopScrollTarget"
:right="28"
:bottom="28"
class="z-[90]"
>
<FaSvgIcon icon="ri:arrow-up-circle-line" class="text-2xl text-g-600" />
</ElBacktop>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
import FaSvgIcon from "@/components/base/fa-svg-icon/index.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useWorktabStore } from "@stores/modules/worktab.store";
defineOptions({ name: "FaPageContent" });
const route = useRoute();
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-scroll-main"));
const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"));
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingsStore());
const { keepAliveExclude } = storeToRefs(useWorktabStore());
const isRefresh = shallowRef(true);
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO;
// 标记是否是首次加载(浏览器刷新)
const isFirstLoad = ref(true);
const actualTransition = computed(() => {
if (isFirstLoad.value) return "";
return pageTransition.value;
});
const containerStyle = computed(
(): CSSProperties => ({
width: "100%",
minWidth: 0,
maxWidth: containerWidth.value,
flex: "1",
minHeight: "0",
display: "flex",
flexDirection: "column",
})
);
/** 常规布局下由 `.layout-content` 承担纵向滚动,路由视图填满剩余高度 */
const contentStyle = computed(
(): CSSProperties => ({ flex: "1", minHeight: "0", minWidth: 0, width: "100%" })
);
const reload = () => {
isRefresh.value = false;
nextTick(() => {
isRefresh.value = true;
});
};
watch(refresh, reload, { flush: "post" });
// 组件挂载后标记首次加载完成
onMounted(() => {
// 延迟一帧,确保首次渲染完成
nextTick(() => {
isFirstLoad.value = false;
});
});
</script>
@@ -0,0 +1,751 @@
<!-- 锁屏 -->
<template>
<div class="layout-lock-screen">
<!-- 开发者工具警告覆盖层 -->
<div
v-if="showDevToolsWarning"
class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in"
>
<div class="p-5 text-center select-none">
<div class="mb-7.5 text-5xl">🔒</div>
<h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1>
<p class="max-w-125 m-0 text-lg leading-relaxed text-white">
检测到开发者工具已打开
<br />
为了系统安全,请关闭开发者工具后继续使用
</p>
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
</div>
</div>
<!-- 设置锁屏密码弹窗(与旧版 LockDialog.vue 一致;锁密码仍写入 userStore + 加密) -->
<div v-if="!isLock">
<FaDialog
v-model="visible"
width="500px"
max-height="170px"
:title="t('lock.lockScreen')"
class="v-lock-dialog"
@opened="handleDialogOpen"
>
<div class="lock-dialog-content">
<img v-if="userAvatar" :src="userAvatar" alt="" class="lock-dialog-avatar object-cover" />
<img v-else class="lock-dialog-avatar" src="@imgs/user/avatar.webp" alt="" />
<span class="lock-dialog-name">{{ displayName }}</span>
</div>
<ElForm ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock">
<ElFormItem :label="t('lockScreen.lockPassword')" prop="password">
<ElInput
ref="lockInputRef"
v-model="formData.password"
type="password"
show-password
clearable
autocomplete="new-password"
:placeholder="t('lock.placeholder')"
@keydown.enter="handleLock"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton type="primary" @click="handleLock">{{ t("navbar.lock") }}</ElButton>
</template>
</FaDialog>
</div>
<!-- 解锁全屏(旧版 LockPage 样式) -->
<div v-else class="lockpage">
<div v-show="showClock" class="unlock-container" @click="showUnlockForm">
<ElIcon><Lock /></ElIcon>
<span>{{ t("lock.unlock") }}</span>
</div>
<div class="time-container w-screen h-screen">
<div class="hour-container mr-5 md:mr-20 w-2/5 h-2/5 md:h-4/5">
<span>{{ hour }}</span>
<span v-show="showClock" class="meridiem absolute left-5 top-5 text-md xl:text-xl">
{{ meridiem }}
</span>
</div>
<div class="minute-container w-2/5 h-2/5 md:h-4/5">
<span>{{ minute }}</span>
</div>
</div>
<transition name="fade-slide">
<div v-show="!showClock" class="entry-wrapper">
<div class="entry-content">
<div class="avatar-container">
<img v-if="userAvatar" :src="userAvatar" alt="" class="avatar object-cover" />
<img v-else class="avatar" src="@imgs/user/avatar.webp" alt="" />
<span class="username">{{ displayName }}</span>
</div>
<ElInput
ref="passwordInputRef"
v-model="unlockPwd"
:placeholder="t('lock.placeholder')"
class="password-input"
show-password
clearable
@keydown.enter="submitUnlock"
/>
<span v-if="unlockErrMsg" class="error-message">
{{ t("lock.message") }}
</span>
<div class="button-group">
<ElButton
type="primary"
size="small"
class="back-button"
link
:disabled="unlockLoading"
@click="showClockView"
>
{{ t("common.back") }}
</ElButton>
<ElButton
type="primary"
size="small"
class="login-button"
link
:disabled="unlockLoading"
@click="goLogin"
>
{{ t("lock.backToLogin") }}
</ElButton>
<ElButton
type="primary"
class="entry-button"
size="small"
link
:disabled="unlockLoading"
@click="submitUnlock"
>
{{ t("lock.entrySystem") }}
</ElButton>
</div>
</div>
</div>
</transition>
<div class="date-container">
<div v-show="!showClock" class="time-display">
{{ hour }}:{{ minute }}
<span class="meridiem-display">{{ meridiem }}</span>
</div>
<div class="full-date">{{ year }}/{{ month }}/{{ day }} {{ week }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Lock } from "@element-plus/icons-vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElInput } from "element-plus";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import CryptoJS from "crypto-js";
import { useUserStore } from "@stores/modules/user.store";
import { mittBus } from "@utils/sys";
import { useNow } from "@utils/common";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY;
const userStore = useUserStore();
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore);
const { hour, month, minute, meridiem, year, day, week } = useNow(true);
const displayName = computed(
() =>
(userInfo.value as { name?: string; username?: string })?.name ||
(userInfo.value as { username?: string })?.username ||
"—"
);
const userAvatar = computed(() => {
const a = (userInfo.value as { avatar?: string })?.avatar?.trim();
return a || "";
});
const visible = ref<boolean>(false);
const lockInputRef = ref<any>(null);
const passwordInputRef = ref<InstanceType<typeof ElInput>>();
const showDevToolsWarning = ref<boolean>(false);
/** true:大号时钟;false:密码表单(与旧版 LockPage showDate 一致) */
const showClock = ref(true);
const unlockPwd = ref("");
const unlockErrMsg = ref(false);
const unlockLoading = ref(false);
const formRef = ref<FormInstance>();
const formData = ref({
password: "",
});
const rules = computed<FormRules>(() => ({
password: [{ required: true, message: t("lock.required"), trigger: "blur" }],
}));
const isMobile = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
const disableDevTools = () => {
const handleContextMenu = (e: Event) => {
if (isLock.value) {
e.preventDefault();
e.stopPropagation();
return false;
}
};
document.addEventListener("contextmenu", handleContextMenu, true);
const handleKeyDown = (e: KeyboardEvent) => {
if (!isLock.value) return;
if (e.key === "F12") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.shiftKey) {
const key = e.key.toLowerCase();
if (["i", "j", "c", "k"].includes(key)) {
e.preventDefault();
e.stopPropagation();
return false;
}
}
if (e.ctrlKey && e.key.toLowerCase() === "u") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key.toLowerCase() === "a") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key.toLowerCase() === "p") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key.toLowerCase() === "f") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.altKey && e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.key.toLowerCase() === "w") {
e.preventDefault();
e.stopPropagation();
return false;
}
if ((e.ctrlKey && e.key.toLowerCase() === "r") || e.key === "F5") {
e.preventDefault();
e.stopPropagation();
return false;
}
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "r") {
e.preventDefault();
e.stopPropagation();
return false;
}
};
document.addEventListener("keydown", handleKeyDown, true);
const handleSelectStart = (e: Event) => {
if (isLock.value) {
e.preventDefault();
return false;
}
};
document.addEventListener("selectstart", handleSelectStart, true);
const handleDragStart = (e: Event) => {
if (isLock.value) {
e.preventDefault();
return false;
}
};
document.addEventListener("dragstart", handleDragStart, true);
const devtools = { open: false };
const threshold = 160;
let devToolsInterval: ReturnType<typeof setInterval> | null = null;
const checkDevTools = () => {
if (!isLock.value || isMobile()) return;
const isDevToolsOpen =
window.outerHeight - window.innerHeight > threshold ||
window.outerWidth - window.innerWidth > threshold;
if (isDevToolsOpen && !devtools.open) {
devtools.open = true;
showDevToolsWarning.value = true;
} else if (!isDevToolsOpen && devtools.open) {
devtools.open = false;
showDevToolsWarning.value = false;
}
};
if (!isMobile()) {
devToolsInterval = setInterval(checkDevTools, 500);
}
return () => {
document.removeEventListener("contextmenu", handleContextMenu, true);
document.removeEventListener("keydown", handleKeyDown, true);
document.removeEventListener("selectstart", handleSelectStart, true);
document.removeEventListener("dragstart", handleDragStart, true);
if (devToolsInterval) {
clearInterval(devToolsInterval);
}
};
};
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
try {
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
CryptoJS.enc.Utf8
);
return inputPassword === decryptedPassword;
} catch (error) {
console.error("密码解密失败:", error);
return false;
}
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.altKey && event.key.toLowerCase() === "¬") {
event.preventDefault();
if (!isLock.value) {
visible.value = true;
}
}
};
const handleDialogOpen = () => {
setTimeout(() => {
lockInputRef.value?.input?.focus();
}, 100);
};
const handleLock = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid, fields) => {
if (valid) {
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString();
userStore.setLockStatus(true);
userStore.setLockPassword(encryptedPassword);
visible.value = false;
formData.password = "";
showClock.value = true;
unlockPwd.value = "";
unlockErrMsg.value = false;
} else {
console.error("表单验证失败:", fields);
}
});
};
function showUnlockForm() {
showClock.value = false;
unlockErrMsg.value = false;
requestAnimationFrame(() => {
passwordInputRef.value?.focus?.();
});
}
function showClockView() {
showClock.value = true;
unlockPwd.value = "";
unlockErrMsg.value = false;
}
async function submitUnlock() {
if (!unlockPwd.value) {
return;
}
unlockLoading.value = true;
try {
const isValid = verifyPassword(unlockPwd.value, lockPassword.value);
if (isValid) {
userStore.setLockStatus(false);
userStore.setLockPassword("");
unlockPwd.value = "";
unlockErrMsg.value = false;
visible.value = false;
showDevToolsWarning.value = false;
showClock.value = true;
} else {
unlockErrMsg.value = true;
ElMessage.error(t("lockScreen.pwdError"));
const root = passwordInputRef.value?.$el as HTMLElement | undefined;
const inputWrap = root?.querySelector?.(".el-input__wrapper") as HTMLElement | undefined;
const shakeTarget = inputWrap ?? root;
if (shakeTarget) {
shakeTarget.classList.add("shake-animation");
setTimeout(() => {
shakeTarget.classList.remove("shake-animation");
}, 300);
}
unlockPwd.value = "";
}
} finally {
unlockLoading.value = false;
}
}
async function goLogin() {
await userStore.logout({ navigate: false }).catch(() => {});
const redirect = route.path !== "/login" && route.name !== "Login" ? route.fullPath : undefined;
await router.replace({
name: "Login",
...(redirect ? { query: { redirect } } : {}),
});
}
const openLockScreen = () => {
if (!isLock.value) {
visible.value = true;
}
};
watch(isLock, (newValue) => {
if (newValue) {
document.body.style.overflow = "hidden";
showClock.value = true;
unlockPwd.value = "";
unlockErrMsg.value = false;
} else {
document.body.style.overflow = "auto";
showDevToolsWarning.value = false;
}
});
let cleanupDevTools: (() => void) | null = null;
onMounted(() => {
mittBus.on("openLockScreen", openLockScreen);
document.addEventListener("keydown", handleKeydown);
if (isLock.value) {
document.body.style.overflow = "hidden";
}
cleanupDevTools = disableDevTools();
});
onUnmounted(() => {
mittBus.off("openLockScreen", openLockScreen);
document.removeEventListener("keydown", handleKeydown);
document.body.style.overflow = "auto";
if (cleanupDevTools) {
cleanupDevTools();
cleanupDevTools = null;
}
});
</script>
<style lang="scss" scoped>
.v-lock-dialog {
@media (width <=767px) {
max-width: calc(100vw - 16px);
}
.lock-dialog {
&-content {
display: flex;
flex-direction: column;
align-items: center;
}
&-avatar {
width: 70px;
height: 70px;
border-radius: 50%;
}
&-name {
margin: 10px 0;
font-size: 14px;
color: var(--top-header-text-color);
}
}
}
.lockpage {
position: fixed;
inset: 0;
top: 0;
left: 0;
z-index: 3000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-color-white);
background-color: rgb(0 0 0 / 90%);
backdrop-filter: blur(8px);
.unlock-container {
position: absolute;
top: 0.5rem;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 4rem;
padding: 0.5rem 1rem;
padding-top: 1.25rem;
color: inherit;
cursor: pointer;
border-radius: 12px;
transform: translateX(-50%);
@media (width >= 640px) {
font-size: 0.875rem;
}
@media (width >= 1280px) {
font-size: 1.25rem;
}
}
.time-container {
display: flex;
align-items: center;
justify-content: center;
.hour-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
margin-bottom: 2rem;
font-size: 220px;
font-weight: 700;
color: var(--el-text-color-primary);
background-color: var(--el-bg-color-overlay);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.minute-container {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
margin-bottom: 2rem;
font-size: 220px;
font-weight: 700;
color: var(--el-text-color-primary);
background-color: var(--el-bg-color-overlay);
border-radius: 16px;
backdrop-filter: blur(8px);
}
}
.meridiem {
position: absolute;
top: 1.25rem;
left: 1.25rem;
font-size: 1.25rem;
}
.entry-wrapper {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(8px);
}
.entry-content {
width: 260px;
color: var(--el-text-color-regular);
text-align: center;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
}
.avatar {
width: 70px;
height: 70px;
border-radius: 50%;
}
.username {
margin: 0.625rem 0;
font-size: 0.875rem;
color: var(--el-text-color-primary);
}
.password-input {
margin-top: 1rem;
}
.error-message {
display: inline-block;
margin-top: 0.625rem;
font-size: 0.875rem;
color: var(--el-color-danger);
}
.button-group {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.5rem;
.back-button,
.login-button,
.entry-button {
min-width: auto;
padding: 0;
}
.login-button {
flex: 1;
text-align: center;
}
}
.date-container {
position: absolute;
bottom: 1.25rem;
width: 100%;
color: inherit;
text-align: center;
@media (width >= 1280px) {
font-size: 1.25rem;
}
@media (width >= 1536px) {
font-size: 1.875rem;
}
}
.time-display {
margin-bottom: 1rem;
font-size: 3rem;
.meridiem-display {
font-size: 1.875rem;
}
}
.full-date {
font-size: 1.5rem;
}
}
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-60px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(60px);
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-in-out;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-10px);
}
20%,
40%,
60%,
80% {
transform: translateX(10px);
}
}
.shake-animation {
animation: shake 0.5s ease-in-out;
}
</style>
@@ -0,0 +1,308 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { ContainerWidthEnum } from "@/enums/appEnum";
import AppConfig from "@/config";
import { headerBarConfig } from "@/config/modules/headerBar";
/**
* 设置项配置选项管理
*/
export function useSettingsConfig() {
const { t } = useI18n();
// 标签页风格选项
const tabStyleOptions = computed(() => [
{
value: "tab-default",
label: t("setting.tabStyle.default"),
},
{
value: "tab-card",
label: t("setting.tabStyle.card"),
},
{
value: "tab-google",
label: t("setting.tabStyle.google"),
},
]);
// 页面切换动画选项
const pageTransitionOptions = computed(() => [
{
value: "",
label: t("setting.transition.list.none"),
},
{
value: "fade",
label: t("setting.transition.list.fade"),
},
{
value: "slide-left",
label: t("setting.transition.list.slideLeft"),
},
{
value: "slide-bottom",
label: t("setting.transition.list.slideBottom"),
},
{
value: "slide-top",
label: t("setting.transition.list.slideTop"),
},
]);
// 圆角大小选项
const customRadiusOptions = [
{ value: "0", label: "0" },
{ value: "0.25", label: "0.25" },
{ value: "0.5", label: "0.5" },
{ value: "0.75", label: "0.75" },
{ value: "1", label: "1" },
];
// 容器宽度选项
const containerWidthOptions = computed(() => [
{
value: ContainerWidthEnum.FULL,
label: t("setting.container.list[0]"),
icon: "icon-park-outline:auto-width",
},
{
value: ContainerWidthEnum.BOXED,
label: t("setting.container.list[1]"),
icon: "ix:width",
},
]);
// 盒子样式选项
const boxStyleOptions = computed(() => [
{
value: "border-mode",
label: t("setting.box.list[0]"),
type: "border-mode" as const,
},
{
value: "shadow-mode",
label: t("setting.box.list[1]"),
type: "shadow-mode" as const,
},
]);
// 从配置文件获取的选项
const configOptions = {
// 主题色彩选项
mainColors: AppConfig.systemMainColor,
// 主题风格选项
themeList: AppConfig.settingThemeList,
// 菜单布局选项
menuLayoutList: AppConfig.menuLayoutList,
};
// 基础设置项配置
const basicSettingsConfig = computed(() => {
// 定义所有基础设置项
const allSettings = [
{
key: "showWorkTab",
label: t("setting.basics.list.multiTab"),
type: "switch" as const,
handler: "workTab",
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "uniqueOpened",
label: t("setting.basics.list.accordion"),
type: "switch" as const,
handler: "uniqueOpened",
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "showMenuButton",
label: t("setting.basics.list.collapseSidebar"),
type: "switch" as const,
handler: "menuButton",
headerBarKey: "menuButton" as const,
},
{
key: "showFastEnter",
label: t("setting.basics.list.fastEnter"),
type: "switch" as const,
handler: "fastEnter",
headerBarKey: "fastEnter" as const,
},
{
key: "showRefreshButton",
label: t("setting.basics.list.reloadPage"),
type: "switch" as const,
handler: "refreshButton",
headerBarKey: "refreshButton" as const,
},
{
key: "showCrumbs",
label: t("setting.basics.list.breadcrumb"),
type: "switch" as const,
handler: "crumbs",
mobileHide: true,
headerBarKey: "breadcrumb" as const,
},
{
key: "showLanguage",
label: t("setting.basics.list.language"),
type: "switch" as const,
handler: "language",
headerBarKey: "language" as const,
},
{
key: "showMenuSearch",
label: t("setting.basics.list.menuSearch"),
type: "switch" as const,
handler: "menuSearch",
mobileHide: true,
headerBarKey: "globalSearch" as const,
},
{
key: "showFullscreen",
label: t("setting.basics.list.fullscreenTool"),
type: "switch" as const,
handler: "fullscreenTool",
mobileHide: true,
headerBarKey: "fullscreen" as const,
},
{
key: "showSizeSelect",
label: t("setting.basics.list.layoutSize"),
type: "switch" as const,
handler: "layoutSizeTool",
mobileHide: true,
headerBarKey: "sizeSelect" as const,
},
{
key: "showNotification",
label: t("setting.basics.list.notificationTool"),
type: "switch" as const,
handler: "notificationTool",
mobileHide: true,
headerBarKey: "notification" as const,
},
{
key: "showNprogress",
label: t("setting.basics.list.progressBar"),
type: "switch" as const,
handler: "nprogress",
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "colorWeak",
label: t("setting.basics.list.weakMode"),
type: "switch" as const,
handler: "colorWeak",
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "watermarkVisible",
label: t("setting.basics.list.watermark"),
type: "switch" as const,
handler: "watermark",
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "showAppLogo",
label: t("setting.basics.list.appLogo"),
type: "switch" as const,
handler: "appLogo",
headerBarKey: null,
},
{
key: "showGuide",
label: t("setting.basics.list.loginGuide"),
type: "switch" as const,
handler: "loginGuide",
headerBarKey: null,
},
{
key: "grayMode",
label: t("setting.basics.list.grayMode"),
type: "switch" as const,
handler: "grayMode",
headerBarKey: null,
},
{
key: "userEnableAi",
label: t("setting.basics.list.aiAssistant"),
type: "switch" as const,
handler: "aiAssistant",
headerBarKey: null,
},
{
key: "menuOpenWidth",
label: t("setting.basics.list.menuWidth"),
type: "input-number" as const,
handler: "menuOpenWidth",
min: 180,
max: 320,
step: 10,
style: { width: "120px" },
controlsPosition: "right" as const,
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "tabStyle",
label: t("setting.basics.list.tabStyle"),
type: "select" as const,
handler: "tabStyle",
options: tabStyleOptions.value,
style: { width: "120px" },
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "pageTransition",
label: t("setting.basics.list.pageTransition"),
type: "select" as const,
handler: "pageTransition",
options: pageTransitionOptions.value,
style: { width: "120px" },
headerBarKey: null, // 不依赖headerBar配置
},
{
key: "customRadius",
label: t("setting.basics.list.borderRadius"),
type: "select" as const,
handler: "customRadius",
options: customRadiusOptions,
style: { width: "120px" },
headerBarKey: null, // 不依赖headerBar配置
},
];
// 根据 headerBarConfig 过滤设置项
return (
allSettings
.filter((setting) => {
// 如果设置项不依赖headerBar配置,则始终显示
if (setting.headerBarKey === null) {
return true;
}
// 如果依赖headerBar配置,检查对应的功能是否启用
const headerBarFeature = headerBarConfig[setting.headerBarKey];
return headerBarFeature?.enabled !== false;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
);
});
return {
// 选项配置
tabStyleOptions,
pageTransitionOptions,
customRadiusOptions,
containerWidthOptions,
boxStyleOptions,
configOptions,
// 设置项配置
basicSettingsConfig,
};
}
@@ -0,0 +1,182 @@
import { useSettingsStore } from "@stores/modules/setting.store";
import { storeToRefs } from "pinia";
import type { ContainerWidthEnum } from "@/enums/appEnum";
/**
* 设置项通用处理逻辑
*/
export function useSettingsHandlers() {
const settingStore = useSettingsStore();
// DOM 操作相关
const domOperations = {
// 设置HTML类名
setHtmlClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName("html")[0];
if (add) {
el.classList.add(className);
} else {
el.classList.remove(className);
}
},
// 设置根元素属性
setRootAttribute: (attribute: string, value: string) => {
const el = document.documentElement;
el.setAttribute(attribute, value);
},
// 设置body类名
setBodyClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName("body")[0];
if (add) {
el.classList.add(className);
} else {
el.classList.remove(className);
}
},
};
// 通用切换处理器
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
return () => {
storeMethod();
callback?.();
};
};
// 通用值变更处理器
const createValueHandler = <T>(
storeMethod: (value: T) => void,
callback?: (value: T) => void
) => {
return (value: T) => {
if (value !== undefined && value !== null) {
storeMethod(value);
callback?.(value);
}
};
};
// 基础设置处理器
const basicHandlers = {
// 工作台标签页
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
// 菜单手风琴
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
// 显示菜单按钮
menuButton: createToggleHandler(() => settingStore.setButton()),
// 显示快速入口
fastEnter: createToggleHandler(() => settingStore.setFastEnter()),
// 显示刷新按钮
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
// 显示面包屑
crumbs: createToggleHandler(() => settingStore.setCrumbs()),
// 显示语言切换
language: createToggleHandler(() => settingStore.setLanguage()),
/** 桌面工具栏(与旧版持久化项对齐,走 updateSetting) */
menuSearch: () => settingStore.updateSetting("showMenuSearch", !settingStore.showMenuSearch),
fullscreenTool: () =>
settingStore.updateSetting("showFullscreen", !settingStore.showFullscreen),
layoutSizeTool: () =>
settingStore.updateSetting("showSizeSelect", !settingStore.showSizeSelect),
notificationTool: () =>
settingStore.updateSetting("showNotification", !settingStore.showNotification),
// 显示进度条
nprogress: createToggleHandler(() => settingStore.setNprogress()),
// 色弱模式
colorWeak: createToggleHandler(
() => settingStore.setColorWeak(),
() => {
domOperations.setHtmlClass("color-weak", settingStore.colorWeak);
}
),
// 水印显示
watermark: createToggleHandler(() =>
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
),
/** 与旧版 Settings 对齐:持久化项走 updateSetting */
appLogo: () => settingStore.updateSetting("showAppLogo", !settingStore.showAppLogo),
loginGuide: () => settingStore.updateSetting("showGuide", !settingStore.showGuide),
grayMode: () => settingStore.updateSetting("grayMode", !settingStore.grayMode),
aiAssistant: () => settingStore.updateSetting("userEnableAi", !settingStore.userEnableAi),
// 菜单展开宽度
menuOpenWidth: createValueHandler<number>((width: number) =>
settingStore.setMenuOpenWidth(width)
),
// 标签页风格
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
// 页面切换动画
pageTransition: createValueHandler<string>((transition: string) =>
settingStore.setPageTransition(transition)
),
// 圆角大小
customRadius: createValueHandler<string>((radius: string) =>
settingStore.setCustomRadius(radius)
),
};
// 盒子样式处理器
const boxStyleHandlers = {
// 设置盒子模式
setBoxMode: (type: "border-mode" | "shadow-mode") => {
const { boxBorderMode } = storeToRefs(settingStore);
// 防止重复设置
if (
(type === "shadow-mode" && boxBorderMode.value === false) ||
(type === "border-mode" && boxBorderMode.value === true)
) {
return;
}
setTimeout(() => {
domOperations.setRootAttribute("data-box-mode", type);
settingStore.setBorderMode();
}, 50);
},
};
// 颜色设置处理器
const colorHandlers = {
// 选择主题色
selectColor: (theme: string) => {
settingStore.setElementTheme(theme);
settingStore.reload();
},
};
// 容器设置处理器
const containerHandlers = {
// 设置容器宽度
setWidth: (type: ContainerWidthEnum) => {
settingStore.setContainerWidth(type);
settingStore.reload();
},
};
return {
domOperations,
basicHandlers,
boxStyleHandlers,
colorHandlers,
containerHandlers,
createToggleHandler,
createValueHandler,
};
}
@@ -0,0 +1,233 @@
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { storeToRefs } from "pinia";
import { useBreakpoints } from "@vueuse/core";
import AppConfig from "@/config";
import { SystemThemeEnum, MenuTypeEnum } from "@/enums/appEnum";
import { mittBus } from "@utils/sys";
import { StorageConfig } from "@utils";
import { useTheme } from "@/hooks/core/useTheme";
import { useCeremony } from "@/hooks/core/useCeremony";
import { useSettingsState } from "./useSettingsState";
import { useSettingsHandlers } from "./useSettingsHandlers";
/**
* 设置面板核心逻辑管理
*/
export function useSettingsPanel() {
const settingStore = useSettingsStore();
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore);
// Composables
const { openFestival, cleanup } = useCeremony();
const { setSystemTheme, setSystemAutoTheme } = useTheme();
const { initColorWeak } = useSettingsState();
const { domOperations } = useSettingsHandlers();
// 响应式状态
const showDrawer = ref(false);
// 使用 VueUse breakpoints 优化性能
const breakpoints = useBreakpoints({ tablet: 1000 });
const isMobile = breakpoints.smaller("tablet");
// 记录窗口宽度变化前的菜单类型
const getStoredDesktopMenuType = (): MenuTypeEnum | undefined => {
const storedMenuType = localStorage.getItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY);
return Object.values(MenuTypeEnum).includes(storedMenuType as MenuTypeEnum)
? (storedMenuType as MenuTypeEnum)
: undefined;
};
const setStoredDesktopMenuType = (type: MenuTypeEnum) => {
localStorage.setItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY, type);
};
const clearStoredDesktopMenuType = () => {
localStorage.removeItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY);
};
const storedDesktopMenuType = getStoredDesktopMenuType();
const beforeMenuType = ref<MenuTypeEnum | undefined>(storedDesktopMenuType);
const hasChangedMenu = ref(Boolean(storedDesktopMenuType));
// 计算属性
const systemThemeColor = computed(() => settingStore.systemThemeColor as string);
// 主题相关处理
const useThemeHandlers = () => {
// 初始化系统颜色
const initSystemColor = () => {
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
settingStore.setElementTheme(AppConfig.systemMainColor[0]);
settingStore.reload();
}
};
// 初始化系统主题
const initSystemTheme = () => {
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
setSystemAutoTheme();
} else {
setSystemTheme(systemThemeType.value);
}
};
// 监听系统主题变化
const listenerSystemTheme = () => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", initSystemTheme);
return () => {
mediaQuery.removeEventListener("change", initSystemTheme);
};
};
return {
initSystemColor,
initSystemTheme,
listenerSystemTheme,
};
};
// 响应式布局处理
const useResponsiveLayout = () => {
// 使用 watch 监听断点变化,性能更优
const stopWatch = watch(
isMobile,
(mobile: boolean) => {
if (mobile) {
// 切换到移动端布局
if (!hasChangedMenu.value) {
beforeMenuType.value = menuType.value;
if (menuType.value !== MenuTypeEnum.LEFT) {
setStoredDesktopMenuType(menuType.value);
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT);
hasChangedMenu.value = true;
}
}
settingStore.setMenuOpen(false);
} else {
// 恢复桌面端布局
if (hasChangedMenu.value && beforeMenuType.value) {
if (menuType.value === MenuTypeEnum.LEFT) {
useSettingsState().switchMenuLayouts(beforeMenuType.value);
}
clearStoredDesktopMenuType();
hasChangedMenu.value = false;
}
settingStore.setMenuOpen(true);
}
},
{ immediate: true }
);
return { stopWatch };
};
// 抽屉控制
const useDrawerControl = () => {
// 用于存储 setTimeout 的 ID,以便在需要时清除
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null;
// 打开抽屉
const handleOpen = () => {
// 清除可能存在的旧定时器
if (themeChangeTimer) {
clearTimeout(themeChangeTimer);
}
// 延迟添加 theme-change class,避免抽屉打开动画受影响
themeChangeTimer = setTimeout(() => {
domOperations.setBodyClass("theme-change", true);
themeChangeTimer = null;
}, 500);
};
// 关闭抽屉
const handleClose = () => {
// 清除未执行的定时器,防止关闭后才添加 class
if (themeChangeTimer) {
clearTimeout(themeChangeTimer);
themeChangeTimer = null;
}
// 立即移除 theme-change class
domOperations.setBodyClass("theme-change", false);
};
// 打开设置
const openSetting = () => {
showDrawer.value = true;
};
// 关闭设置
const closeDrawer = () => {
showDrawer.value = false;
};
return {
handleOpen,
handleClose,
openSetting,
closeDrawer,
};
};
// Props 变化监听
const usePropsWatcher = (props: { open?: boolean }) => {
watch(
() => props.open,
(val: boolean | undefined) => {
if (val !== undefined) {
showDrawer.value = val;
}
}
);
};
// 初始化设置
const useSettingsInitializer = () => {
const themeHandlers = useThemeHandlers();
const { openSetting } = useDrawerControl();
const { stopWatch } = useResponsiveLayout();
let themeCleanup: (() => void) | null = null;
const initializeSettings = () => {
mittBus.on("openSetting", openSetting);
themeHandlers.initSystemColor();
themeCleanup = themeHandlers.listenerSystemTheme();
initColorWeak();
// 设置盒子模式
const boxMode = settingStore.boxBorderMode ? "border-mode" : "shadow-mode";
domOperations.setRootAttribute("data-box-mode", boxMode);
themeHandlers.initSystemTheme();
openFestival();
};
const cleanupSettings = () => {
stopWatch();
themeCleanup?.();
cleanup();
};
return {
initializeSettings,
cleanupSettings,
};
};
return {
// 状态
showDrawer,
// 方法组合
useThemeHandlers,
useResponsiveLayout,
useDrawerControl,
usePropsWatcher,
useSettingsInitializer,
};
}
@@ -0,0 +1,37 @@
import { useSettingsStore } from "@stores/modules/setting.store";
import { MenuThemeEnum, MenuTypeEnum } from "@/enums/appEnum";
/**
* 设置状态管理
*/
export function useSettingsState() {
const settingStore = useSettingsStore();
// 色弱模式初始化
const initColorWeak = () => {
if (settingStore.colorWeak) {
const el = document.getElementsByTagName("html")[0];
setTimeout(() => {
el.classList.add("color-weak");
}, 100);
}
};
// 菜单布局切换
const switchMenuLayouts = (type: MenuTypeEnum) => {
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
settingStore.setMenuOpen(true);
}
settingStore.switchMenuLayouts(type);
if (type === MenuTypeEnum.DUAL_MENU) {
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN);
settingStore.setMenuOpen(true);
}
};
return {
// 方法
initColorWeak,
switchMenuLayouts,
};
}
@@ -0,0 +1,72 @@
<!-- 设置面板 -->
<template>
<div class="layout-settings">
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
<!-- 头部关闭按钮 -->
<SettingHeader @close="closeDrawer" />
<!-- 主题风格 -->
<ThemeSettings />
<!-- 菜单布局 -->
<MenuLayoutSettings />
<!-- 菜单风格 -->
<MenuStyleSettings />
<!-- 系统主题色 -->
<ColorSettings />
<!-- 盒子样式 -->
<BoxStyleSettings />
<!-- 容器宽度 -->
<ContainerSettings />
<!-- 基础配置 -->
<BasicSettings />
<!-- 操作按钮 -->
<SettingActions />
</SettingDrawer>
</div>
</template>
<script setup lang="ts">
import { useSettingsPanel } from "./composables/useSettingsPanel";
import SettingDrawer from "./widget/SettingDrawer.vue";
import SettingHeader from "./widget/SettingHeader.vue";
import ThemeSettings from "./widget/ThemeSettings.vue";
import MenuLayoutSettings from "./widget/MenuLayoutSettings.vue";
import MenuStyleSettings from "./widget/MenuStyleSettings.vue";
import ColorSettings from "./widget/ColorSettings.vue";
import BoxStyleSettings from "./widget/BoxStyleSettings.vue";
import ContainerSettings from "./widget/ContainerSettings.vue";
import BasicSettings from "./widget/BasicSettings.vue";
import SettingActions from "./widget/SettingActions.vue";
defineOptions({ name: "FaSettingsPanel" });
interface Props {
/** 是否打开 */
open?: boolean;
}
const props = defineProps<Props>();
// 使用设置面板逻辑
const settingsPanel = useSettingsPanel();
const { showDrawer } = settingsPanel;
// 获取各种处理器
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl();
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer();
// 监听 props 变化
settingsPanel.usePropsWatcher(props);
onMounted(() => {
initializeSettings();
});
onUnmounted(() => {
cleanupSettings();
});
</script>
<style lang="scss">
@use "./style";
</style>
@@ -0,0 +1,92 @@
@use "@styles/core/mixin.scss" as *;
// 设置抽屉模态框样式
.setting-modal {
background: transparent !important;
.el-drawer {
// 背景滤镜效果
background: rgba($color: #fff, $alpha: 50%) !important;
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
@include backdropBlur();
.setting-box-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
width: calc(100% + 15px);
margin-bottom: 10px;
.setting-item {
box-sizing: border-box;
width: calc(33.333% - 15px);
margin-right: 15px;
text-align: center;
.box {
position: relative;
box-sizing: border-box;
display: flex;
height: 52px;
overflow: hidden;
cursor: pointer;
border: 2px solid var(--default-border);
border-radius: 8px;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
transition: box-shadow 0.1s;
&.mt-16 {
margin-top: 16px;
}
&.is-active {
border: 2px solid var(--theme-color);
}
img {
width: 100%;
height: 100%;
}
}
.name {
margin-top: 6px;
font-size: 14px;
text-align: center;
}
}
}
}
// 去除滚动条
.el-drawer__body::-webkit-scrollbar {
width: 0 !important;
}
}
.dark {
.setting-modal {
.el-drawer {
background: rgba($color: #000, $alpha: 50%) !important;
.setting-item {
.box {
border: 2px solid transparent;
}
}
}
}
}
// 去除火狐浏览器滚动条
:deep(.el-drawer__body) {
scrollbar-width: none;
}
// 移动端隐藏
@media screen and (width <= 800px) {
.mobile-hide {
display: none !important;
}
}
@@ -0,0 +1,93 @@
<template>
<div>
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
<SettingItem
v-for="config in basicSettingsConfig"
:key="config.key"
:config="config"
:model-value="getSettingValue(config.key)"
@change="handleSettingChange(config.handler, $event)"
/>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import SettingItem from "./SettingItem.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
const settingStore = useSettingsStore();
const { basicSettingsConfig } = useSettingsConfig();
const { basicHandlers } = useSettingsHandlers();
// 获取store的响应式状态
const {
uniqueOpened,
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
showWorkTab,
showLanguage,
showMenuSearch,
showFullscreen,
showSizeSelect,
showNotification,
showNprogress,
colorWeak,
watermarkVisible,
showAppLogo,
showGuide,
grayMode,
userEnableAi,
menuOpenWidth,
tabStyle,
pageTransition,
customRadius,
} = storeToRefs(settingStore);
// 创建设置值映射
const settingValueMap = {
uniqueOpened,
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
showWorkTab,
showLanguage,
showMenuSearch,
showFullscreen,
showSizeSelect,
showNotification,
showNprogress,
colorWeak,
watermarkVisible,
showAppLogo,
showGuide,
grayMode,
userEnableAi,
menuOpenWidth,
tabStyle,
pageTransition,
customRadius,
};
// 获取设置值的方法
const getSettingValue = (key: string) => {
const settingRef = settingValueMap[key as keyof typeof settingValueMap];
return settingRef?.value ?? null;
};
// 统一的设置变更处理
const handleSettingChange = (handlerName: string, value: any) => {
const handler = (basicHandlers as any)[handlerName];
if (typeof handler === "function") {
handler(value);
} else {
console.warn(`Handler "${handlerName}" not found in basicHandlers`);
}
};
</script>
@@ -0,0 +1,38 @@
<template>
<div>
<SectionTitle :title="$t('setting.box.title')" class="mt-10" />
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
<div
v-for="option in boxStyleOptions"
:key="option.value"
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
:class="
isActive(option.type)
? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300'
: 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20'
"
@click="boxStyleHandlers.setBoxMode(option.type)"
>
{{ option.label }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
const settingStore = useSettingsStore();
const { boxBorderMode } = storeToRefs(settingStore);
const { boxStyleOptions } = useSettingsConfig();
const { boxStyleHandlers } = useSettingsHandlers();
// 判断当前选项是否激活
const isActive = (type: "border-mode" | "shadow-mode") => {
return type === "border-mode" ? boxBorderMode.value : !boxBorderMode.value;
};
</script>
@@ -0,0 +1,35 @@
<template>
<div>
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
<div class="-mr-4">
<div class="flex flex-wrap">
<div
v-for="color in configOptions.mainColors"
:key="color"
class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85"
:style="{ background: `${color} !important` }"
@click="colorHandlers.selectColor(color)"
>
<FaSvgIcon
icon="ri:check-fill"
class="text-base !text-white"
v-show="color === systemThemeColor"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
const settingStore = useSettingsStore();
const { systemThemeColor } = storeToRefs(settingStore);
const { configOptions } = useSettingsConfig();
const { colorHandlers } = useSettingsHandlers();
</script>
@@ -0,0 +1,33 @@
<template>
<div>
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
<div class="flex">
<div
v-for="option in containerWidthOptions"
:key="option.value"
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0"
:class="{
'border-theme [&_i]:!text-theme': containerWidth === option.value,
'border-full-d': containerWidth !== option.value,
}"
@click="containerHandlers.setWidth(option.value)"
>
<FaSvgIcon :icon="option.icon" class="mr-2 text-lg" />
<span class="text-sm">{{ option.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsHandlers } from "../composables/useSettingsHandlers";
import { storeToRefs } from "pinia";
const settingStore = useSettingsStore();
const { containerWidth } = storeToRefs(settingStore);
const { containerWidthOptions } = useSettingsConfig();
const { containerHandlers } = useSettingsHandlers();
</script>
@@ -0,0 +1,31 @@
<template>
<div v-if="width > 1000">
<SectionTitle :title="$t('setting.menuType.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="(item, index) in configOptions.menuLayoutList"
:key="item.value"
@click="switchMenuLayouts(item.value)"
>
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }">
<img :src="item.img" />
</div>
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useSettingsState } from "../composables/useSettingsState";
const { width } = useWindowSize();
const settingStore = useSettingsStore();
const { menuType } = storeToRefs(settingStore);
const { configOptions } = useSettingsConfig();
const { switchMenuLayouts } = useSettingsState();
</script>
@@ -0,0 +1,44 @@
<template>
<SectionTitle :title="$t('setting.menu.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="item in menuThemeList"
:key="item.theme"
@click="switchMenuStyles(item.theme)"
>
<div
class="box"
:class="{ 'is-active': item.theme === menuThemeType }"
:style="{
cursor: disabled ? 'no-drop' : 'pointer',
}"
>
<img :src="item.img" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from "@/config";
import SectionTitle from "./SectionTitle.vue";
import { MenuTypeEnum, type MenuThemeEnum } from "@/enums/appEnum";
import { useSettingsStore } from "@stores/modules/setting.store";
const menuThemeList = AppConfig.themeList;
const settingStore = useSettingsStore();
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore);
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP);
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU);
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value);
// 菜单样式切换
const switchMenuStyles = (theme: MenuThemeEnum) => {
if (isDualMenu.value || isTopMenu.value || isDark.value) {
return;
}
settingStore.switchMenuStyles(theme);
};
</script>
@@ -0,0 +1,17 @@
<template>
<p
class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--fa-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300"
:style="style"
>
{{ title }}
</p>
</template>
<script setup lang="ts">
interface Props {
title: string;
style?: Record<string, any>;
}
defineProps<Props>();
</script>
@@ -0,0 +1,243 @@
<!-- 设置操作按钮 -->
<template>
<div
class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--fa-bg-color)] pt-5"
>
<ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig">
{{ $t("setting.actions.copyConfig") }}
</ElButton>
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
{{ $t("setting.actions.resetConfig") }}
</ElButton>
</div>
</template>
<script setup lang="ts">
import { nextTick } from "vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { SETTING_DEFAULT_CONFIG } from "@/config/setting";
import { useClipboard } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { MenuThemeEnum } from "@/enums/appEnum";
import { useTheme } from "@/hooks/core/useTheme";
defineOptions({ name: "SettingActions" });
const { t } = useI18n();
const settingStore = useSettingsStore();
const { copy, copied } = useClipboard();
const { switchThemeStyles } = useTheme();
/** 枚举映射表 */
const ENUM_MAPS = {
menuType: {
left: "MenuTypeEnum.LEFT",
top: "MenuTypeEnum.TOP",
"top-left": "MenuTypeEnum.TOP_LEFT",
"dual-menu": "MenuTypeEnum.DUAL_MENU",
},
systemTheme: {
auto: "SystemThemeEnum.AUTO",
light: "SystemThemeEnum.LIGHT",
dark: "SystemThemeEnum.DARK",
},
menuTheme: {
design: "MenuThemeEnum.DESIGN",
light: "MenuThemeEnum.LIGHT",
dark: "MenuThemeEnum.DARK",
},
containerWidth: {
"100%": "ContainerWidthEnum.FULL",
"1200px": "ContainerWidthEnum.BOXED",
},
} as const;
/** 配置项定义 */
interface ConfigItem {
comment: string;
key: keyof typeof settingStore;
enumMap?: Record<string, string>;
forceValue?: any;
}
const CONFIG_ITEMS: ConfigItem[] = [
{ comment: "菜单类型", key: "menuType", enumMap: ENUM_MAPS.menuType },
{ comment: "菜单展开宽度", key: "menuOpenWidth" },
{ comment: "菜单是否展开", key: "menuOpen" },
{ comment: "双菜单是否显示文本", key: "dualMenuShowText" },
{ comment: "系统主题类型", key: "systemThemeType", enumMap: ENUM_MAPS.systemTheme },
{ comment: "系统主题模式", key: "systemThemeMode", enumMap: ENUM_MAPS.systemTheme },
{ comment: "菜单风格", key: "menuThemeType", enumMap: ENUM_MAPS.menuTheme },
{ comment: "系统主题颜色", key: "systemThemeColor" },
{ comment: "是否显示菜单按钮", key: "showMenuButton" },
{ comment: "是否显示快速入口", key: "showFastEnter" },
{ comment: "是否显示刷新按钮", key: "showRefreshButton" },
{ comment: "是否显示面包屑", key: "showCrumbs" },
{ comment: "是否显示工作台标签", key: "showWorkTab" },
{ comment: "是否显示语言切换", key: "showLanguage" },
{ comment: "是否显示进度条", key: "showNprogress" },
{ comment: "是否显示设置引导", key: "showSettingGuide" },
{ comment: "是否显示节日文本", key: "showFestivalText" },
{ comment: "是否显示水印", key: "watermarkVisible" },
{ comment: "是否显示应用 Logo 与系统标题", key: "showAppLogo" },
{ comment: "登录后是否开启新手引导", key: "showGuide" },
{ comment: "灰色模式", key: "grayMode" },
{ comment: "是否启用 AI 助手", key: "userEnableAi" },
{ comment: "是否自动关闭", key: "autoClose" },
{ comment: "是否唯一展开", key: "uniqueOpened" },
{ comment: "是否色弱模式", key: "colorWeak" },
{ comment: "是否刷新", key: "refresh" },
{ comment: "是否加载节日烟花", key: "holidayFireworksLoaded" },
{ comment: "边框模式", key: "boxBorderMode" },
{ comment: "页面过渡效果", key: "pageTransition" },
{ comment: "标签页样式", key: "tabStyle" },
{ comment: "自定义圆角", key: "customRadius" },
{ comment: "容器宽度", key: "containerWidth", enumMap: ENUM_MAPS.containerWidth },
{
comment: "上次完成烟花播放的自然日(YYYY-MM-DD),留空可重新触发",
key: "festivalDate",
forceValue: "",
},
];
/**
* 将值转换为代码字符串
*/
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
if (value === null) return "null";
if (value === undefined) return "undefined";
// 优先查找枚举映射
if (enumMap && typeof value === "string" && enumMap[value]) {
return enumMap[value];
}
// 其他类型处理
if (typeof value === "string") return `'${value}'`;
if (typeof value === "boolean" || typeof value === "number") return String(value);
return JSON.stringify(value);
};
/**
* 生成配置代码
*/
const generateConfigCode = (): string => {
const lines = ["export const SETTING_DEFAULT_CONFIG = {"];
CONFIG_ITEMS.forEach((item) => {
lines.push(` /** ${item.comment} */`);
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key];
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`);
});
lines.push("}");
return lines.join("\n");
};
/**
* 复制配置到剪贴板
*/
const handleCopyConfig = async () => {
try {
const configText = generateConfigCode();
await copy(configText);
if (copied.value) {
ElMessage.success({
message: t("setting.actions.copySuccess"),
duration: 3000,
});
}
} catch (error) {
console.error("复制配置失败:", error);
ElMessage.error(t("setting.actions.copyFailed"));
}
};
/**
* 切换布尔值配置(如果当前值与默认值不同)
*/
const toggleIfDifferent = (currentValue: boolean, defaultValue: boolean, toggleFn: () => void) => {
if (currentValue !== defaultValue) {
toggleFn();
}
};
/**
* 重置配置为默认值
*/
const handleResetConfig = async () => {
try {
const config = SETTING_DEFAULT_CONFIG;
// 菜单相关
settingStore.switchMenuLayouts(config.menuType);
settingStore.setMenuOpenWidth(config.menuOpenWidth);
settingStore.setMenuOpen(config.menuOpen);
settingStore.setDualMenuShowText(config.dualMenuShowText);
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
switchThemeStyles(config.systemThemeMode);
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
await nextTick();
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType;
settingStore.switchMenuStyles(menuTheme);
settingStore.setElementTheme(config.systemThemeColor);
// 界面显示(切换类方法)
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
settingStore.setButton()
);
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
settingStore.setFastEnter()
);
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
settingStore.setShowRefreshButton()
);
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () => settingStore.setCrumbs());
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
settingStore.setLanguage()
);
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
settingStore.setNprogress()
);
// 界面显示(直接设置类方法)
settingStore.setWorkTab(config.showWorkTab);
settingStore.setShowFestivalText(config.showFestivalText);
settingStore.setWatermarkVisible(config.watermarkVisible);
settingStore.updateSetting("showAppLogo", config.showAppLogo);
settingStore.updateSetting("showGuide", config.showGuide);
settingStore.updateSetting("grayMode", config.grayMode);
settingStore.updateSetting("userEnableAi", config.aiEnabled);
// 功能设置
toggleIfDifferent(settingStore.autoClose, config.autoClose, () => settingStore.setAutoClose());
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
settingStore.setUniqueOpened()
);
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () => settingStore.setColorWeak());
// 样式设置
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
settingStore.setBorderMode()
);
settingStore.setPageTransition(config.pageTransition);
settingStore.setTabStyle(config.tabStyle);
settingStore.setCustomRadius(config.customRadius);
settingStore.setContainerWidth(config.containerWidth);
// 节日相关
settingStore.setFestivalDate(config.festivalDate);
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded);
location.reload();
} catch (error) {
console.error("重置配置失败:", error);
ElMessage.error(t("setting.actions.resetFailed"));
}
};
</script>
@@ -0,0 +1,51 @@
<template>
<div class="setting-drawer">
<ElDrawer
size="300px"
v-model="visible"
:lock-scroll="true"
:with-header="false"
:before-close="handleClose"
:destroy-on-close="false"
modal-class="setting-modal"
@open="handleOpen"
@close="handleDrawerClose"
>
<div class="drawer-con">
<slot />
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
}
interface Emits {
(e: "update:modelValue", value: boolean): void;
(e: "open"): void;
(e: "close"): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const handleOpen = () => {
emit("open");
};
const handleDrawerClose = () => {
emit("close");
};
const handleClose = () => {
visible.value = false;
};
</script>
@@ -0,0 +1,18 @@
<template>
<div>
<div class="flex justify-end">
<div
@click="$emit('close')"
class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80"
>
<FaSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits<{
close: [];
}>();
</script>
@@ -0,0 +1,101 @@
<template>
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
<span class="text-sm">{{ config.label }}</span>
<!-- 开关类型 -->
<ElSwitch v-if="config.type === 'switch'" :model-value="modelValue" @change="handleChange" />
<!-- 数字输入类型 -->
<ElInputNumber
v-else-if="config.type === 'input-number'"
:model-value="modelValue"
:min="config.min"
:max="config.max"
:step="config.step"
:style="config.style"
:controls-position="config.controlsPosition"
@change="handleChange"
/>
<!-- 选择器类型 -->
<ElSelect
v-else-if="config.type === 'select'"
:model-value="modelValue"
:style="config.style"
@change="handleChange"
>
<ElOption
v-for="option in normalizedOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
</template>
<script setup lang="ts">
import type { ComputedRef } from "vue";
interface SettingItemConfig {
key: string;
label: string;
type: "switch" | "input-number" | "select";
handler: string;
mobileHide?: boolean;
min?: number;
max?: number;
step?: number;
style?: Record<string, string>;
controlsPosition?: "" | "right";
options?:
| Array<{ value: any; label: string }>
| ComputedRef<Array<{ value: any; label: string }>>;
}
interface Props {
config: SettingItemConfig;
modelValue: any;
}
interface Emits {
(e: "change", value: any): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// 标准化选项,处理computed和普通数组
const normalizedOptions = computed(() => {
if (!props.config.options) return [];
try {
// 如果是 ComputedRef,则返回其值
if (typeof props.config.options === "object" && "value" in props.config.options) {
return props.config.options.value || [];
}
// 如果是普通数组,直接返回
return Array.isArray(props.config.options) ? props.config.options : [];
} catch (error) {
console.warn("Error processing options for config:", props.config.key, error);
return [];
}
});
const handleChange = (value: any) => {
try {
emit("change", value);
} catch (error) {
console.error("Error handling change for config:", props.config.key, error);
}
};
</script>
<style lang="scss" scoped>
@media screen and (width <= 768px) {
.mobile-hide {
display: none !important;
}
}
</style>
@@ -0,0 +1,28 @@
<template>
<SectionTitle :title="$t('setting.theme.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="(item, index) in configOptions.themeList"
:key="item.theme"
@click="switchThemeStyles(item.theme)"
>
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
<img :src="item.img" />
</div>
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from "./SectionTitle.vue";
import { useSettingsStore } from "@stores/modules/setting.store";
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useTheme } from "@/hooks/core/useTheme";
const settingStore = useSettingsStore();
const { systemThemeMode } = storeToRefs(settingStore);
const { configOptions } = useSettingsConfig();
const { switchThemeStyles } = useTheme();
</script>
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
<!-- 布局容器 -->
<template>
<div class="app-layout">
<aside id="app-sidebar">
<FaSidebarMenu />
</aside>
<main id="app-main">
<div id="app-header">
<FaHeaderBar />
</div>
<div id="app-content">
<FaPageContent />
</div>
</main>
<div id="app-global">
<FaGlobalComponent />
<Guide v-if="guideVisible" v-model="guideVisible" @skip="onGuideFinished" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import Guide from "@/components/others/fa-guide/index.vue";
import { useAppStore } from "@stores/modules/app.store";
import { useSettingsStore } from "@stores/modules/setting.store";
defineOptions({ name: "AppLayout" });
const appStore = useAppStore();
const settingStore = useSettingsStore();
const guideVisible = computed({
get: () => appStore.guideVisible,
set: (v: boolean) => appStore.showGuide(v),
});
function onGuideFinished(): void {
settingStore.updateSetting("showGuide", false);
}
</script>
<style lang="scss" scoped>
@use "./style";
</style>
@@ -0,0 +1,101 @@
.app-layout {
display: flex;
width: 100%;
min-height: 100vh;
background: var(--default-bg-color);
#app-sidebar {
flex-shrink: 0;
}
#app-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: auto;
#app-header {
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
width: 100%;
}
#app-content {
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
overflow: auto;
:deep(.layout-content) {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: auto;
// 子页面默认 style(勿用 overflow:hidden:在 flex 主区内会裁切长页导致无法滚动)
.page-content {
position: relative;
box-sizing: border-box;
min-height: 0;
padding: 20px;
overflow: hidden auto;
background: var(--default-box-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
}
}
}
}
}
@media only screen and (width <= 1180px) {
.app-layout {
#app-main {
height: 100dvh;
}
}
}
@media only screen and (width <= 800px) {
.app-layout {
position: relative;
#app-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 300;
height: 100vh;
}
#app-main {
width: 100%;
height: auto;
overflow: visible;
#app-content {
:deep(.layout-content) {
width: calc(100% - 40px);
}
}
}
}
}
@media only screen and (width <= 640px) {
.app-layout {
#app-main {
#app-content {
:deep(.layout-content) {
width: calc(100% - 30px);
}
}
}
}
}