mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-29 15:42:55 +00:00
refactor(layouts): 将布局组件从layouts目录迁移到components/layouts目录
重构项目结构,将原本位于src/layouts下的所有布局相关组件迁移到src/components/layouts目录中,包括: - 主布局容器index.vue - 设置面板相关组件 - 水平菜单组件 - 快速入口组件 - 全局组件 同时更新了相关路由配置和组件引用路径,确保功能不受影响。此重构旨在使项目结构更加清晰,遵循组件化开发规范。
This commit is contained in:
+144
@@ -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
@@ -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>
|
||||
+111
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -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>
|
||||
+308
@@ -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,
|
||||
};
|
||||
}
|
||||
+182
@@ -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,
|
||||
};
|
||||
}
|
||||
+233
@@ -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>
|
||||
+1085
File diff suppressed because it is too large
Load Diff
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user