refactor: 清理无用导入、重构组件路径与统一依赖导入

1.  移除 .prettierignore 和 tsconfig.json 中对 src/types/import/ 的配置
2.  全局替换 @/utils/message 导入为直接从 element-plus 导入组件
3.  移动并统一组件路径:将 widget/fa-icon-button 迁移至 others/fa-icon-button
4.  新增多环境配置示例文件与docker部署配置
5.  补充类型导入与类型文件统一导出
6.  修复卡片样式与布局细节问题
7.  移除过时的 initIconify 同步初始化函数
This commit is contained in:
zhangtao
2026-07-24 02:03:07 +08:00
parent 77860b0478
commit 096fa8e6da
198 changed files with 1419 additions and 1154 deletions
@@ -3,7 +3,7 @@
<div class="fa-card p-5 flex items-center flex-col pb-6 h-full" :style="{ height: height }">
<div class="flex items-center flex-col gap-4 text-center">
<div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" loading="lazy" />
<img :src="image" :alt="title" class="w-full h-full object-contain" />
</div>
<div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
@@ -6,7 +6,6 @@
<ElImage
:src="props.imageUrl"
fit="cover"
lazy
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
>
<template #placeholder>
@@ -7,6 +7,7 @@
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import { getCssVar } from "@utils";
import { graphic, type EChartsOption } from "@/plugins/echarts";
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
defineOptions({ name: "FaBarChart" });
const props = withDefaults(defineProps<BarChartProps>(), {
@@ -6,6 +6,7 @@
<script setup lang="ts">
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { EChartsOption, BarSeriesOption } from "@/plugins/echarts";
import type { BidirectionalBarChartProps } from "@/types/component/chart";
defineOptions({ name: "FaDualBarCompareChart" });
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
@@ -12,6 +12,7 @@
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import { getCssVar } from "@utils";
import { graphic, type EChartsOption } from "@/plugins/echarts";
import type { BarChartProps, BarDataItem } from "@/types/component/chart";
defineOptions({ name: "FaHBarChart" });
const props = withDefaults(defineProps<BarChartProps>(), {
@@ -11,6 +11,7 @@
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { KLineChartProps } from "@/types/component/chart";
defineOptions({ name: "FaKLineChart" });
const props = withDefaults(defineProps<KLineChartProps>(), {
@@ -12,6 +12,7 @@
import { graphic, type EChartsOption } from "@/plugins/echarts";
import { getCssVar, hexToRgba } from "@utils";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { LineChartProps, LineDataItem } from "@/types/component/chart";
defineOptions({ name: "FaLineChart" });
const props = withDefaults(defineProps<LineChartProps>(), {
@@ -16,6 +16,7 @@
import { echarts } from "@/plugins/echarts";
import { useSettingsStore } from "@stores";
import chinaMapJson from "@/mock/json/chinaMap.json";
import type { MapChartProps } from "@/types/component/chart";
defineOptions({ name: "FaMapChart" });
const chinaMapRef = ref<HTMLElement | null>(null);
@@ -11,6 +11,7 @@
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { RadarChartProps } from "@/types/component/chart";
defineOptions({ name: "FaRadarChart" });
const props = withDefaults(defineProps<RadarChartProps>(), {
@@ -11,6 +11,7 @@
<script setup lang="ts">
import type { EChartsOption } from "@/plugins/echarts";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { RingChartProps } from "@/types/component/chart";
defineOptions({ name: "FaRingChart" });
const props = withDefaults(defineProps<RingChartProps>(), {
@@ -12,6 +12,7 @@
import type { EChartsOption } from "@/plugins/echarts";
import { getCssVar } from "@utils";
import { useChartOps, useChartComponent } from "@/hooks/core/useChart";
import type { ScatterChartProps } from "@/types/component/chart";
defineOptions({ name: "FaScatterChart" });
const props = withDefaults(defineProps<ScatterChartProps>(), {
@@ -230,6 +230,10 @@ import {
defineOptions({ name: "FaForm" });
defineSlots<{
[slotName: string]: (props: { item: FormItem; modelValue: Record<string, any> }) => any;
}>();
const componentMap = {
input: ElInput, // 输入框
inputtag: ElInputTag, // 标签输入框
@@ -22,6 +22,7 @@
<script setup lang="ts">
import type { ISelectConfig } from "@/components/others/fa-table-select/index.vue";
import FaTableSelect from "@/components/others/fa-table-select/index.vue";
import UserAPI, { UserPageQuery } from "@/api/module_system/user";
defineOptions({ name: "FaUserTableSelect" });
@@ -25,7 +25,7 @@ import { useUserStore } from "@stores";
import { request, EmojiText } from "@utils";
import { IDomEditor, IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
import type { AxiosResponse } from "axios";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
defineOptions({ name: "FaWangEditor" });
@@ -1,123 +0,0 @@
.app-layout {
display: flex;
width: 100%;
min-height: 100vh;
background: var(--app-bg);
#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%;
border-bottom: 1px solid var(--fa-gray-200);
// 毛玻璃效果移到伪元素,避免创建 containing block 影响内部 fixed 定位元素
&::before {
position: absolute;
inset: 0;
z-index: -1;
content: "";
background-color: color-mix(in srgb, var(--default-box-color) 85%, transparent);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
}
#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;
#app-header {
backdrop-filter: none; // 移动端无毛玻璃
}
}
}
}
@media only screen and (width <= 800px) {
.app-layout {
position: relative;
#app-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 300;
height: 100vh;
}
/** H5 端:使用 100dvh(动态视口高度,排除移动浏览器地址栏)
* 让 #app-main 有明确高度,#app-content 的 flex:1 才能撑满,
* 表格区才能像 PC 端一样在视口内独立滚动(而非整页滚动) */
#app-main {
width: 100%;
height: 100dvh;
overflow: hidden;
#app-content {
padding: 12px;
:deep(.layout-content) {
width: calc(100% - 24px);
}
}
}
}
}
@media only screen and (width <= 640px) {
.app-layout {
#app-main {
#app-content {
padding: 8px;
:deep(.layout-content) {
width: calc(100% - 16px);
}
}
}
}
}
@@ -1,145 +0,0 @@
<!-- 面包屑导航 -->
<template>
<nav class="ml-2.5 max-lg:hidden!" aria-label="breadcrumb">
<ul class="flex items-center h-full">
<li
v-for="(item, index) in breadcrumbItems"
:key="item.path"
class="box-border flex items-center h-7 text-sm leading-7"
>
<div
:class="
isClickable(item, index)
? 'cursor-pointer py-1 rounded transition duration-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),则移除它
const firstItem = items[0];
if (items.length > 1 && firstItem && isWrapperContainer(firstItem)) {
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>
@@ -1,263 +0,0 @@
<!-- 系统聊天窗口 -->
<template>
<div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
<div class="mb-5 flex items-center justify-between">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="mt-1.5 flex items-center gap-1">
<div class="h-2 w-2 rounded-full" :class="isOnline ? 'bg-success' : 'bg-danger'"></div>
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
</div>
</div>
<div>
<ElIcon class="cursor-pointer" :size="20" @click="closeChat">
<Close />
</ElIcon>
</div>
</div>
<div class="flex h-[calc(100%-70px)] flex-col">
<!-- 聊天消息区域 -->
<ElScrollbar
ref="messageContainer"
class="flex-1 border-t border-(--default-border) px-4 py-7.5"
>
<template v-for="message in messages" :key="message.id">
<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>
</ElScrollbar>
<!-- 聊天输入区域 -->
<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 items-center justify-between">
<div class="flex items-center">
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
<FaSvgIcon
icon="ri:emotion-happy-line"
class="mr-5 cursor-pointer 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";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
import { MOBILE_BREAKPOINT } from "@/utils/constants/definitions";
defineOptions({ name: "FaChatWindow" });
// 类型定义
interface ChatMessage {
id: number;
sender: string;
content: string;
time: string;
isMe: boolean;
avatar: string;
}
// 常量定义
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<InstanceType<typeof ElScrollbar> | 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(() => {
const wrap = messageContainer.value?.wrapRef;
if (wrap) wrap.scrollTop = wrap.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>
@@ -1,186 +0,0 @@
<!-- 顶部快速入口面板 -->
<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 items-center 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 cursor-pointer flex items-center 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 items-center justify-center 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 border-[var(--default-border)] 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="cursor-pointer py-2 hover:[&_span]:text-theme"
@click="handleQuickLinkClick(quickLink)"
>
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
</li>
</ul>
</div>
</div>
</ElPopover>
<!-- 礼花效果介绍弹窗(放在 Popover 外,避免被 Popover 销毁) -->
<ElDialog
v-model="fireworksDialogVisible"
title="礼花效果"
width="480px"
:close-on-click-modal="true"
>
<div class="px-2">
<div class="mb-5 flex items-start gap-4">
<div
class="flex items-center justify-center size-14 rounded-xl bg-linear-to-br from-purple-400 to-indigo-500"
>
<FaSvgIcon icon="ri:loader-line" class="text-2xl text-white" />
</div>
<div class="flex-1">
<h4 class="m-0 text-base font-semibold">节日礼花动画</h4>
<p class="mt-1 text-sm text-g-500 leading-relaxed">
根据当前节日自动匹配对应素材的全屏 Canvas 烟花效果,支持多种粒子形状和绚丽色彩。
</p>
</div>
</div>
<ElDivider />
<div class="space-y-3">
<div class="flex items-center gap-3">
<ElTag type="primary" class="rounded-md! border-0! shrink-0">快捷键</ElTag>
<span class="text-sm text-g-600">
<ElTag size="small" class="mr-1!">Ctrl</ElTag>
+
<ElTag size="small" class="mx-1!">Shift</ElTag>
+
<ElTag size="small" class="ml-1!">P</ElTag>
<span class="mx-2 text-g-400">/</span>
<ElTag size="small" class="mr-1!">⌘</ElTag>
+
<ElTag size="small" class="mx-1!">Shift</ElTag>
+
<ElTag size="small" class="ml-1!">P</ElTag>
</span>
</div>
<div class="flex items-center gap-3">
<ElTag type="success" class="rounded-md! border-0! shrink-0">节日素材</ElTag>
<span class="text-sm text-g-600">
礼花素材与当前节日配置一致,自动匹配春节、元宵、中秋等传统节日主题
</span>
</div>
<div class="flex items-center gap-3">
<ElTag type="warning" class="rounded-md! border-0! shrink-0">触发方式</ElTag>
<span class="text-sm text-g-600">
页面加载时自动检测节日并连发,也可通过快捷键手动触发一次
</span>
</div>
</div>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { useFastEnter } from "@/hooks/core/useFastEnter";
defineOptions({ name: "FaFastEnter" });
const router = useRouter();
const popoverRef = ref();
const fireworksDialogVisible = ref(false);
// 使用快速入口配置
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 => {
if (application.isDialog) {
popoverRef.value?.hide();
fireworksDialogVisible.value = true;
return;
}
handleNavigate(application.routeName, application.link, application.routeQuery);
};
/**
* 处理快速链接点击
* @param quickLink 快速链接配置对象
*/
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
if (quickLink.isDialog) {
popoverRef.value?.hide();
fireworksDialogVisible.value = true;
return;
}
handleNavigate(quickLink.routeName, quickLink.link, quickLink.routeQuery);
};
</script>
@@ -1,651 +0,0 @@
<!-- 烟花效果 | 礼花效果(快捷键 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";
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; // 向上初始速度
const colors = CONFIG.COLORS;
const shapes = availableShapes;
particle.color = colors[Math.floor(Math.random() * colors.length)] ?? colors[0]!;
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 = shapes[Math.floor(Math.random() * shapes.length)] ?? shapes[0]!;
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>
@@ -1,14 +0,0 @@
<!-- 全局组件 -->
<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>
@@ -1,426 +0,0 @@
<!-- 全局搜索组件 -->
<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 items-center justify-center 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! cursor-pointer text-base leading-none"
v-for="(item, index) in searchResult"
:key="item.path"
>
<div
class="mt-2 h-12 flex items-center justify-between 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 cursor-pointer flex items-center justify-between rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
v-for="(item, index) in historyResult"
:key="item.path"
: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 items-center justify-center cursor-pointer"
@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 items-center border-t border-(--default-border) pt-4.5 pb-1"
>
<div class="flex items-center justify-center">
<FaSvgIcon
icon="fluent:arrow-enter-left-20-filled"
class="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)]"
/>
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.selectKeydown") }}</span>
</div>
<div class="flex items-center">
<FaSvgIcon
icon="ri:arrow-up-wide-fill"
class="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)]"
/>
<FaSvgIcon
icon="ri:arrow-down-wide-fill"
class="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)]"
/>
<span class="mr-3.5 text-xs text-g-700">{{ $t("search.switchKeydown") }}</span>
</div>
<div class="flex items-center">
<i
class="mr-2 box-border h-5 rounded border border-g-400 px-1 text-g-500 shadow-[0_2px_0_var(--default-border-dashed)] w-8! flex items-center justify-center"
><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 { Search } from "@element-plus/icons-vue";
import { mittBus, formatMenuTitle, handleMenuJump } from "@utils";
import { useUserStore, useMenuStore } from "@stores";
import { type ScrollbarInstance } from "element-plus";
import { useDebounceFn } from "@vueuse/core";
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(() => {
mittBus.off("openSearchDialog", openSearchDialog);
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 = useDebounceFn((val: string) => {
if (val) {
searchResult.value = flattenAndFilterMenuItems(menuList.value, val);
} else {
searchResult.value = [];
}
}, 150);
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) {
const target = searchResult.value[highlightedIndex.value];
if (target) searchGoPage(target);
} else if (!searchVal.value && historyResult.value.length) {
const target = historyResult.value[historyHIndex.value];
if (target) searchGoPage(target);
}
};
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: var(--fa-overlay-light);
}
: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: var(--fa-input-bg);
border: 1px solid var(--fa-input-border);
}
}
:deep(.search-modal) {
background-color: var(--fa-overlay-light);
backdrop-filter: none;
}
:deep(.el-dialog) {
background-color: var(--fa-dialog-bg);
}
}
</style>
@@ -1,537 +0,0 @@
<!-- 顶部栏 -->
<template>
<div
class="w-full bg-(--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 justify-between h-15 leading-15 select-none"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' || tabStyle === 'tab-default'
? 'border-b border-(--fa-card-border)'
: '',
]"
>
<div class="flex items-center flex-1 min-w-0 leading-15" :style="{ display: 'flex' }">
<!-- 系统信息:Logo + 标题一并受「显示应用 Logo」控制 -->
<div
class="flex items-center cursor-pointer"
@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 items-center gap-2.5">
<!-- 搜索 -->
<div
v-if="shouldShowGlobalSearch"
class="search-bar-trigger flex items-center justify-between w-40 h-9 px-2.5 cursor-pointer border border-g-400 rounded-custom-sm max-md:hidden! transition duration-300 hover:-translate-y-0.5 hover:shadow-md"
@click="openSearchDialog"
>
<div class="flex items-center">
<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 items-center h-5 px-1.5 text-g-500/80 border 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 items-center justify-center 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"
>
<ElBadge
v-if="noticeStore.total > 0"
:value="noticeStore.total > 99 ? '99+' : noticeStore.total"
:max="99"
class="absolute top-0 right-0"
>
<div class="size-1.5"></div>
</ElBadge>
</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 items-center justify-center">
<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 { LanguageEnum } from "@/enums/appEnum";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useFullscreen, useWindowSize } from "@vueuse/core";
import {
useSettingsStore,
useMenuStore,
useUserStore,
useNoticeStore,
useConfigStore,
} from "@stores";
import AppConfig from "@/config";
import { languageOptions } from "@/locales";
import { mittBus, themeAnimation } from "@utils";
import { useCommon } from "@/hooks/core/useCommon";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import FaUserMenu from "./widgets/FaUserMenu.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();
const noticeStore = useNoticeStore();
/** 租户配置:logo_url / name */
const headerLogoSrc = computed(() => {
const raw = configStore.configData.logo_url?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const headerSystemName = computed(() => {
const raw = configStore.configData.sys_name?.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 { visibleMenus: 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;
}
/* 搜索触发按钮 hover 边框变主题色 */
.search-bar-trigger:hover {
border-color: var(--el-color-primary) !important;
}
.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>
@@ -1,196 +0,0 @@
<!-- 用户菜单:合并旧版顶栏(配置中心、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] cursor-pointer 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 items-center 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="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="goPage('/fastlink/profile')"
>
<FaSvgIcon icon="ri:user-3-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.userCenter") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="toGithub()"
>
<FaSvgIcon icon="ri:github-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.github") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="toGitee"
>
<FaSvgIcon icon="ri:git-branch-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.gitee") }}</span>
</li>
<li
class="flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--el-color-primary)/10"
@click="lockScreen()"
>
<FaSvgIcon icon="ri:lock-line" class="mr-2 text-base" />
<span class="text-sm">{{ $t("topBar.user.lockScreen") }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<li
class="flex p-2 select-none rounded-md cursor-pointer last:mb-0 hover:bg-(--fa-gray-200) justify-center mt-5 mb-0 py-1.5 text-xs border border-g-400 hover:text-(--el-color-danger) hover:border-(--el-color-danger-light-3)"
@click="handleLogout"
>
{{ $t("topBar.user.logout") }}
</li>
</ul>
</div>
</template>
</ElPopover>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { ElMessageBox } from "@/utils/message";
import { useUserStore } from "@stores";
import { WEB_LINKS, mittBus } from "@utils";
defineOptions({ name: "FaUserMenu" });
const router = useRouter();
const { t } = useI18n();
const userStore = useUserStore();
const { info: userInfo } = storeToRefs(userStore);
const userMenuPopover = ref();
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 || "");
function goPage(path: string): void {
router.push(path);
}
function toGithub(): void {
window.open(WEB_LINKS.GITHUB);
}
function toGitee(): void {
window.open(WEB_LINKS.GITEE);
}
function lockScreen(): void {
mittBus.emit("openLockScreen");
}
function handleLogout(): void {
closeUserMenu();
setTimeout(async () => {
try {
await ElMessageBox.confirm(t("common.logoutTips"), t("common.tips"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
customClass: "login-out-dialog",
});
await userStore.logout();
} catch {
// 用户取消
}
}, 200);
}
function closeUserMenu(): void {
setTimeout(() => {
userMenuPopover.value?.hide?.();
}, 100);
}
</script>
<style scoped>
/* ElPopover 基于 Tooltip:触发层默认 inline-block,与顶栏 flex 图标中线对齐 */
.fa-user-menu .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>
@@ -1,142 +0,0 @@
<!-- 水平菜单 -->
<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"
>
<FaHorizontalSubmenu
v-for="item in filteredMenuItems"
:key="item.path"
:item="item"
:level="0"
/>
</ElMenu>
</div>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@stores";
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;
}
/* Horizontal menu item hover background */
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: var(--el-color-primary-light-9) !important;
}
/* 暗色模式:水平菜单内联项(非弹出层)hover */
.dark {
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
}
</style>
<style lang="scss">
/* 水平菜单弹出层 hover 背景(popper 在组件树外,需 un-scoped) */
.horizontal-menu-popper {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: var(--el-color-primary-light-9) !important;
border-radius: 6px;
}
}
/* 暗色模式:hover 背景使用半透明主题色 */
.dark {
.horizontal-menu-popper {
.el-menu-item:hover,
.el-sub-menu__title:hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
}
}
</style>
@@ -1,109 +0,0 @@
<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>
<!-- 递归调用自身处理子菜单 -->
<FaHorizontalSubmenu
v-for="child in filteredChildren"
:key="child.path"
:item="child"
:theme="theme"
:level="level + 1"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else-if="isNavigableRoute"
:index="item.path || item.meta.title"
@click="goPage(item)"
>
<FaMenuRouteIcon
: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 } from "vue";
import { handleMenuJump, formatMenuTitle } from "@utils";
defineOptions({ name: "FaHorizontalSubmenu" });
interface Props {
item: AppRouteRecord;
theme?: Record<string, any>;
level?: number;
}
const props = withDefaults(defineProps<Props>(), {
theme: () => ({}),
level: 0,
});
interface Emits {
close: [];
}
const emit = defineEmits<Emits>();
// 过滤后的子菜单项(不包含隐藏的)
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>
@@ -1,263 +0,0 @@
<!-- 混合菜单 -->
<template>
<div id="app-menu-top-left" class="relative box-border flex items-center 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 items-center 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 shrink-0 h-10 px-3 text-sm flex items-center cursor-pointer 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, handleMenuJump } from "@utils";
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>
: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);
}
.menu-item:hover {
background-color: var(--el-color-primary-light-9);
border-radius: 6px;
}
.dark .menu-item:hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
</style>
@@ -1,892 +0,0 @@
<!-- 左侧菜单 或 双列菜单 -->
<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" :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, useSettingsStore, useMenuStore } from "@stores";
import { MenuWidth } from "@/enums/appEnum";
import { isIframe, handleMenuJump } from "@utils";
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
import { useCommon } from "@/hooks/core/useCommon";
import { useWindowSize, useTimeoutFn } from "@vueuse/core";
import { MOBILE_BREAKPOINT } from "@utils/constants";
defineOptions({ name: "FaSidebarMenu" });
const ANIMATION_DELAY = 350;
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE;
const route = useRoute();
const router = useRouter();
const settingStore = useSettingsStore();
const configStore = useConfigStore();
/** 租户配置:logo_url / name */
const sidebarLogoSrc = computed(() => {
const raw = configStore.configData.logo_url?.config_value;
return typeof raw === "string" && raw.trim() ? raw.trim() : undefined;
});
const sidebarTitle = computed(() => {
const raw = configStore.configData.sys_name?.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 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().visibleMenus.filter((menu) => !menu.meta.isHide);
});
const menuList = computed(() => {
const menuStore = useMenuStore();
const allMenus = menuStore.visibleMenus;
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
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: AppRouteRecord[]) => {
// 递归查找包含当前路径的菜单项
const hasPath = (items: AppRouteRecord[]): 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>
.layout-sidebar {
display: flex;
height: 100vh;
user-select: none;
scrollbar-width: none;
border-right: 1px solid var(--fa-card-border);
&.no-border {
border-right: none !important;
}
:deep(.el-scrollbar__bar.is-vertical) {
width: 4px;
}
:deep(.el-scrollbar__thumb) {
right: -2px;
background-color: var(--fa-gray-400);
border-radius: 2px;
}
.dual-menu-left {
position: relative;
width: 80px;
height: 100%;
border-right: 1px solid var(--fa-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(--fa-card-border);
border-radius: 0 15px 15px 0;
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.2s;
&:hover {
.art-svg-icon {
color: var(--fa-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: var(--fa-overlay-heavy);
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 var(--fa-dark-border-subtle);
:deep(.el-scrollbar__thumb) {
background-color: var(--fa-scrollbar-thumb);
}
.dual-menu-left {
border-right: 1px solid var(--fa-dark-border-subtle) !important;
}
}
}
</style>
<style lang="scss">
@use "@styles/core/mixin.scss" as *;
/* 菜单样式变量 */
$menu-height: 42px;
$menu-icon-size: 20px;
$menu-font-size: 14px;
$hover-bg-color: var(--el-color-primary-light-9);
$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(--fa-gray-600);
}
}
/* ---------------------- Dark theme menu ---------------------- */
.el-menu-dark {
@include theme-menu-base;
@include menu-active(var(--fa-gray-900), var(--fa-gray-300), var(--fa-gray-900));
@include menu-hover(var(--fa-gray-200));
.el-sub-menu__icon-arrow {
color: var(--fa-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(--fa-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(--el-color-primary-light-9), var(--fa-gray-900), var(--fa-gray-200));
}
/* 暗黑模式菜单样式 */
.dark {
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(
var(--el-color-primary-light-9),
var(--fa-gray-900),
var(--fa-gray-300)
);
}
.layout-sidebar {
/* 图标颜色、文字颜色 */
.menu-icon .art-svg-icon,
.menu-name {
color: var(--fa-gray-800) !important;
}
/* 暗色模式:悬浮使用半透明主题色 */
.el-menu-item:not(.is-active):hover,
.el-sub-menu__title:not(.is-active):hover {
background-color: color-mix(in srgb, var(--el-color-primary) 15%, transparent) !important;
}
/* 选中的文字颜色跟图标颜色 */
.el-menu-item.is-active {
span,
.menu-icon .art-svg-icon {
color: var(--theme-color) !important;
}
}
/* 右侧箭头颜色 */
.el-sub-menu__icon-arrow {
color: var(--fa-gray-900);
}
}
}
.layout-sidebar {
/* 展开的宽度 */
.el-menu:not(.el-menu--collapse) {
width: v-bind(menuopenwidth);
}
/* 折叠后宽度 */
.el-menu--collapse {
width: v-bind(menuclosewidth);
}
}
</style>
@@ -1,200 +0,0 @@
<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 items-center justify-center">
<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>
<FaSidebarSubmenu
:list="item.children"
:level="level + 1"
:theme="theme"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else
:index="isExternalLink(item) ? '' : item.path || item.meta.title"
:level-item="level + 1"
@click="goPage(item)"
>
<div class="menu-icon flex items-center justify-center">
<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 { formatMenuTitle, handleMenuJump } from "@utils";
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaSidebarSubmenu" });
interface MenuTheme {
iconColor?: string;
}
interface Props {
/** 菜单标题 */
title?: string;
/** 菜单列表 */
list?: AppRouteRecord[];
/** 主题配置 */
theme?: MenuTheme;
/** 菜单层级 */
level?: number;
}
interface Emits {
/** 关闭菜单事件 */
(e: "close"): void;
}
const props = withDefaults(defineProps<Props>(), {
title: "",
list: () => [],
theme: () => ({}),
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>
@@ -1,177 +0,0 @@
<!-- 通知组件 -->
<template>
<div
class="fa-notification-panel fa-card-sm shadow-xl! flex flex-col"
: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 shrink-0">
<span class="text-base font-medium text-g-800">{{ $t("notice.title") }}</span>
</div>
<ElScrollbar class="flex-1 min-h-0 overflow-y-scroll scrollbar-thin">
<ul>
<li
v-for="(item, index) in noticeList"
:key="item.title + item.time"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
@click="handleMarkAsRead(index)"
>
<div class="size-9 leading-9 text-center rounded-lg flex-cc bg-theme/12 text-theme">
<FaSvgIcon class="text-lg bg-transparent!" icon="ri:notification-3-line" />
</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>
<div v-if="!item.read" class="ml-2 size-2 rounded-full bg-danger shrink-0"></div>
</li>
</ul>
<!-- 空状态 -->
<div
v-show="noticeList.length === 0"
class="h-full text-g-500 text-center bg-transparent! flex flex-col items-center justify-center mt-12"
>
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs bg-transparent!">{{ $t("notice.empty") }}</p>
</div>
</ElScrollbar>
<div class="box-border w-full px-3.5 pt-2 pb-3.5 shrink-0">
<ElButton class="w-full" @click="handleViewAll" v-ripple>
{{ $t("notice.viewAll") }}
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { useRouter } from "vue-router";
import NoticeAPI from "@/api/module_system/notice";
defineOptions({ name: "FaNotification" });
interface NoticeItem {
title: string;
time: string;
read: boolean;
}
interface Props {
value: boolean;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:value": [value: boolean];
}
const emit = defineEmits<Emits>();
const show = ref(false);
const visible = ref(false);
const noticeList = ref<NoticeItem[]>([]);
const loading = ref(false);
const fetchNotices = async () => {
loading.value = true;
try {
const res = await NoticeAPI.listNoticeAvailable();
const items = res.data?.data ?? [];
noticeList.value = items.map((n) => ({
title: n.notice_title ?? "",
time: n.created_time ?? "",
read: false,
}));
} catch {
noticeList.value = [];
} finally {
loading.value = false;
}
};
onMounted(() => fetchNotices());
watch(visible, (v) => {
if (v) fetchNotices();
});
const handleViewAll = () => {
const router = useRouter();
router.push("/module_system/notice");
emit("update:value", false);
};
const handleMarkAsRead = (index: number) => {
const item = noticeList.value[index];
if (item && !item.read) {
item.read = true;
// 本地标记已读;全局计数由 noticeStore 管理
}
};
const showNotice = (open: boolean) => {
if (open) {
visible.value = true;
setTimeout(() => {
show.value = true;
}, 5);
} else {
show.value = false;
setTimeout(() => {
visible.value = false;
}, 350);
}
};
watch(
() => props.value,
(newValue) => {
showNotice(newValue);
}
);
</script>
<style scoped>
@reference '@styles/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-16.25
max-[640px]:right-0
max-[640px]:w-full
max-[640px]:h-[80vh];
}
.fa-notification-panel.fa-notification-panel {
border-radius: calc(var(--custom-radius) + 2px) !important;
}
.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: var(--fa-scrollbar-thumb-dark) !important;
}
</style>
@@ -1,150 +0,0 @@
<!-- 布局内容 -->
<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 border-[var(--default-border)] 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">
<div v-if="Component" class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
<!-- 是否缓存以后端菜单 keep_alive → meta.keepAlive 为准;此处 !== false 即包 KeepAlive(与 MenuProcessor 一致) -->
<KeepAlive
v-if="wrapPageWithKeepAlive"
:max="10"
:include="keepAliveInclude"
:exclude="keepAliveExclude"
>
<component
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="routeLeafCacheKey(router)"
/>
</KeepAlive>
<component
v-else
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="routeLeafCacheKey(router)"
/>
</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-theme" />
</ElBacktop>
</div>
</template>
<script setup lang="ts">
/**
* 布局滚动容器 + 业务路由出口;与 settings.refresh 联动可整体重建 RouterView。
*
* 缓存开关数据源:后端菜单 `keep_alive` → MenuProcessor 写入 `meta.keepAlive`;工作栏 tab 随路由写入同一 meta。
* include / wrapPageWithKeepAlive 均用 `!== false`,与静态路由里显式 `keepAlive: false`、后端布尔字段对齐。
*/
import type { CSSProperties } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
import { useSettingsStore, useWorktabStore } from "@stores";
defineOptions({ name: "FaPageContent" });
/**
* KeepAlive / RouterView 子节点缓存键。
* - `meta.remountOnFullPath === true`:整 URL 参与键(依赖 query 初值或须随 query 重建的页)。
* - 有 `name`:`name` + `params`,同一路由仅 query 变化不换实例。
* - 无 `name`:`path`(勿用 fullPath,否则仅 query 变化也会反复挂载)。
*/
function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
if (r.meta.remountOnFullPath === true) {
return r.fullPath;
}
if (r.name != null) {
return `${String(r.name)}:${JSON.stringify(r.params ?? {})}`;
}
return r.path;
}
const route = useRoute();
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-content"));
const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"));
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
/** 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。 */
const keepAliveInclude = computed(() => {
if (!showWorkTab.value) return undefined;
const names = new Set<string>();
for (const t of opened.value) {
if (t.name && t.keepAlive !== false) names.add(String(t.name));
}
return names.size ? Array.from(names) : undefined;
});
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>
@@ -1,784 +0,0 @@
<!-- 锁屏 -->
<template>
<div class="layout-lock-screen">
<!-- 开发者工具警告覆盖层 -->
<div
v-if="showDevToolsWarning"
class="fixed top-0 left-0 z-999999 flex items-center justify-center w-full h-full text-white bg-linear-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"
loading="eager"
/>
<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" :style="lockPageBgStyle">
<div v-show="showClock" class="unlock-container" @click="showUnlockForm">
<ElIcon><Lock /></ElIcon>
<span>{{ t("lock.unlock") }}</span>
</div>
<div class="time-container flex-cc 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 flex-cc 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 flex-cc">
<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";
defineOptions({ name: "FaScreenLock" });
import type { FormInstance, FormRules } from "element-plus";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import CryptoJS from "crypto-js";
import { useUserStore, useSettingsStore } from "@stores";
import { mittBus, useNow } from "@utils";
import bgDark from "@imgs/lock/bg_dark.webp";
import bgLight from "@imgs/lock/bg_light.webp";
import { ElMessage } from "@/utils/message";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY;
const userStore = useUserStore();
const settingsStore = useSettingsStore();
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore);
const { isDark } = storeToRefs(settingsStore);
/** 锁屏全屏背景:随明暗主题切换壁纸,叠加半透明罩层保证时钟/文案可读 */
const lockPageBgStyle = computed(() => {
const url = isDark.value ? bgDark : bgLight;
return {
backgroundImage: `linear-gradient(rgb(0 0 0 / 46%), rgb(0 0 0 / 46%)), url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
};
});
const { hour, month, minute, meridiem, year, day, week } = useNow(true);
const displayName = computed(() => userInfo.value?.name || userInfo.value?.username || "—");
const userAvatar = computed(() => {
const a = userInfo.value?.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.value.password,
ENCRYPT_KEY
).toString();
userStore.setLockStatus(true);
userStore.setLockPassword(encryptedPassword);
visible.value = false;
formData.value.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%;
@media (width <= 640px) {
width: 50px;
height: 50px;
}
}
&-name {
margin: 10px 0;
font-size: 14px;
color: var(--top-header-text-color);
@media (width <= 640px) {
margin: 8px 0;
font-size: 12px;
}
}
}
}
.lockpage {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--el-color-white);
.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;
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: var(--fa-overlay-heavy);
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%;
@media (width <= 640px) {
width: 50px;
height: 50px;
}
}
.username {
margin: 0.625rem 0;
font-size: 0.875rem;
color: var(--el-text-color-primary);
@media (width <= 640px) {
margin: 0.5rem 0;
font-size: 0.75rem;
}
}
.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>
@@ -1,307 +0,0 @@
import { computed } from "vue";
import { useI18n } from "vue-i18n";
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,
};
}
@@ -1,182 +0,0 @@
import { ContainerWidthEnum } from "@/enums/appEnum";
import { useSettingsStore } from "@stores";
import { storeToRefs } from "pinia";
/**
* 设置项通用处理逻辑
*/
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,
};
}
@@ -1,235 +0,0 @@
import { ref, computed, watch } from "vue";
import { useSettingsStore } from "@stores";
import { storeToRefs } from "pinia";
import { MenuTypeEnum } from "@/enums/appEnum";
import { useWindowSize } from "@vueuse/core";
import { MOBILE_BREAKPOINT } from "@utils/constants";
import AppConfig from "@/config";
import { mittBus, 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);
// 响应式布局
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// 记录窗口宽度变化前的菜单类型
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();
mittBus.off("openSetting", openSetting);
themeCleanup?.();
cleanup();
};
return {
initializeSettings,
cleanupSettings,
};
};
return {
// 状态
showDrawer,
// 方法组合
useThemeHandlers,
useResponsiveLayout,
useDrawerControl,
usePropsWatcher,
useSettingsInitializer,
};
}
@@ -1,37 +0,0 @@
import { MenuTypeEnum } from "@/enums/appEnum";
import { useSettingsStore } from "@stores";
/**
* 设置状态管理
*/
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,
};
}
@@ -1,154 +0,0 @@
<!-- 设置面板 -->
<template>
<div class="layout-settings">
<FaSettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
<!-- 头部关闭按钮 -->
<FaSettingHeader @close="closeDrawer" />
<!-- 主题风格 -->
<FaThemeSettings />
<!-- 菜单布局 -->
<FaMenuLayoutSettings />
<!-- 菜单风格 -->
<FaMenuStyleSettings />
<!-- 系统主题色 -->
<FaColorSettings />
<!-- 盒子样式 -->
<FaBoxStyleSettings />
<!-- 容器宽度 -->
<FaContainerSettings />
<!-- 基础配置 -->
<FaBasicSettings />
<!-- 操作按钮 -->
<FaSettingActions />
</FaSettingDrawer>
</div>
</template>
<script setup lang="ts">
import { useSettingsPanel } from "./composables/useSettingsPanel";
defineOptions({ name: "FaSettingsPanel" });
interface Props {
/** 是否打开 */
open?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
// 使用设置面板逻辑
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 "@styles/core/mixin.scss" as *;
/* 设置抽屉模态框样式 */
.setting-modal {
background: transparent !important;
.el-drawer {
// 背景滤镜效果
background: var(--fa-drawer-bg) !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: var(--fa-drawer-bg) !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;
}
}
</style>
@@ -1,93 +0,0 @@
<template>
<div>
<FaSectionTitle :title="$t('setting.basics.title')" class="mt-10" />
<FaSettingItem
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 { useSettingsStore } from "@stores";
defineOptions({ name: "FaBasicSettings" });
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 Record<string, (...args: any[]) => any>)[handlerName];
if (typeof handler === "function") {
handler(value);
} else {
console.warn(`Handler "${handlerName}" not found in basicHandlers`);
}
};
</script>
@@ -1,39 +0,0 @@
<template>
<div>
<FaSectionTitle :title="$t('setting.box.title')" class="mt-10" />
<div class="box-border flex items-center justify-between 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 cursor-pointer select-none rounded-md transition-all duration-200"
:class="
isActive(option.type)
? 'text-g-800 bg-(--default-box-color) dark:text-white! dark:bg-g-300'
: 'hover:text-g-800 hover:bg-black/4 dark:hover:bg-black/20'
"
@click="boxStyleHandlers.setBoxMode(option.type)"
>
{{ option.label }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaBoxStyleSettings" });
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>
@@ -1,36 +0,0 @@
<template>
<div>
<FaSectionTitle :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 { useSettingsStore } from "@stores";
defineOptions({ name: "FaColorSettings" });
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>
@@ -1,34 +0,0 @@
<template>
<div>
<FaSectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
<div class="flex">
<div
v-for="option in containerWidthOptions"
:key="option.value"
class="flex items-center justify-center 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 border-[var(--default-border)]': 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 { useSettingsStore } from "@stores";
defineOptions({ name: "FaContainerSettings" });
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>
@@ -1,32 +0,0 @@
<template>
<div v-if="width > 1000">
<FaSectionTitle :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" loading="eager" />
</div>
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaMenuLayoutSettings" });
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>
@@ -1,46 +0,0 @@
<template>
<FaSectionTitle :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" loading="eager" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MenuThemeEnum } from "@/enums/appEnum";
import AppConfig from "@/config";
defineOptions({ name: "FaMenuStyleSettings" });
import { useSettingsStore } from "@stores";
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>
@@ -1,21 +0,0 @@
<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-(--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">
defineOptions({ name: "FaSectionTitle" });
interface Props {
title: string;
style?: Record<string, any>;
}
withDefaults(defineProps<Props>(), {
style: () => ({}),
});
</script>
@@ -1,242 +0,0 @@
<!-- 设置操作按钮 -->
<template>
<div class="mt-10 flex gap-8 border-t border-(--default-border) bg-(--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";
import { SETTING_DEFAULT_CONFIG } from "@/config/setting";
import { useClipboard } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { useTheme } from "@/hooks/core/useTheme";
import { ElMessage } from "@/utils/message";
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>
@@ -1,53 +0,0 @@
<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">
defineOptions({ name: "FaSettingDrawer" });
interface Props {
modelValue: boolean;
}
interface Emits {
(e: "update:modelValue", value: boolean): void;
(e: "open"): void;
(e: "close"): void;
}
const props = withDefaults(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>
@@ -1,22 +0,0 @@
<template>
<div>
<div class="flex justify-end">
<div
@click="$emit('close')"
class="flex items-center justify-center cursor-pointer 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">
defineOptions({ name: "FaSettingHeader" });
interface Emits {
close: [];
}
defineEmits<Emits>();
</script>
@@ -1,106 +0,0 @@
<template>
<div
class="flex items-center justify-between 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";
defineOptions({ name: "FaSettingItem" });
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 = withDefaults(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>
@@ -1,29 +0,0 @@
<template>
<FaSectionTitle :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" loading="eager" />
</div>
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingsStore } from "@stores";
defineOptions({ name: "FaThemeSettings" });
import { useSettingsConfig } from "../composables/useSettingsConfig";
import { useTheme } from "@/hooks/core/useTheme";
const settingStore = useSettingsStore();
const { systemThemeMode } = storeToRefs(settingStore);
const { configOptions } = useSettingsConfig();
const { switchThemeStyles } = useTheme();
</script>
File diff suppressed because it is too large Load Diff
@@ -1,68 +0,0 @@
<!--
布局根容器:三区域结构
- #app-sidebar ← 左侧菜单导航(收起/展开)
- #app-main ← 右侧主区域(顶栏 + 页面内容)
- #app-header 顶栏(面包屑、搜索、通知、用户菜单)
- #app-content 页面内容(RouterView + 页签)
- #app-global ← 全局浮层层(Toast、Modal、新手引导)
-->
<template>
<div class="app-layout">
<!-- 左侧菜单导航 -->
<aside id="app-sidebar" aria-label="主菜单导航">
<FaSidebarMenu />
</aside>
<!-- 右侧主区域 -->
<main id="app-main" aria-label="主要内容区域">
<div id="app-header">
<FaHeaderBar />
</div>
<div id="app-content">
<FaPageContent />
</div>
</main>
<!-- 全局浮层层(引导、通知等跨页面组件) -->
<div id="app-global">
<FaGlobalComponent />
<FaGuide v-if="guideVisible" v-model="guideVisible" @skip="onGuideFinished" />
</div>
</div>
</template>
<script setup lang="ts">
/**
* 布局根组件 —— 组装侧栏、顶栏、页面内容、全局浮层。
*
* 依赖:
* appStore.guideVisible ← 控制新手指引显隐(session 级状态)
* settingStore.showGuide ← 用户是否关闭指引(持久化)
*
* 流程:
* 首次访问 → guideVisible=true → 用户完成/跳过指引 → onGuideFinished()
* → settingStore.showGuide=false → 后续不再显示
*/
import { computed } from "vue";
import { useAppStore, useSettingsStore } from "@stores";
defineOptions({ name: "AppLayout" });
const appStore = useAppStore();
const settingStore = useSettingsStore();
/** 新手指引显隐 —— session 级状态,首次登录/注册后自动弹出 */
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 "./fa-layouts";
</style>
@@ -66,7 +66,7 @@
<script setup lang="ts">
import type { DialogProps } from "element-plus";
import { computed, ref, useAttrs, watch, onMounted, onUnmounted } from "vue";
import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
import FaIconButton from "@/components/others/fa-icon-button/index.vue";
defineOptions({ name: "FaDialog", inheritAttrs: false });
@@ -59,7 +59,7 @@
<script setup lang="ts">
import type { DrawerProps } from "element-plus";
import { computed, useAttrs, onMounted, onUnmounted } from "vue";
import FaIconButton from "@/components/widget/fa-icon-button/index.vue";
import FaIconButton from "@/components/others/fa-icon-button/index.vue";
defineOptions({ name: "FaDrawer", inheritAttrs: false });
@@ -75,8 +75,9 @@
import ExcelJS from "exceljs";
import type { IContentConfig, IObject } from "@/components/modal/types";
import { useThrottleFn } from "@vueuse/core";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import { nextTick, reactive, computed } from "vue";
defineOptions({ name: "FaExportDialog", inheritAttrs: false });
@@ -76,7 +76,8 @@
<script lang="ts" setup>
import { Download, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import type { UploadUserFile } from "element-plus";
import { ref, reactive } from "vue";
import type { IContentConfig, IObject } from "@/components/modal/types";
@@ -124,7 +124,7 @@ defineOptions({ name: "FaAiAssistant" });
import { resolveIconForFaSvgIcon } from "@utils";
import { nextTick, onBeforeUnmount, onMounted, watch, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
import { useSettingsStore } from "@stores";
import { AiChatAPI, ChatSession, ChatSessionDetail } from "@/api/module_ai/chat";
@@ -42,7 +42,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
interface Comment {
id: number;
@@ -9,6 +9,7 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { DocumentCopy } from "@element-plus/icons-vue";
defineOptions({
name: "FaCopyButton",
@@ -90,6 +90,16 @@
<script setup lang="ts">
defineOptions({ name: "FaDescriptions" });
defineSlots<{
default(props: object): any;
title(props: object): any;
[slotName: string]: (props: {
item: DescriptionsItem;
value: unknown;
row: Record<string, unknown> | null;
}) => any;
}>();
import { computed, useAttrs } from "vue";
import { useNamespace } from "element-plus/es/hooks/use-namespace/index";
@@ -28,7 +28,7 @@ import { ComponentSize } from "@/enums/settings/layout.enum";
import { useAppStore } from "@stores";
import { resolveIconForFaSvgIcon } from "@utils";
import { computed } from "vue";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
const { t } = useI18n();
const sizeOptions = computed(() => {
@@ -154,7 +154,7 @@ defineSlots<{
import { ref, reactive, computed } from "vue";
import { useResizeObserver } from "@vueuse/core";
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
// 对象类型
export type IObject = Record<string, any>;
@@ -79,9 +79,9 @@
defineOptions({ name: "FaUpload" });
import { ref, watch } from "vue";
import { ElMessage } from "@/utils/message";
import { ElMessage } from "element-plus";
import type { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
import { CircleCloseFilled } from "@element-plus/icons-vue";
import { CircleCloseFilled, Plus } from "@element-plus/icons-vue";
import ParamsAPI from "@/api/module_system/params";
import { dataURLToFile } from "@utils";
@@ -52,7 +52,7 @@
<div
v-for="item in tableSizeOptions"
:key="item.value"
class="table-size-btn-item [&_.el-dropdown-menu__item]:mb-[3px]! last:[&_.el-dropdown-menu__item]:mb-0!"
class="table-size-btn-item [&_.el-dropdown-menu__item]:mb-0.75! last:[&_.el-dropdown-menu__item]:mb-0!"
>
<ElDropdownItem
:key="item.value"
@@ -149,7 +149,7 @@
<FaSvgIcon icon="ri:settings-line" />
</div>
</template>
<div class="flex min-w-[200px] flex-col gap-2">
<div class="flex min-w-50 flex-col gap-2">
<ElCheckbox v-model="isZebra" :value="true">
{{ t("table.zebra") }}
</ElCheckbox>
@@ -176,6 +176,7 @@ import { TableSizeEnum } from "@/enums/formEnum";
import { useTableStore } from "@stores";
import { VueDraggable } from "vue-draggable-plus";
import { useI18n } from "vue-i18n";
import type { ColumnOption } from "@/types/component";
defineOptions({ name: "FaTableHeader" });
// 显式声明插槽类型
@@ -140,9 +140,15 @@ import { useTableHeight } from "@/hooks/core/useTableHeight";
import { useWindowSize } from "@vueuse/core";
import { VueDraggable } from "vue-draggable-plus";
import { MOBILE_BREAKPOINT } from "@utils/constants/definitions";
import type { ColumnOption } from "@/types/component";
defineOptions({ name: "FaTable" });
defineSlots<{
default(props: object): any;
[slotName: string]: (props: Record<string, any>) => any;
}>();
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// H5 ↔ 桌面切换时强制重建 ElTable,使列宽 / formatter 重新计算
@@ -1,450 +0,0 @@
/**
* 登录根布局
* - 顶栏 fixed 透明叠在页面上(不占一条实体顶区);仅插画列 / 表单区随布局切换
* - left / right:插画列与表单列横向排列(约 65% / 35%)
* - center:全宽 + 视口级背景;登录卡居中、阴影高亮
*/
.login-auth-split {
display: flex;
min-width: 0;
min-height: 0;
}
.login-auth-split__col--illustration {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
.login-auth-split__col--form {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
/* 横向:插画 ~65%,表单列占剩余 */
.login-auth-split--left,
.login-auth-split--right {
flex-direction: row;
}
.login-auth-split--left .login-auth-split__col--illustration,
.login-auth-split--right .login-auth-split__col--illustration {
flex: 0 0 65%;
}
.login-auth-split--left .login-auth-split__col--form,
.login-auth-split--right .login-auth-split__col--form {
flex: 1 1 0;
min-width: 0;
}
.login-auth-split--left {
flex-direction: row-reverse;
}
@media only screen and (width <= 1600px) {
.login-auth-split--left .login-auth-split__col--illustration,
.login-auth-split--right .login-auth-split__col--illustration {
flex: 0 0 60%;
}
}
/* 居中模式:仅一列表单壳占满屏宽 */
.login-auth-split--center {
flex-direction: column;
}
.login-auth-split--center .login-auth-split__col--form {
flex: 1 1 auto;
width: 100%;
min-height: 0;
background-color: transparent;
}
/* 顶栏与页脚之间的区域:纵向居中承载登录卡 */
.login-auth-split--center .login-page-panel__scroll.login-page-panel__scroll--centered {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
/* 盖过 Tailwind overflow-y-auto:仅卡片内滚动,外层不出滚动条 */
overflow: hidden;
}
.login-page-panel__scroll--centered .login-panel-align-row {
flex: 0 1 auto;
justify-content: center;
width: 100%;
min-height: 0;
max-height: 100%;
}
/* 悬浮卡片:玻璃拟态,圆角透明 */
.login-auth-split--center .auth-right-wrap {
width: min(480px, calc(100vw - 2rem)) !important;
max-width: min(480px, calc(100vw - 2rem));
height: auto;
max-height: calc(100dvh - 8rem);
padding: 1.25rem 1.75rem;
overflow: hidden auto;
background: color-mix(in srgb, var(--el-bg-color) 72%, transparent);
border: 1px solid color-mix(in srgb, var(--el-border-color-lighter) 60%, transparent);
border-radius: 20px;
box-shadow:
0 1px 8px rgb(15 23 42 / 5%),
0 0 16px color-mix(in srgb, var(--el-color-primary) 6%, transparent);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
.login-auth-split--center .auth-right-wrap .form {
height: auto;
min-height: 0;
padding-top: 0;
padding-bottom: 0;
}
html.dark .login-auth-split--center .auth-right-wrap {
background: color-mix(in srgb, var(--el-bg-color) 55%, transparent);
border-color: rgb(255 255 255 / 8%);
box-shadow:
0 1px 8px rgb(0 0 0 / 18%),
0 0 16px color-mix(in srgb, var(--el-color-primary) 10%, transparent);
}
/* 居中模式下页脚弱化,减少「分区后台」感 */
.login-page-footer--floating-layout {
border-top-color: color-mix(in srgb, var(--el-border-color-lighter) 55%, transparent);
}
html.dark .login-page-footer--floating-layout {
border-top-color: rgb(255 255 255 / 8%);
}
/* 居中模式 footer 单行展示 */
.login-auth-split--center .login-page-footer--floating-layout {
.login-footer-text {
white-space: nowrap;
}
.login-footer-row {
display: inline;
}
.login-footer-row + .login-footer-row {
margin-top: 0;
}
.login-footer-sep-center {
display: inline;
}
}
@media only screen and (width <= 1180px) {
.login-auth-split {
flex-direction: column !important;
}
.login-auth-split__col--illustration {
flex: 0 0 auto !important;
width: 100%;
}
.login-auth-split__col--form {
flex: 1 1 auto !important;
width: 100%;
min-height: 0;
}
/* 窄屏横排切竖排:恢复插画;居中模式本身无插画,不受影响 */
.login-auth-split--center .auth-right-wrap {
width: min(420px, calc(100vw - 1.5rem)) !important;
max-width: min(420px, calc(100vw - 1.5rem));
max-height: calc(100dvh - 6rem);
}
}
/* 授权页表单区域(左右分栏时) */
.auth-right-wrap {
position: relative;
flex-shrink: 0;
width: 440px;
height: 670px;
padding-top: 5px;
padding-bottom: 5px;
overflow: hidden;
animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
@media (width <= 640px) {
width: 100%;
height: auto;
max-height: calc(100vh - 10rem);
padding-right: 1.75rem;
padding-left: 1.75rem;
}
@media (width <= 768px) {
animation: none;
}
.form {
height: 100%;
padding-top: 10px;
padding-bottom: 40px;
}
.title {
font-size: 2.25rem;
font-weight: 600;
line-height: 2.5rem;
color: var(--fa-gray-900);
@media (width <= 768px) {
font-size: 1.875rem;
line-height: 2.25rem;
}
@media (width <= 640px) {
padding-top: 2.5rem;
}
}
.sub-title {
margin-top: 10px;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--fa-gray-600);
}
// 桌面端保持原始高度;H5 由 ConfigProvider size="small" 接管
@media (width >= 768px) {
.custom-height {
height: 40px;
}
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.login-page-panel {
.btn {
border-radius: 8px;
}
}
html.dark .login-page-panel {
.text-g-800 {
color: rgb(255 255 255 / 78%) !important;
}
}
.login-page-form {
margin-top: 1rem;
}
.login-page-form .el-form-item {
margin-bottom: 1.1rem;
}
.el-select__wrapper {
min-height: 42px;
}
// 桌面端按钮保持原始高度;H5 由 ConfigProvider size="small" 接管
@media (width >= 768px) {
.login-secondary-actions .login-secondary-btn {
height: 42px;
border-radius: 8px;
}
}
html.dark {
.login-page-form .el-input__wrapper {
background-color: rgb(255 255 255 / 6%) !important;
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
}
.login-page-form .el-input__inner {
color: rgb(255 255 255 / 92%);
}
.login-page-form .el-input__inner::placeholder {
color: rgb(255 255 255 / 35%);
}
.login-page-form .el-select .el-select__wrapper {
background-color: rgb(255 255 255 / 6%) !important;
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset !important;
}
.login-page-form .el-select__placeholder {
color: rgb(255 255 255 / 40%);
}
.login-page-form .el-select__selected-item {
color: rgb(255 255 255 / 92%);
}
.login-remember .el-checkbox__label {
color: rgb(255 255 255 / 65%);
}
.login-secondary-btn {
color: rgb(255 255 255 / 85%) !important;
background: transparent !important;
border-color: rgb(255 255 255 / 22%) !important;
border-radius: 8px;
&:hover {
color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9) !important;
border-color: var(--el-color-primary) !important;
}
}
@media (width >= 768px) {
.login-secondary-actions .login-secondary-btn {
height: 42px;
}
}
}
.login-page-footer {
margin-top: 0;
text-align: center;
border-top: 1px solid var(--el-border-color-lighter);
.login-footer-text {
line-height: 1.65;
color: var(--el-text-color-secondary) !important;
}
.login-footer-row {
white-space: nowrap;
}
.login-footer-row + .login-footer-row {
margin-top: 0.25rem;
}
.login-footer-sep-center {
display: none;
}
.login-page-footer__link {
color: var(--el-text-color-regular);
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: var(--el-color-primary);
}
}
.login-page-footer__sep {
margin: 0 0.35rem;
color: var(--el-border-color);
}
.login-page-footer__record {
display: inline-block;
color: var(--el-text-color-placeholder);
}
}
html.dark .login-page-footer {
border-top-color: rgb(255 255 255 / 8%);
.login-footer-text {
color: rgb(255 255 255 / 42%) !important;
}
.login-page-footer__link {
color: rgb(255 255 255 / 48%);
&:hover {
color: var(--el-color-primary);
}
}
.login-page-footer__sep {
color: rgb(255 255 255 / 25%);
}
.login-page-footer__record {
color: rgb(255 255 255 / 35%);
}
}
.login-mobile-otp-cell {
box-sizing: border-box;
flex: 1;
min-width: 0;
max-width: 48px;
height: 40px;
padding: 0;
font-size: 1rem;
font-weight: 500;
color: var(--el-text-color-primary);
text-align: center;
appearance: none;
outline: none;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: 8px;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:focus {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
}
}
.login-mobile-sms-btn {
border-radius: 8px;
}
.login-mobile-actions {
.el-button + .el-button {
margin-left: 0 !important;
}
.el-button {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
}
}
.login-qr-card {
box-sizing: border-box;
}
html.dark {
.login-mobile-otp-cell {
background: rgb(255 255 255 / 6%);
border-color: rgb(255 255 255 / 14%);
&:focus {
border-color: var(--el-color-primary);
}
}
}
@@ -1,83 +0,0 @@
<!-- 居中布局:与左侧栏一致的浅色底 + 中部淡化插画(不参与交互) -->
<template>
<div
class="login-center-backdrop"
:class="{ 'login-center-backdrop--viewport-fixed': viewportFixed }"
aria-hidden="true"
>
<div class="login-center-backdrop__bg" />
<div class="login-center-backdrop__hero-wrap">
<FaThemeSvg :src="loginIcon" size="100%" class="login-center-backdrop__hero" />
</div>
</div>
</template>
<script setup lang="ts">
import loginIcon from "@fa_imgs/background.svg";
defineOptions({ name: "LoginCenterBackdrop" });
interface Props {
/** 铺满视口并置于底层(与固定顶栏配合) */
viewportFixed?: boolean;
}
withDefaults(defineProps<Props>(), { viewportFixed: false });
</script>
<style scoped lang="scss">
.login-center-backdrop {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.login-center-backdrop--viewport-fixed {
position: fixed;
inset: 0;
z-index: 0;
}
.login-center-backdrop__bg {
position: absolute;
inset: 0;
background-color: color-mix(
in srgb,
var(--el-color-primary-light-9) 100%,
var(--default-box-color)
);
}
.login-center-backdrop__hero-wrap {
position: absolute;
top: 12%;
left: 50%;
width: min(420px, 52vw);
height: min(340px, 38vh);
opacity: 0.22;
transform: translateX(-50%);
}
.login-center-backdrop__hero {
width: 100%;
height: 100%;
}
html.dark .login-center-backdrop__bg {
background-color: color-mix(in srgb, var(--el-color-primary-light-9) 60%, #070707);
}
html.dark .login-center-backdrop__hero-wrap {
opacity: 0.14;
}
@media only screen and (width <= 1180px) {
.login-center-backdrop__hero-wrap {
top: 10%;
width: min(320px, 72vw);
height: min(240px, 28vh);
}
}
</style>
@@ -1,692 +0,0 @@
<!-- 登录、注册、忘记密码左侧背景 -->
<template>
<div class="login-left-view">
<div v-if="!hideTopBranding" class="logo">
<FaLogo class="icon" size="46" :src="webLogoSrc" />
<div class="logo-title-wrap">
<div class="logo-title-inline">
<h1 class="title">{{ siteTitle }}</h1>
<div class="logo-version-badge" :title="displayVersion">
<span class="logo-version-pill">{{ displayVersion }}</span>
</div>
</div>
</div>
</div>
<div class="left-img">
<FaThemeSvg :src="loginIcon" size="100%" />
</div>
<div class="text-wrap">
<h1>{{ configStore.configData?.login_title?.config_value || $t("login.leftView.title") }}</h1>
<p>
{{ configStore.configData?.login_subtitle?.config_value || $t("login.leftView.subTitle") }}
</p>
</div>
<!-- 几何装饰元素 -->
<div class="geometric-decorations">
<!-- 基础几何形状 -->
<div
class="geo-element circle-outline animate-fade-in-up"
:style="'animation-delay: 0s'"
></div>
<div
class="geo-element square-rotated animate-fade-in-left"
:style="'animation-delay: 0s'"
></div>
<div
class="geo-element circle-small animate-fade-in-up"
:style="'animation-delay: 0.3s'"
></div>
<div
class="geo-element square-bottom-right animate-fade-in-right"
:style="'animation-delay: 0s'"
></div>
<!-- 背景泡泡 -->
<div class="geo-element bg-bubble animate-scale-in" :style="'animation-delay: 0.5'"></div>
<!-- 太阳/月亮 -->
<div
class="geo-element circle-top-right animate-fade-in-down"
:style="'animation-delay: 0.5'"
@click="themeAnimation"
></div>
<!-- 装饰点 -->
<div
class="geo-element dot dot-top-left animate-bounce-in"
:style="'animation-delay: 0s'"
></div>
<div
class="geo-element dot dot-top-right animate-bounce-in"
:style="'animation-delay: 0s'"
></div>
<div
class="geo-element dot dot-center-right animate-bounce-in"
:style="'animation-delay: 0s'"
></div>
<!-- 叠加方块组 -->
<div class="squares-group">
<i
class="geo-element square square-blue animate-fade-in-left-rotated-blue"
:style="'animation-delay: 0.2s'"
></i>
<i
class="geo-element square square-pink animate-fade-in-left-rotated-pink"
:style="'animation-delay: 0.4s'"
></i>
<i
class="geo-element square square-purple animate-fade-in-left-no-rotation"
:style="'animation-delay: 0.6s'"
></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import AppConfig from "@/config";
import loginIcon from "@fa_imgs/background.svg";
import { useConfigStore } from "@stores";
import { themeAnimation } from "@utils";
defineOptions({ name: "FaLoginLeftView" });
interface Props {
hideContent?: boolean;
/** 顶栏已展示 Logo/标题/版本时隐藏左侧重复顶栏 */
hideTopBranding?: boolean;
}
withDefaults(defineProps<Props>(), {
hideContent: false,
hideTopBranding: false,
});
const configStore = useConfigStore();
/** 接口 logo_url,空则 FaLogo 内置默认图 */
const webLogoSrc = computed(
() => configStore.configData.logo_url?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.sys_name?.config_value?.trim() || AppConfig.systemInfo.name
);
const DEFAULT_APP_VERSION = "3.0.0";
const displayVersion = computed(() => {
const raw = configStore.configData.version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
</script>
<style lang="scss" scoped>
// 颜色变量定义
$primary-light-7: var(--el-color-primary-light-7);
$primary-light-8: var(--el-color-primary-light-8);
$primary-light-9: var(--el-color-primary-light-9);
$primary-base: var(--el-color-primary);
$main-bg: var(--default-box-color);
// 混合颜色函数
$bg-mix-light-9: color-mix(in srgb, $primary-light-9 100%, $main-bg);
$bg-mix-light-8: color-mix(in srgb, $primary-light-8 80%, $main-bg);
$bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
.login-left-view {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 15px;
overflow: hidden;
background-color: $bg-mix-light-9;
.logo {
position: relative;
z-index: 100;
display: flex;
align-items: center;
.logo-title-wrap {
flex: 1;
min-width: 0;
margin-left: 10px;
}
/** 标题与版本:垂直居中对齐(上下在同一水平中线),长标题可自然换行 */
.logo-title-inline {
display: flex;
flex-flow: row wrap;
gap: 0.45rem;
align-items: center;
min-width: 0;
max-width: 100%;
}
.title {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.25rem);
font-weight: 600;
line-height: 1.35;
color: var(--el-text-color-primary);
letter-spacing: -0.02em;
}
.logo-version-badge {
flex-shrink: 0;
}
.logo-version-pill {
display: inline-block;
padding: 0.28rem 0.6rem;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
color: var(--el-color-primary);
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--el-color-primary) 11%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 28%, transparent);
border-radius: 999px;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
transition:
border-color 0.2s ease,
background 0.2s ease;
}
}
.left-img {
position: absolute;
inset: 0 0 10.5%;
z-index: 10;
width: 40%;
margin: auto;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.text-wrap {
position: absolute;
bottom: 80px;
width: 100%;
text-align: center;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
h1 {
font-size: 24px;
font-weight: 400;
color: var(--fa-gray-900) !important;
}
p {
margin-top: 10px;
font-size: 14px;
color: var(--fa-gray-600) !important;
}
}
.geometric-decorations {
.geo-element {
position: absolute;
opacity: 0;
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
animation-fill-mode: forwards;
}
// 动画 mixin
@mixin fadeAnimation($direction: "", $rotation: 0deg) {
from {
opacity: 0;
@if $direction == "up" {
transform: translateY(30px) rotate($rotation);
} @else if $direction == "down" {
transform: translateY(-30px) rotate($rotation);
} @else if $direction == "left" {
transform: translateX(-30px) rotate($rotation);
} @else if $direction == "right" {
transform: translateX(30px) rotate($rotation);
}
}
to {
opacity: 1;
@if $direction == "up" or $direction == "down" {
transform: translateY(0) rotate($rotation);
} @else {
transform: translateX(0) rotate($rotation);
}
}
}
// 动画定义
@keyframes fadeInUp {
@include fadeAnimation("up");
}
@keyframes fadeInDown {
@include fadeAnimation("down");
}
@keyframes fadeInLeft {
@include fadeAnimation("left");
}
@keyframes fadeInLeftRotated {
@include fadeAnimation("left", -25deg);
}
@keyframes fadeInRight {
@include fadeAnimation("right");
}
@keyframes fadeInRightRotated {
@include fadeAnimation("right", 45deg);
}
@keyframes fadeInLeftRotatedBlue {
@include fadeAnimation("left", -10deg);
}
@keyframes fadeInLeftRotatedPink {
@include fadeAnimation("left", 10deg);
}
@keyframes fadeInLeftNoRotation {
@include fadeAnimation("left");
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes lineGrow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// 动画类
.animate-fade-in-up {
animation-name: fadeInUp;
}
.animate-fade-in-down {
animation-name: fadeInDown;
}
.animate-fade-in-left {
animation-name: fadeInLeft;
}
.animate-fade-in-right {
animation-name: fadeInRight;
}
.animate-scale-in {
animation-name: scaleIn;
animation-duration: 1.2s;
}
.animate-bounce-in {
animation-name: bounceIn;
animation-duration: 0.6s;
}
.animate-fade-in-left-rotated-blue {
animation-name: fadeInLeftRotatedBlue;
}
.animate-fade-in-left-rotated-pink {
animation-name: fadeInLeftRotatedPink;
}
.animate-fade-in-left-no-rotation {
animation-name: fadeInLeftNoRotation;
}
// 基础几何形状
.circle-outline {
top: 10%;
left: 25%;
width: 42px;
height: 42px;
border: 2px solid $primary-light-8;
border-radius: 50%;
}
.square-rotated {
top: 50%;
left: 16%;
width: 60px;
height: 60px;
background-color: $bg-mix-light-8;
&.animate-fade-in-left {
animation-name: fadeInLeftRotated;
}
}
.circle-small {
bottom: 26%;
left: 30%;
width: 18px;
height: 18px;
background-color: $primary-light-8;
border-radius: 50%;
}
// 太阳/月亮效果
.circle-top-right {
top: 3%;
right: 3%;
z-index: 100;
width: 50px;
height: 50px;
cursor: pointer;
background: $bg-mix-light-7;
border-radius: 50%;
box-shadow: 0 0 20px rgba(#fffc00, 0.5);
transition: all 0.3s;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
content: "";
background: linear-gradient(to right, #fcbb04, #fffc00);
border-radius: 50%;
opacity: 1;
transform: translate(-50%, -50%);
transition: all 0.5s;
}
&:hover {
box-shadow: 0 0 36px #fffc00;
transform: scale(1.08);
}
}
.square-bottom-right {
right: 10%;
bottom: 10%;
width: 50px;
height: 50px;
background-color: $primary-light-8;
&.animate-fade-in-right {
animation-name: fadeInRightRotated;
}
}
// 背景泡泡
.bg-bubble {
top: -120px;
right: -120px;
width: 360px;
height: 360px;
background-color: $bg-mix-light-8;
border-radius: 50%;
}
// 装饰点
.dot {
width: 14px;
height: 14px;
background-color: $primary-light-7;
border-radius: 50%;
&.dot-top-left {
top: 140px;
left: 100px;
}
&.dot-top-right {
top: 140px;
right: 120px;
}
&.dot-center-right {
top: 46%;
right: 22%;
background-color: $primary-light-8;
}
}
// 叠加方块组
.squares-group {
position: absolute;
bottom: 18px;
left: 20px;
width: 140px;
height: 140px;
pointer-events: none;
.square {
position: absolute;
display: block;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(64 87 167 / 12%);
&.square-blue {
top: 12px;
left: 30px;
z-index: 2;
width: 50px;
height: 50px;
background-color: rgb(from $primary-base r g b / 30%);
}
&.square-pink {
top: 30px;
left: 48px;
z-index: 1;
width: 70px;
height: 70px;
background-color: rgb(from $primary-base r g b / 15%);
}
&.square-purple {
top: 66px;
left: 86px;
z-index: 3;
width: 32px;
height: 32px;
background-color: rgb(from $primary-base r g b / 45%);
}
}
// 装饰线条
&::after {
position: absolute;
top: 86px;
left: 72px;
width: 80px;
height: 1px;
content: "";
background: linear-gradient(90deg, var(--el-color-primary-light-6), transparent);
opacity: 0;
transform: rotate(50deg);
animation: lineGrow 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 1.2s;
}
}
}
@media only screen and (width <= 1600px) {
.text-wrap {
bottom: 40px;
}
}
@media only screen and (width <= 1180px) {
width: auto;
height: auto;
padding: 0;
// 隐藏背景和其他内容,只保留 logo
background: transparent;
.left-img,
.text-wrap,
.geometric-decorations {
display: none;
}
.logo {
display: none;
}
}
}
// 暗色主题
.dark .login-left-view {
background-color: color-mix(in srgb, $primary-light-9 60%, #070707);
@media only screen and (width <= 1180px) {
background: transparent;
}
.geometric-decorations {
// 月亮效果
.circle-top-right {
background-color: #f5f3d0;
box-shadow: 0 0 30px rgba(#fffc00, 0.35);
rotate: -48deg;
transition: all 0.3s ease-in-out 0.1s;
&::after {
opacity: 0;
}
&::before {
position: absolute;
top: 0;
left: 15px;
width: 50px;
height: 50px;
content: "";
background-color: color-mix(in srgb, $primary-light-9 60%, #070707);
border-radius: 50%;
transition: all 0.3s ease-in-out;
}
&:hover {
background-color: #fffc00;
box-shadow: 0 0 45px rgba(#fffc00, 0.6);
&::before {
left: 22px;
}
&::after {
opacity: 0;
}
}
}
.bg-bubble {
background-color: $bg-mix-light-9;
}
// 其他元素颜色调整
.square-rotated {
background-color: $bg-mix-light-9;
}
.circle-small,
.dot {
background-color: $primary-light-8;
}
.square-bottom-right {
background-color: $primary-light-9;
}
.dot.dot-top-right {
background-color: $primary-light-8;
}
}
// 方块组暗色调整
.squares-group {
.square {
box-shadow: none;
&.square-blue {
background-color: rgb(from $primary-base r g b / 18%);
}
&.square-pink {
background-color: rgb(from $primary-base r g b / 10%);
}
&.square-purple {
background-color: rgb(from $primary-base r g b / 20%);
}
}
&::after {
background: linear-gradient(90deg, $primary-light-8, transparent);
}
}
}
</style>
@@ -1,36 +0,0 @@
import { ref, watch } from "vue";
/** 左侧插画背景与右侧登录区的分栏比例 */
export type LoginPanelAlign = "left" | "center" | "right";
const STORAGE_KEY = "login-panel-align";
function readInitial(): LoginPanelAlign {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === "left" || v === "center" || v === "right") return v;
} catch {
/* ignore */
}
/** 默认:插画在左、表单在右 */
return "right";
}
/**
* 登录页布局(本地持久化)
* - left/right:交换插画与表单列左右位置(列宽约 65%/35%)
* - center:隐藏插画,全宽背景 + 440px 卡片在顶栏与页脚之间居中悬浮
*/
export function useLoginPanelAlign() {
const panelAlign = ref<LoginPanelAlign>(readInitial());
watch(panelAlign, (v) => {
try {
localStorage.setItem(STORAGE_KEY, v);
} catch {
/* ignore */
}
});
return { panelAlign };
}
@@ -1,203 +0,0 @@
<!-- 账号密码登录表单(含快捷账号、验证码、滑块) -->
<template>
<div>
<ElForm
ref="formRef"
:model="loginForm"
:rules="rules"
:key="formKey"
class="login-page-form"
:validate-on-rule-change="false"
@keyup.enter="$emit('submit')"
>
<ElFormItem>
<ElSelect
:model-value="demoAccountKey"
class="custom-height w-full"
:placeholder="$t('login.quickSelectAccount')"
@update:model-value="$emit('setupAccount', $event as AccountKey)"
>
<ElOption
v-for="account in accounts"
:key="account.key"
:label="account.label"
:value="account.key"
>
<span>{{ account.label }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem prop="username">
<ElInput
class="custom-height"
v-model.trim="loginForm.username"
clearable
:placeholder="$t('login.placeholder.username')"
>
<template #prefix>
<ElIcon><User /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="password">
<ElInput
class="custom-height"
v-model.trim="loginForm.password"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="onPasswordKeyup"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<div class="login-form-tail flex flex-col gap-[1.1rem]">
<div class="relative pb-3">
<div
class="relative z-2 overflow-hidden select-none rounded-lg border border-transparent transition duration-300"
:class="{ 'border-[#FF4E4F]!': !isPassing && isClickPass }"
>
<FaDragVerify
ref="dragVerifyRef"
v-model:value="isPassing"
:text="$t('login.sliderText')"
:text-color="dragVerifyTextColor"
:success-text="$t('login.sliderSuccessText')"
progress-bar-bg="var(--el-color-success)"
:background="isDark ? '#26272F' : 'var(--el-border-color-light)'"
handler-bg="var(--default-box-color)"
/>
</div>
<p
class="absolute top-0 z-1 mt-2 px-px text-xs text-[#f56c6c] transition duration-300"
:class="{ 'translate-y-10': !isPassing && isClickPass }"
>
{{ $t("login.placeholder.slider") }}
</p>
</div>
<div class="login-options-row flex items-center justify-between text-sm">
<ElCheckbox v-model="loginForm.remember" class="login-remember">
{{ $t("login.rememberPwd") }}
</ElCheckbox>
<ElLink
type="primary"
underline="never"
class="inline-flex items-center text-sm leading-[inherit]!"
@click="$emit('forget')"
>
{{ $t("login.forgetPwd") }}
</ElLink>
</div>
<div>
<ElButton
class="h-11 w-full rounded-lg! text-base font-medium"
type="primary"
:loading="loading"
v-ripple
@click="$emit('submit')"
>
{{ $t("login.btnText") }}
</ElButton>
</div>
<div class="login-secondary-actions grid grid-cols-2 gap-2">
<ElButton class="login-secondary-btn" plain @click="$emit('openMobile')">
{{ $t("login.mobileLogin") }}
</ElButton>
<ElButton class="login-secondary-btn" plain @click="$emit('openQr')">
{{ $t("login.qrLogin") }}
</ElButton>
</div>
</div>
</ElForm>
<FaLoginThirdPartySection @oauth="$emit('oauth', $event)" />
<FaLoginAuthLinkRow
:hint="$t('login.noAccount')"
:link-text="$t('login.register')"
@link="$emit('register')"
/>
</div>
</template>
<script setup lang="ts">
import { Lock, User } from "@element-plus/icons-vue";
import type { CaptchaInfo, LoginFormData } from "@/api/module_system/auth";
import type { FormRules } from "element-plus";
import type { Account, AccountKey } from "@views/module_system/auth/login/types";
const loginForm = defineModel<LoginFormData>("loginForm", { required: true });
defineOptions({ name: "FaLoginAccountForm" });
interface Props {
rules: FormRules;
captchaState: CaptchaInfo;
codeLoading: boolean;
demoAccountKey: AccountKey;
accounts: Account[];
formKey: number | string;
isDark: boolean;
dragVerifyTextColor: string;
loading: boolean;
}
withDefaults(defineProps<Props>(), {});
const isPassing = defineModel<boolean>("isPassing", { required: true });
const isClickPass = defineModel<boolean>("isClickPass", { required: true });
interface Emits {
submit: [];
setupAccount: [key: AccountKey];
getCaptcha: [];
openMobile: [];
openQr: [];
forget: [];
register: [];
oauth: [provider: "wechat" | "qq" | "github" | "gitee"];
}
const emit = defineEmits<Emits>();
const formRef = ref();
const dragVerifyRef = ref<{ reset?: () => void } | null>(null);
const isCapsLock = ref(false);
function onPasswordKeyup(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
defineExpose({
validate: () => formRef.value?.validate?.(),
clearValidate: () => formRef.value?.clearValidate?.(),
resetDragVerify: () => dragVerifyRef.value?.reset?.(),
});
</script>
<style scoped lang="scss">
@use "../fa-login";
.el-select.custom-height {
:deep(.el-select__wrapper) {
height: 40px;
}
}
</style>
@@ -1,126 +0,0 @@
<!-- 重置密码 / 忘记密码 -->
<template>
<div>
<ElForm
ref="formRef"
:model="forgetForm"
:rules="forgetRules"
:key="formKey"
class="login-page-form mt-4"
@keyup.enter="$emit('submit')"
>
<ElFormItem prop="username">
<ElInput
v-model.trim="forgetForm.username"
class="custom-height"
clearable
:placeholder="$t('login.placeholder.username')"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><User /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="new_password">
<ElInput
v-model.trim="forgetForm.new_password"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="confirmPassword">
<ElInput
v-model.trim="forgetForm.confirmPassword"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<div class="mt-6">
<ElButton
class="h-11 w-full min-w-0 rounded-lg! text-base font-medium"
type="primary"
:loading="forgetLoading"
v-ripple
@click="$emit('submit')"
>
{{ $t("common.confirm") }}
</ElButton>
</div>
</ElForm>
<FaLoginAuthLinkRow
:hint="$t('login.thinkOfPasswd')"
:link-text="$t('login.backLoginBtnText')"
@link="$emit('toLogin')"
/>
</div>
</template>
<script setup lang="ts">
import { Lock, User } from "@element-plus/icons-vue";
import type { ForgetPasswordForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus";
const forgetForm = defineModel<ForgetPasswordForm>("forgetForm", { required: true });
defineOptions({ name: "FaLoginForgetPanel" });
interface Props {
forgetRules: FormRules<ForgetPasswordForm>;
formKey: number | string;
forgetLoading: boolean;
}
withDefaults(defineProps<Props>(), {});
interface Emits {
submit: [];
toLogin: [];
}
const emit = defineEmits<Emits>();
const formRef = ref();
const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
defineExpose({
validate: () => formRef.value?.validate?.(),
clearValidate: () => formRef.value?.clearValidate?.(),
});
</script>
<style scoped lang="scss">
@use "../fa-login";
</style>
@@ -1,152 +0,0 @@
<!-- 手机号登录(无后端接口,演示交互) -->
<template>
<div>
<div class="login-page-form login-mobile-flow">
<div class="mb-[1.1rem]">
<ElInput
v-model.trim="mobileForm.phone"
class="custom-height"
maxlength="11"
inputmode="numeric"
clearable
:placeholder="$t('login.mobilePhonePlaceholder')"
@keyup.enter="submitMobileLogin"
>
<template #prefix>
<ElIcon><Iphone /></ElIcon>
</template>
</ElInput>
</div>
<div class="login-mobile-code-row mb-[1.1rem] flex items-stretch gap-2 sm:gap-3">
<div class="flex min-w-0 flex-1">
<ElInputOtp
v-model="otpCode"
class="w-full"
:length="6"
size="large"
inputmode="numeric"
autofocus
@finish="onOtpFilled"
/>
</div>
<ElButton
class="login-mobile-sms-btn h-10 shrink-0 px-3 sm:px-4"
plain
:disabled="smsCountdown > 0"
@click="sendSmsCodeMock"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : $t("login.getSmsCode") }}
</ElButton>
</div>
<div class="login-mobile-actions flex w-full min-w-0 flex-col items-stretch gap-3">
<ElButton
type="primary"
class="h-11 w-full min-w-0 rounded-lg! text-base font-medium"
v-ripple
@click="submitMobileLogin"
>
{{ $t("login.btnText") }}
</ElButton>
<ElButton
class="login-secondary-btn h-11 w-full min-w-0 rounded-lg! text-base font-medium"
plain
@click="$emit('back')"
>
{{ $t("login.backToAccountLogin") }}
</ElButton>
</div>
</div>
<FaLoginAuthLinkRow
:hint="$t('login.noAccount')"
:link-text="$t('login.register')"
@link="$emit('register')"
/>
</div>
</template>
<script setup lang="ts">
import { Iphone } from "@element-plus/icons-vue";
import { ElMessage } from "@/utils/message";
defineOptions({ name: "FaLoginMobilePanel" });
interface Emits {
back: [];
register: [];
}
defineEmits<Emits>();
const { t } = useI18n();
const mobileForm = reactive({
phone: "",
});
const otpCode = ref("");
const smsCountdown = ref(0);
let smsTimerId: number | null = null;
function clearSmsTimer() {
if (smsTimerId != null) {
clearInterval(smsTimerId);
smsTimerId = null;
}
}
function resetMobileLoginUi() {
mobileForm.phone = "";
otpCode.value = "";
smsCountdown.value = 0;
clearSmsTimer();
}
defineExpose({ resetMobileLoginUi });
function sendSmsCodeMock() {
const phone = mobileForm.phone.trim();
if (!/^1\d{10}$/.test(phone)) {
ElMessage.warning(t("login.message.mobile.invalid"));
return;
}
if (smsCountdown.value > 0) return;
ElMessage.success(t("login.smsCodeSentMock"));
smsCountdown.value = 60;
clearSmsTimer();
smsTimerId = window.setInterval(() => {
smsCountdown.value--;
if (smsCountdown.value <= 0) {
clearSmsTimer();
}
}, 1000);
}
function onOtpFilled(value: string) {
otpCode.value = value;
}
function submitMobileLogin() {
const phone = mobileForm.phone.trim();
if (!/^1\d{10}$/.test(phone)) {
ElMessage.warning(t("login.message.mobile.invalid"));
return;
}
if (otpCode.value.length !== 6) {
ElMessage.warning(t("login.smsCodeRequired"));
return;
}
ElMessage.info(t("login.mobileLoginPending"));
}
onBeforeUnmount(() => {
clearSmsTimer();
});
</script>
<style scoped lang="scss">
@use "../fa-login";
</style>
@@ -1,76 +0,0 @@
<!-- 扫码登录(无后端会话,演示二维码与交互) -->
<template>
<div>
<div class="login-page-form login-qr-flow flex w-full min-w-0 flex-col items-center">
<div
class="login-qr-card flex w-full max-w-[280px] flex-col items-center rounded-2xl bg-white p-6 shadow-[0_8px_24px_rgb(0_0_0_/12%)] dark:shadow-[0_8px_28px_rgb(0_0_0_/45%)]"
>
<QrcodeVue class="block" :value="qrPayload" v-bind="qrCodeRenderOptions" />
</div>
<p
class="login-qr-hint mt-4 max-w-[280px] text-center text-sm leading-relaxed text-(--el-text-color-secondary)"
>
{{ $t("login.qrLoginHint") }}
</p>
<div class="login-mobile-actions mt-8 w-full min-w-0">
<ElButton
class="login-secondary-btn h-11 w-full min-w-0 rounded-lg! text-base font-medium"
plain
@click="$emit('back')"
>
{{ $t("login.backToAccountLogin") }}
</ElButton>
</div>
</div>
<FaLoginAuthLinkRow
:hint="$t('login.noAccount')"
:link-text="$t('login.register')"
@link="$emit('register')"
/>
</div>
</template>
<script setup lang="ts">
import QrcodeVue from "qrcode.vue";
import type { Level, RenderAs } from "qrcode.vue";
defineOptions({ name: "FaLoginQrPanel" });
interface Emits {
back: [];
register: [];
}
defineEmits<Emits>();
/** 集中配置 + H 级纠错 + SVG(矢量清晰、易对齐容器) */
const qrCodeRenderOptions: {
size: number;
level: Level;
renderAs: RenderAs;
margin: number;
background: string;
foreground: string;
} = {
size: 220,
level: "H",
renderAs: "svg",
margin: 0,
background: "#ffffff",
foreground: "#000000",
};
/** 演示用载荷;接入后端后替换为会话 URL / ticket */
const qrPayload = computed(() => {
if (typeof window === "undefined") return "https://example.com/login";
const { origin, pathname } = window.location;
return `${origin}${pathname}?qrLogin=demo`;
});
</script>
<style scoped lang="scss">
@use "../fa-login";
</style>
@@ -1,161 +0,0 @@
<!-- 注册表单 -->
<template>
<div>
<ElForm
ref="formRef"
:model="registerForm"
:rules="registerRules"
:key="formKey"
class="login-page-form"
@keyup.enter="$emit('submit')"
>
<ElFormItem prop="username">
<ElInput
class="custom-height"
v-model.trim="registerForm.username"
clearable
:placeholder="$t('login.placeholder.username')"
>
<template #prefix>
<ElIcon><User /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="password">
<ElInput
class="custom-height"
v-model.trim="registerForm.password"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="confirmPassword">
<ElInput
class="custom-height"
v-model.trim="registerForm.confirmPassword"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<ElFormItem v-if="showEmail" prop="email">
<ElInput
class="custom-height"
v-model.trim="registerForm.email"
clearable
:placeholder="$t('login.placeholder.email')"
>
<template #prefix>
<ElIcon><Message /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElFormItem>
<div class="flex flex-wrap items-center gap-2">
<ElCheckbox v-model="registerAgreementReadModel">
{{ $t("login.agree") }}
</ElCheckbox>
<ElLink
type="primary"
underline="never"
class="text-sm font-medium"
:href="userAgreementHref"
target="_blank"
rel="noopener noreferrer"
>
{{ $t("login.userAgreement") }}
</ElLink>
</div>
</ElFormItem>
<div class="mt-6">
<ElButton
class="h-11 w-full rounded-lg! text-base font-medium"
type="primary"
:loading="registerLoading"
v-ripple
@click="$emit('submit')"
>
{{ $t("login.register") }}
</ElButton>
</div>
</ElForm>
<FaLoginAuthLinkRow
:hint="$t('login.haveAccount')"
:link-text="$t('login.backLoginBtnText')"
@link="$emit('toLogin')"
/>
</div>
</template>
<script setup lang="ts">
import { Lock, Message, User } from "@element-plus/icons-vue";
import type { RegisterForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus";
const registerForm = defineModel<RegisterForm>("registerForm", { required: true });
defineOptions({ name: "FaLoginRegisterPanel" });
interface Props {
registerRules: FormRules<RegisterForm & { email: string }>;
formKey: number | string;
registerLoading: boolean;
userAgreementHref: string;
showEmail?: boolean;
}
withDefaults(defineProps<Props>(), { showEmail: false });
const registerAgreementReadModel = defineModel<boolean>("registerAgreementRead", {
required: true,
});
interface Emits {
submit: [];
toLogin: [];
}
const emit = defineEmits<Emits>();
const formRef = ref();
const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
defineExpose({
validate: () => formRef.value?.validate?.(),
clearValidate: () => formRef.value?.clearValidate?.(),
validateField: (prop: string | string[]) => formRef.value?.validateField?.(prop),
});
</script>
<style scoped lang="scss">
@use "../fa-login";
</style>
@@ -1,331 +0,0 @@
<!-- 授权页顶栏:左上 Logo / 标题 / 版本(固定),右上操作(固定);切换布局时仅下方主体变化 -->
<template>
<header
class="auth-top-bar pointer-events-none fixed left-0 right-0 top-0 z-100 flex items-center justify-between gap-3 bg-transparent px-5 py-4.5 md:gap-4 md:px-10"
>
<div class="pointer-events-auto flex min-w-0 flex-1 items-center gap-3">
<FaLogo class="icon shrink-0" size="46" :src="webLogoSrc" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h1 class="auth-top-bar__site-title">{{ siteTitle }}</h1>
<div class="logo-version-badge shrink-0" :title="displayVersion">
<span class="logo-version-pill">{{ displayVersion }}</span>
</div>
</div>
</div>
</div>
<div
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
>
<div class="color-picker-expandable relative flex items-center max-sm:hidden!">
<div
class="color-dots absolute right-0 rounded-full flex items-center gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
>
<div
v-for="(_color, index) in mainColors"
:key="_color"
class="color-dot relative size-5 cursor-pointer flex items-center justify-center rounded-full opacity-0"
:class="{ active: _color === systemThemeColor }"
:style="{ background: _color, '--index': index }"
@click="changeThemeColor(_color)"
>
<FaSvgIcon v-if="_color === systemThemeColor" icon="ri:check-fill" class="text-white" />
</div>
</div>
<div
class="btn palette-btn auth-top-bar__action relative z-2 h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
>
<FaSvgIcon icon="ri:palette-line" class="text-xl transition-colors duration-300" />
</div>
</div>
<ElDropdown
v-if="panelAlign != null"
@command="onPanelAlign"
popper-class="langDropDownStyle"
>
<div
class="btn layout-align-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
:title="$t('login.panelAlign.label')"
>
<FaSvgIcon
:icon="panelAlignTriggerIcon"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="opt in layoutAlignOptions" :key="opt.value" class="lang-btn-item">
<ElDropdownItem
:command="opt.value"
:class="{ 'is-selected': panelAlign === opt.value }"
>
<FaSvgIcon :icon="opt.icon" class="mr-2 text-base" />
<span class="menu-txt">{{ $t(opt.labelKey) }}</span>
<FaSvgIcon icon="ri:check-fill" class="text-base" v-if="panelAlign === opt.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElDropdown
v-if="shouldShowLanguage"
@command="changeLanguage"
popper-class="langDropDownStyle"
>
<div
class="btn language-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
>
<FaSvgIcon
icon="ri:translate-2"
class="text-[19px] text-g-800 transition-colors duration-300"
/>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
<ElDropdownItem
:command="lang.value"
:class="{ 'is-selected': locale === lang.value }"
>
<span class="menu-txt">{{ lang.label }}</span>
<FaSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<div
v-if="shouldShowThemeToggle"
class="btn theme-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
@click="themeAnimation"
>
<FaSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { LanguageEnum } from "@/enums/appEnum";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import { useSettingsStore, useUserStore, useConfigStore } from "@stores";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import { themeAnimation } from "@utils";
import { languageOptions } from "@/locales";
import AppConfig from "@/config";
import { LoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
defineOptions({ name: "AuthTopBar" });
const DEFAULT_APP_VERSION = "3.0.0";
interface Props {
/** 登录区表单水平对齐;未传入时不展示布局切换 */
panelAlign?: LoginPanelAlign | null;
}
const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:panelAlign": [value: LoginPanelAlign];
}
const emit = defineEmits<Emits>();
const layoutAlignOptions: {
value: LoginPanelAlign;
icon: string;
labelKey: string;
}[] = [
{ value: "left", icon: "ri:layout-left-2-line", labelKey: "login.panelAlign.left" },
{ value: "center", icon: "ri:layout-column-line", labelKey: "login.panelAlign.center" },
{ value: "right", icon: "ri:layout-right-2-line", labelKey: "login.panelAlign.right" },
];
/** 与当前选中项同一套 icon,避免触发器与菜单不一致 */
const panelAlignTriggerIcon = computed(() => {
const opt = layoutAlignOptions.find((o) => o.value === props.panelAlign);
return opt?.icon ?? "ri:layout-column-line";
});
function onPanelAlign(cmd: string) {
if (cmd === "left" || cmd === "center" || cmd === "right") {
emit("update:panelAlign", cmd);
}
}
const configStore = useConfigStore();
const settingStore = useSettingsStore();
const userStore = useUserStore();
const { isDark, systemThemeColor } = storeToRefs(settingStore);
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar();
const { locale } = useI18n();
const mainColors = AppConfig.systemMainColor;
/** 与 Element 主题主色同步,供调色盘图标与展开态使用 */
const themeColorForCss = computed(() => systemThemeColor.value);
const webLogoSrc = computed(
() => configStore.configData.logo_url?.config_value?.trim() || undefined
);
const siteTitle = computed(
() => configStore.configData.sys_name?.config_value?.trim() || AppConfig.systemInfo.name
);
const displayVersion = computed(() => {
const raw = configStore.configData.version?.config_value?.trim();
const ver = raw || DEFAULT_APP_VERSION;
return ver.startsWith("v") || ver.startsWith("V") ? ver : `v${ver}`;
});
const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return;
locale.value = lang;
userStore.setLanguage(lang);
};
const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return;
settingStore.setElementTheme(color);
settingStore.reload();
};
</script>
<style scoped>
.auth-top-bar__site-title {
max-width: min(52vw, 28rem);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: clamp(1rem, 2.2vw, 1.25rem);
font-weight: 600;
line-height: 1.35;
color: var(--el-text-color-primary);
letter-spacing: -0.02em;
white-space: nowrap;
}
.logo-version-pill {
display: inline-block;
padding: 0.28rem 0.6rem;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.15;
color: var(--el-color-primary);
letter-spacing: 0.02em;
background: color-mix(in srgb, var(--el-color-primary) 11%, transparent);
border: 1px solid color-mix(in srgb, var(--el-color-primary) 28%, transparent);
border-radius: 999px;
}
/* 右上角操作的整体衬底 */
.auth-top-bar-actions-panel {
background-color: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-lighter);
/* 胶囊形:左右两端为半圆弧 */
border-radius: 9999px;
box-shadow: 0 2px 12px rgb(0 0 0 / 6%);
backdrop-filter: blur(10px);
}
.dark .auth-top-bar-actions-panel {
background-color: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 12%);
box-shadow: 0 4px 20px rgb(0 0 0 / 35%);
}
/* 右上角三个操作按钮:悬浮抬升 + 浅底 + 图标随主色 */
.auth-top-bar__action {
border-radius: 10px;
transition:
background-color 0.22s ease,
transform 0.22s ease,
box-shadow 0.22s ease;
}
.auth-top-bar__action:hover {
background-color: var(--el-fill-color-light);
box-shadow: 0 4px 14px rgb(0 0 0 / 8%);
transform: translateY(-2px);
}
.auth-top-bar__action:hover :deep(.fa-svg-icon) {
color: var(--el-color-primary);
}
.auth-top-bar__action:active {
box-shadow: 0 2px 6px rgb(0 0 0 / 6%);
transform: translateY(0);
transition-duration: 0.12s;
}
.dark .auth-top-bar__action:hover {
background-color: rgb(255 255 255 / 10%);
box-shadow: 0 4px 18px rgb(0 0 0 / 45%);
}
.color-dots {
pointer-events: none;
box-shadow: 0 2px 12px var(--fa-gray-300);
backdrop-filter: blur(10px);
transform: translateX(10px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.color-dot {
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
transform: translateX(20px) scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--index) * 0.05s);
}
/* 仅展开调色条后,单颗色块悬浮:描边 + 略放大 */
.color-picker-expandable:hover .color-dot:hover {
z-index: 1;
box-shadow:
0 4px 12px rgb(0 0 0 / 28%),
0 0 0 2px rgb(255 255 255 / 88%);
transform: translateX(0) scale(1.16);
}
.color-picker-expandable:hover .color-dots {
pointer-events: auto;
opacity: 1;
transform: translateX(0);
}
.color-picker-expandable:hover .color-dot {
opacity: 1;
transform: translateX(0) scale(1);
}
.dark .color-dots {
background-color: var(--fa-gray-200);
box-shadow: none;
}
/* 调色盘:图标颜色与当前主题主色一致(含单独悬浮、整块调色区悬浮) */
.palette-btn :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.auth-top-bar__action.palette-btn:hover :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
.color-picker-expandable:hover .palette-btn :deep(.fa-svg-icon) {
color: v-bind("themeColorForCss");
}
</style>
@@ -1,42 +0,0 @@
<!-- 登录区底部:灰色提示 + 主题链(注册 / 返回登录等) -->
<template>
<div
class="login-auth-link-row mt-6 flex flex-wrap items-center justify-center gap-1 text-sm leading-normal"
>
<span class="inline-flex items-center text-(--el-text-color-secondary)">{{ hint }}</span>
<ElLink
type="primary"
underline="never"
class="inline-flex items-center font-medium leading-[inherit]!"
@click="$emit('link')"
>
{{ linkText }}
</ElLink>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "FaLoginAuthLinkRow" });
interface Props {
hint: string;
linkText: string;
}
withDefaults(defineProps<Props>(), {});
interface Emits {
link: [];
}
defineEmits<Emits>();
</script>
<style scoped lang="scss">
.login-auth-link-row {
:deep(.el-link),
:deep(.el-link__inner) {
line-height: inherit;
}
}
</style>
@@ -1,117 +0,0 @@
<!-- 第三方 OAuth 登录(后端需配置各渠道 ClientId/Secret) -->
<template>
<div class="third-party-login login-third-on-dark">
<div class="divider-container">
<div class="divider-line" />
<span class="divider-text">{{ $t("login.otherLoginMethods") }}</span>
<div class="divider-line" />
</div>
<div
class="login-third-party-icons flex w-full items-center justify-center gap-x-3 sm:gap-x-4 max-sm:gap-x-2"
>
<ElTooltip
v-for="item in oauthItems"
:key="item.provider"
:content="item.tip"
placement="top"
>
<button
type="button"
class="oauth-social-btn flex size-10 max-sm:size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-transparent transition-colors duration-200 outline-none"
:aria-label="item.tip"
@click="$emit('oauth', item.provider)"
>
<FaSvgIcon :icon="item.icon" :class="item.iconClass" />
</button>
</ElTooltip>
</div>
</div>
</template>
<script setup lang="ts">
import type { OAuthProvider } from "@/api/module_system/auth";
defineOptions({ name: "FaLoginThirdPartySection" });
interface Emits {
oauth: [provider: OAuthProvider];
}
defineEmits<Emits>();
const { t } = useI18n();
const oauthItems = computed(() => [
{
provider: "wechat" as const,
tip: t("login.oauthTooltip.wechat"),
icon: "simple-icons:wechat",
iconClass: "size-[22px] max-sm:size-[18px] text-[#07c160]",
},
{
provider: "qq" as const,
tip: t("login.oauthTooltip.qq"),
icon: "simple-icons:tencentqq",
iconClass: "size-[22px] max-sm:size-[18px] text-[#12b7f5]",
},
{
provider: "github" as const,
tip: t("login.oauthTooltip.github"),
icon: "mdi:github",
iconClass: "size-[22px] max-sm:size-[18px] text-g-800 dark:text-white/85",
},
{
provider: "gitee" as const,
tip: t("login.oauthTooltip.gitee"),
icon: "simple-icons:gitee",
iconClass: "size-[22px] max-sm:size-[18px] text-[#c71d23]",
},
]);
</script>
<style scoped lang="scss">
.login-third-on-dark {
margin-top: 1rem;
.divider-container {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, var(--el-border-color-lighter), transparent);
}
.divider-text {
padding: 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
}
html.dark .login-third-on-dark {
.divider-line {
background: linear-gradient(to right, transparent, rgb(255 255 255 / 15%), transparent);
}
.divider-text {
color: rgb(255 255 255 / 38%);
}
}
.oauth-social-btn:hover {
background-color: var(--el-fill-color-light);
}
.oauth-social-btn:focus-visible {
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
}
html.dark .oauth-social-btn:hover {
background-color: rgb(255 255 255 / 10%);
}
</style>