优化backend前端布局
This commit is contained in:
1 parent
68617335f0
commit
e34543465a
11 files changed
+1899
-903
No files matched your search
Vendored
+2
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AsideMenuItem: typeof import('./src/components/AsideMenuItem.vue')['default']
|
||||||
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
|
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
|
||||||
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
@@ -87,6 +88,7 @@ declare module 'vue' {
|
|||||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
|
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
|
||||||
|
MenuIcon: typeof import('./src/components/MenuIcon.vue')['default']
|
||||||
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
|
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -1,12 +1,51 @@
|
|||||||
// Element Plus Message z-index
|
// 全局主题:浅蓝白后台框架
|
||||||
|
// 自定义变量供顶栏 / 侧栏 / 内容区统一取色,Element Plus 主色同步收敛。
|
||||||
:root {
|
:root {
|
||||||
--el-message-z-index: 9999;
|
--el-message-z-index: 9999;
|
||||||
|
|
||||||
|
--el-color-primary: #2f6bff;
|
||||||
|
--el-color-primary-light-3: #6f97ff;
|
||||||
|
--el-color-primary-light-5: #97b5ff;
|
||||||
|
--el-color-primary-light-7: #c5d8ff;
|
||||||
|
--el-color-primary-light-8: #dce7ff;
|
||||||
|
--el-color-primary-light-9: #eef4ff;
|
||||||
|
--el-color-primary-dark-2: #2555cc;
|
||||||
|
|
||||||
|
--yz-primary: #2f6bff;
|
||||||
|
--yz-primary-soft: #eef4ff;
|
||||||
|
--yz-primary-softer: #f6f9ff;
|
||||||
|
--yz-header-bg: #ffffff;
|
||||||
|
--yz-aside-bg: #ffffff;
|
||||||
|
--yz-page-bg: #f4f7fd;
|
||||||
|
--yz-card-bg: #ffffff;
|
||||||
|
--yz-border: #e8edf7;
|
||||||
|
--yz-text: #24304a;
|
||||||
|
--yz-text-regular: #5b6785;
|
||||||
|
--yz-text-muted: #93a0b8;
|
||||||
|
--yz-shadow: 0 2px 12px rgba(31, 61, 130, 0.06);
|
||||||
|
--yz-header-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暗色模式:只覆盖框架自定义变量,Element Plus 变量交给官方 dark css-vars
|
||||||
|
html.dark {
|
||||||
|
--yz-primary: #6f97ff;
|
||||||
|
--yz-primary-soft: rgba(64, 110, 220, 0.2);
|
||||||
|
--yz-primary-softer: rgba(64, 110, 220, 0.12);
|
||||||
|
--yz-header-bg: #1b1f2b;
|
||||||
|
--yz-aside-bg: #1b1f2b;
|
||||||
|
--yz-page-bg: #131720;
|
||||||
|
--yz-card-bg: #1f2431;
|
||||||
|
--yz-border: #2c3344;
|
||||||
|
--yz-text: #e8ecf6;
|
||||||
|
--yz-text-regular: #b9c2d6;
|
||||||
|
--yz-text-muted: #8692ac;
|
||||||
|
--yz-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// body 样式
|
// body 样式
|
||||||
body {
|
body {
|
||||||
// background-color: #f5f7fa;
|
color: var(--yz-text);
|
||||||
color: #303133;
|
background-color: var(--yz-page-bg);
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<el-menu-item
|
||||||
|
v-if="!item.children || item.children.length === 0"
|
||||||
|
:index="item.path || String(item.id)"
|
||||||
|
:class="{ 'is-locked': item.locked }"
|
||||||
|
>
|
||||||
|
<MenuIcon :icon="item.icon" />
|
||||||
|
<template #title>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu
|
||||||
|
v-else
|
||||||
|
:index="item.path || String(item.id)"
|
||||||
|
teleported
|
||||||
|
:class="{ 'is-locked': item.locked }"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<MenuIcon :icon="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
|
</template>
|
||||||
|
<AsideMenuItem
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.id"
|
||||||
|
:item="child"
|
||||||
|
/>
|
||||||
|
</el-sub-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import MenuIcon from "@/components/MenuIcon.vue";
|
||||||
|
|
||||||
|
defineOptions({ name: "AsideMenuItem" });
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
item: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<i v-if="isFontAwesome" :class="icon" class="menu-icon"></i>
|
||||||
|
<el-icon v-else class="menu-icon">
|
||||||
|
<component :is="elementIconName" />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
icon: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const isFontAwesome = computed(() => /fa-|(^|\s)fa[srlb]?\s/.test(props.icon || ""));
|
||||||
|
|
||||||
|
// main.js 已把所有 Element Plus 图标全局注册;数据库里存的是图标组件名,
|
||||||
|
// 未能识别时统一回退到 Grid,避免渲染出空白图标。
|
||||||
|
const elementIconName = computed(() => {
|
||||||
|
const name = props.icon || "Grid";
|
||||||
|
const components = instance?.appContext?.components || {};
|
||||||
|
return components[name] ? name : "Grid";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useMenuStore } from "@/stores/menu";
|
||||||
|
import { pickDefaultChild, isWorkbenchMenu } from "@/router/menuDefaults";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台框架导航模型。
|
||||||
|
*
|
||||||
|
* 层级约定:
|
||||||
|
* 模块(从门户 /home 进入,如「OA管理」/apps/oa)
|
||||||
|
* └─ 顶级菜单(顶部导航,如「仪表盘」「审批中心」)
|
||||||
|
* └─ 左侧菜单(选中顶级菜单后的内容)
|
||||||
|
*
|
||||||
|
* 顶部导航展示的是当前模块下的顶级菜单,不是门户里的模块卡片。
|
||||||
|
* 选中态直接由当前路由反推,因此标签页切换、刷新、深链接都能自动还原。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fixedCmsMenu = {
|
||||||
|
id: -200,
|
||||||
|
path: "/apps/cms",
|
||||||
|
title: "内容管理",
|
||||||
|
icon: "Document",
|
||||||
|
sort: -200,
|
||||||
|
order: -200,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: -2001,
|
||||||
|
path: "/apps/cms/analytics/content",
|
||||||
|
title: "内容统计",
|
||||||
|
icon: "DataAnalysis",
|
||||||
|
sort: 0,
|
||||||
|
order: 0,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -201,
|
||||||
|
path: "/apps/cms/article",
|
||||||
|
title: "文章管理",
|
||||||
|
icon: "Document",
|
||||||
|
sort: 1,
|
||||||
|
order: 1,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -202,
|
||||||
|
path: "/apps/cms/article/type",
|
||||||
|
title: "文章分类",
|
||||||
|
icon: "Folder",
|
||||||
|
sort: 2,
|
||||||
|
order: 2,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -203,
|
||||||
|
path: "/apps/cms/products",
|
||||||
|
title: "产品管理",
|
||||||
|
icon: "Goods",
|
||||||
|
sort: 3,
|
||||||
|
order: 3,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -204,
|
||||||
|
path: "/apps/cms/products/types",
|
||||||
|
title: "产品分类",
|
||||||
|
icon: "Folder",
|
||||||
|
sort: 4,
|
||||||
|
order: 4,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -205,
|
||||||
|
path: "/apps/cms/solutions",
|
||||||
|
title: "解决方案管理",
|
||||||
|
icon: "Promotion",
|
||||||
|
sort: 5,
|
||||||
|
order: 5,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: -206,
|
||||||
|
path: "/apps/cms/solutions/types",
|
||||||
|
title: "解决方案分类",
|
||||||
|
icon: "Folder",
|
||||||
|
sort: 6,
|
||||||
|
order: 6,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function processMenus(menus) {
|
||||||
|
return (menus || [])
|
||||||
|
.filter((menu) => {
|
||||||
|
// is_visible 只控制侧栏/顶部是否展示,不影响动态路由注册
|
||||||
|
if (menu.is_visible !== undefined && Number(menu.is_visible) === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (menu.path && String(menu.path).trim() !== "") return true;
|
||||||
|
return !!(menu.children && menu.children.length > 0);
|
||||||
|
})
|
||||||
|
.map((menu) => {
|
||||||
|
const sort = Number(menu.sort) || 0;
|
||||||
|
return {
|
||||||
|
id: menu.id,
|
||||||
|
pid: menu.pid || 0,
|
||||||
|
path: menu.path,
|
||||||
|
icon: menu.icon || "Grid",
|
||||||
|
title: menu.title,
|
||||||
|
component_path: menu.component_path || "",
|
||||||
|
type: menu.type,
|
||||||
|
locked: !!menu.locked,
|
||||||
|
sort,
|
||||||
|
order: sort,
|
||||||
|
children: menu.children ? processMenus(menu.children) : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFixedCmsMenu(menus) {
|
||||||
|
return menus.some((menu) => menu.path === fixedCmsMenu.path)
|
||||||
|
? menus
|
||||||
|
: [fixedCmsMenu, ...menus];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMenusRecursively(menus) {
|
||||||
|
(menus || []).forEach((menu) => {
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
menu.children.sort((a, b) => {
|
||||||
|
if (a.order !== b.order) return a.order - b.order;
|
||||||
|
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||||
|
});
|
||||||
|
sortMenusRecursively(menu.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return menus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenuItem(menus, targetIndex) {
|
||||||
|
for (const menu of menus || []) {
|
||||||
|
if (menu.path === targetIndex) return menu;
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const found = findMenuItem(menu.children, targetIndex);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInSubtree(base, currentPath) {
|
||||||
|
if (!base) return false;
|
||||||
|
const normalized = base.replace(/\/+$/, "");
|
||||||
|
return currentPath === normalized || currentPath.startsWith(`${normalized}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findParentModule(menus, currentPath) {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (!menu.path || menu.path === "/home") {
|
||||||
|
const found = findParentModule(menu.children || [], currentPath);
|
||||||
|
if (found) return found;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPathInSubtree(menu.path, currentPath)) return menu;
|
||||||
|
|
||||||
|
for (const child of menu.children || []) {
|
||||||
|
if (isPathInSubtree(child.path, currentPath)) return menu;
|
||||||
|
for (const grandchild of child.children || []) {
|
||||||
|
if (isPathInSubtree(grandchild.path, currentPath)) return menu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在当前模块的顶级菜单里,找出路径最具体覆盖当前路由的那一项 */
|
||||||
|
function findActiveTopMenu(topMenus, currentPath) {
|
||||||
|
let best = null;
|
||||||
|
for (const top of topMenus) {
|
||||||
|
const stack = [top];
|
||||||
|
while (stack.length) {
|
||||||
|
const node = stack.pop();
|
||||||
|
if (node.path && isPathInSubtree(node.path, currentPath)) {
|
||||||
|
const length = node.path.length;
|
||||||
|
if (!best || length > best.length) {
|
||||||
|
best = { menu: top, length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length) {
|
||||||
|
stack.push(...node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.menu || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击顶级菜单时落到具体页面,避免多出一个「目录」标签页 */
|
||||||
|
function resolveLandingMenu(menu) {
|
||||||
|
if (!menu) return null;
|
||||||
|
if (menu.locked) return menu.path ? menu : null;
|
||||||
|
|
||||||
|
let current = menu;
|
||||||
|
const visited = new Set();
|
||||||
|
while (current.children && current.children.length > 0 && !visited.has(current.id)) {
|
||||||
|
visited.add(current.id);
|
||||||
|
// 目录本身有页面时,访问目录路径即可(路由会渲染 index 子路由)
|
||||||
|
if (current.component_path) break;
|
||||||
|
const next = pickDefaultChild(current.children);
|
||||||
|
if (!next) break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return current.path ? current : menu.path ? menu : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenMenus(menus, parentTitle = "") {
|
||||||
|
const result = [];
|
||||||
|
for (const menu of menus || []) {
|
||||||
|
// 只把「可直接打开的页面」放进搜索结果,纯目录交给路由 redirect,
|
||||||
|
// 否则会同时产生「目录」和「落地页」两个标签页
|
||||||
|
const isPage = !menu.children?.length || !!menu.component_path;
|
||||||
|
if (menu.path && menu.path !== "/home" && isPage) {
|
||||||
|
result.push({
|
||||||
|
id: menu.id,
|
||||||
|
path: menu.path,
|
||||||
|
title: menu.title,
|
||||||
|
icon: menu.icon,
|
||||||
|
locked: menu.locked,
|
||||||
|
parentTitle: parentTitle || menu.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length) {
|
||||||
|
result.push(...flattenMenus(menu.children, menu.title || parentTitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在当前模块下找到可直接打开的「仪表盘 / 工作台」页面,作为默认标签页 */
|
||||||
|
function findWorkbenchPage(menus) {
|
||||||
|
const stack = [...menus];
|
||||||
|
const visited = new Set();
|
||||||
|
while (stack.length) {
|
||||||
|
const node = stack.shift();
|
||||||
|
if (!node || visited.has(node.id)) continue;
|
||||||
|
visited.add(node.id);
|
||||||
|
if (
|
||||||
|
node.path &&
|
||||||
|
isWorkbenchMenu(node) &&
|
||||||
|
(!node.children?.length || !!node.component_path)
|
||||||
|
) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (node.children?.length) stack.push(...node.children);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminNavigation() {
|
||||||
|
const route = useRoute();
|
||||||
|
const menuStore = useMenuStore();
|
||||||
|
|
||||||
|
const list = computed(() =>
|
||||||
|
sortMenusRecursively(processMenus(mergeFixedCmsMenu(menuStore.menus || []))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentModule = computed(() => {
|
||||||
|
const path = route.path;
|
||||||
|
if (!path || path === "/" || path === "/home") return null;
|
||||||
|
return findParentModule(list.value, path);
|
||||||
|
});
|
||||||
|
|
||||||
|
const topMenus = computed(() => currentModule.value?.children || []);
|
||||||
|
|
||||||
|
const activeTopMenu = computed(() => {
|
||||||
|
const menus = topMenus.value;
|
||||||
|
if (!menus.length) return null;
|
||||||
|
return (
|
||||||
|
findActiveTopMenu(menus, route.path) ||
|
||||||
|
pickDefaultChild(menus) ||
|
||||||
|
menus[0] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sideMenus = computed(() => {
|
||||||
|
const active = activeTopMenu.value;
|
||||||
|
if (!active) return [];
|
||||||
|
return active.children && active.children.length ? active.children : [active];
|
||||||
|
});
|
||||||
|
|
||||||
|
const sideTitle = computed(
|
||||||
|
() => activeTopMenu.value?.title || currentModule.value?.title || "功能菜单",
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchableMenus = computed(() => flattenMenus(list.value));
|
||||||
|
|
||||||
|
// 「首页」已移除:进入模块后默认展示该模块的仪表盘 / 工作台
|
||||||
|
const defaultTab = computed(() => {
|
||||||
|
const module = currentModule.value;
|
||||||
|
if (!module) return null;
|
||||||
|
const target =
|
||||||
|
findWorkbenchPage(module.children || []) || resolveLandingMenu(module);
|
||||||
|
return target?.path ? target : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
currentModule,
|
||||||
|
topMenus,
|
||||||
|
activeTopMenu,
|
||||||
|
sideMenus,
|
||||||
|
sideTitle,
|
||||||
|
defaultTab,
|
||||||
|
searchableMenus,
|
||||||
|
findMenuItem,
|
||||||
|
resolveLandingMenu,
|
||||||
|
};
|
||||||
|
}
|
||||||
+50
-28
@@ -58,27 +58,23 @@ import { ref as vueRef } from 'vue';
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export const useTabsStore = defineTabsStore('tabs', () => {
|
export const useTabsStore = defineTabsStore('tabs', () => {
|
||||||
// 固定首页tab
|
// 默认标签页:不再固定「首页」,而是由 Main.vue 按当前模块设置成仪表盘
|
||||||
const defaultDashboardPath = '/home';
|
const defaultDashboardPath = vueRef('');
|
||||||
|
|
||||||
// 从 localStorage 恢复 tabs 状态
|
// 从 localStorage 恢复 tabs 状态(兼容旧版本里遗留的 /home 首页标签)
|
||||||
function loadTabsFromStorage() {
|
function loadTabsFromStorage() {
|
||||||
try {
|
try {
|
||||||
const savedTabs = localStorage.getItem('tabs_list');
|
const savedTabs = localStorage.getItem('tabs_list');
|
||||||
const savedActiveTab = localStorage.getItem('active_tab');
|
|
||||||
if (savedTabs) {
|
if (savedTabs) {
|
||||||
const tabs = JSON.parse(savedTabs);
|
const tabs = JSON.parse(savedTabs);
|
||||||
// 确保至少包含首页
|
if (Array.isArray(tabs)) {
|
||||||
const hasHome = tabs.some(t => t.fullPath === defaultDashboardPath);
|
return tabs.filter(t => t && t.fullPath && t.fullPath !== '/home');
|
||||||
if (!hasHome) {
|
|
||||||
tabs.unshift({ title: '首页', fullPath: defaultDashboardPath, name: 'Home' });
|
|
||||||
}
|
}
|
||||||
return tabs;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('恢复 tabs 失败:', e);
|
console.warn('恢复 tabs 失败:', e);
|
||||||
}
|
}
|
||||||
return [{ title: '首页', fullPath: defaultDashboardPath, name: 'Home' }];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存 tabs 到 localStorage
|
// 保存 tabs 到 localStorage
|
||||||
@@ -95,7 +91,29 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
|
|
||||||
const tabList = vueRef(loadTabsFromStorage());
|
const tabList = vueRef(loadTabsFromStorage());
|
||||||
const savedActiveTab = localStorage.getItem('active_tab');
|
const savedActiveTab = localStorage.getItem('active_tab');
|
||||||
const activeTab = vueRef(savedActiveTab || defaultDashboardPath);
|
const activeTab = vueRef(
|
||||||
|
savedActiveTab && savedActiveTab !== '/home' ? savedActiveTab : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置当前模块的默认仪表盘标签:始终排在第一位且不可关闭
|
||||||
|
function setDefaultTab(tab) {
|
||||||
|
const path = tab?.fullPath || '';
|
||||||
|
if (!path) return;
|
||||||
|
defaultDashboardPath.value = path;
|
||||||
|
|
||||||
|
const idx = tabList.value.findIndex(t => t.fullPath === path);
|
||||||
|
if (idx === -1) {
|
||||||
|
tabList.value.unshift(tab);
|
||||||
|
} else if (idx > 0) {
|
||||||
|
const [exist] = tabList.value.splice(idx, 1);
|
||||||
|
tabList.value.unshift(exist);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeTab.value || !tabList.value.some(t => t.fullPath === activeTab.value)) {
|
||||||
|
activeTab.value = path;
|
||||||
|
}
|
||||||
|
saveTabsToStorage(tabList.value, activeTab.value);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加tab,若已存在则激活
|
// 添加tab,若已存在则激活
|
||||||
function addTab(tab) {
|
function addTab(tab) {
|
||||||
@@ -107,8 +125,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
saveTabsToStorage(tabList.value, activeTab.value);
|
saveTabsToStorage(tabList.value, activeTab.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除指定tab并切换激活tab
|
// 删除指定tab并切换激活tab(默认仪表盘标签不可关闭)
|
||||||
function removeTab(fullPath) {
|
function removeTab(fullPath) {
|
||||||
|
if (fullPath === defaultDashboardPath.value) return;
|
||||||
const idx = tabList.value.findIndex((t) => t.fullPath === fullPath);
|
const idx = tabList.value.findIndex((t) => t.fullPath === fullPath);
|
||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
tabList.value.splice(idx, 1);
|
tabList.value.splice(idx, 1);
|
||||||
@@ -119,33 +138,31 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
const newIdx = idx >= tabList.value.length ? tabList.value.length - 1 : idx;
|
const newIdx = idx >= tabList.value.length ? tabList.value.length - 1 : idx;
|
||||||
activeTab.value = tabList.value[newIdx].fullPath;
|
activeTab.value = tabList.value[newIdx].fullPath;
|
||||||
} else {
|
} else {
|
||||||
// 全部关闭,兜底首页
|
activeTab.value = defaultDashboardPath.value;
|
||||||
activeTab.value = defaultDashboardPath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveTabsToStorage(tabList.value, activeTab.value);
|
saveTabsToStorage(tabList.value, activeTab.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭其他,只留首页和当前激活tab
|
// 关闭其他,只留默认仪表盘和当前激活tab
|
||||||
function closeOthers() {
|
function closeOthers() {
|
||||||
tabList.value = tabList.value.filter(
|
tabList.value = tabList.value.filter(
|
||||||
(t) => t.fullPath === defaultDashboardPath || t.fullPath === activeTab.value
|
(t) => t.fullPath === defaultDashboardPath.value || t.fullPath === activeTab.value
|
||||||
);
|
);
|
||||||
saveTabsToStorage(tabList.value, activeTab.value);
|
saveTabsToStorage(tabList.value, activeTab.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭左侧(关闭指定tab左侧的所有tab,保留首页和目标tab)
|
// 关闭左侧(关闭指定tab左侧的所有tab,保留默认仪表盘和目标tab)
|
||||||
function closeLeft(targetFullPath) {
|
function closeLeft(targetFullPath) {
|
||||||
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
||||||
if (targetIndex > -1) {
|
if (targetIndex > -1) {
|
||||||
// 保留首页和目标tab及其右侧的所有tab
|
|
||||||
const beforeIndex = tabList.value.slice(0, targetIndex);
|
const beforeIndex = tabList.value.slice(0, targetIndex);
|
||||||
const hasCloseableLeft = beforeIndex.some(t => t.fullPath !== defaultDashboardPath);
|
const hasCloseableLeft = beforeIndex.some(t => t.fullPath !== defaultDashboardPath.value);
|
||||||
|
|
||||||
if (hasCloseableLeft) {
|
if (hasCloseableLeft) {
|
||||||
tabList.value = tabList.value.filter((t, index) =>
|
tabList.value = tabList.value.filter((t, index) =>
|
||||||
t.fullPath === defaultDashboardPath || index >= targetIndex
|
t.fullPath === defaultDashboardPath.value || index >= targetIndex
|
||||||
);
|
);
|
||||||
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
||||||
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
||||||
@@ -156,17 +173,16 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭右侧(关闭指定tab右侧的所有tab,保留首页和目标tab)
|
// 关闭右侧(关闭指定tab右侧的所有tab,保留默认仪表盘和目标tab)
|
||||||
function closeRight(targetFullPath) {
|
function closeRight(targetFullPath) {
|
||||||
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
|
||||||
if (targetIndex > -1) {
|
if (targetIndex > -1) {
|
||||||
// 保留首页和目标tab及其左侧的所有tab
|
|
||||||
const afterIndex = tabList.value.slice(targetIndex + 1);
|
const afterIndex = tabList.value.slice(targetIndex + 1);
|
||||||
const hasCloseableRight = afterIndex.length > 0;
|
const hasCloseableRight = afterIndex.length > 0;
|
||||||
|
|
||||||
if (hasCloseableRight) {
|
if (hasCloseableRight) {
|
||||||
tabList.value = tabList.value.filter((t, index) =>
|
tabList.value = tabList.value.filter((t, index) =>
|
||||||
t.fullPath === defaultDashboardPath || index <= targetIndex
|
t.fullPath === defaultDashboardPath.value || index <= targetIndex
|
||||||
);
|
);
|
||||||
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
|
||||||
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
|
||||||
@@ -177,10 +193,13 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭全部,只留首页
|
// 关闭全部,只留默认仪表盘
|
||||||
function closeAll() {
|
function closeAll() {
|
||||||
tabList.value = tabList.value.filter((t) => t.fullPath === defaultDashboardPath);
|
const target = defaultDashboardPath.value || activeTab.value;
|
||||||
activeTab.value = defaultDashboardPath;
|
tabList.value = target
|
||||||
|
? tabList.value.filter((t) => t.fullPath === target)
|
||||||
|
: [];
|
||||||
|
activeTab.value = tabList.value.length ? target : '';
|
||||||
saveTabsToStorage(tabList.value, activeTab.value);
|
saveTabsToStorage(tabList.value, activeTab.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +211,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
|
|
||||||
// 重置 tabs store 到初始状态(登出时使用)
|
// 重置 tabs store 到初始状态(登出时使用)
|
||||||
function resetTabs() {
|
function resetTabs() {
|
||||||
tabList.value = [{ title: '首页', fullPath: defaultDashboardPath, name: 'Dashboard' }];
|
tabList.value = [];
|
||||||
activeTab.value = defaultDashboardPath;
|
activeTab.value = '';
|
||||||
|
defaultDashboardPath.value = '';
|
||||||
// 清除 localStorage 中的 tabs 数据
|
// 清除 localStorage 中的 tabs 数据
|
||||||
localStorage.removeItem('tabs_list');
|
localStorage.removeItem('tabs_list');
|
||||||
localStorage.removeItem('active_tab');
|
localStorage.removeItem('active_tab');
|
||||||
@@ -202,7 +222,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
|
|||||||
return {
|
return {
|
||||||
tabList,
|
tabList,
|
||||||
activeTab,
|
activeTab,
|
||||||
|
defaultDashboardPath,
|
||||||
addTab,
|
addTab,
|
||||||
|
setDefaultTab,
|
||||||
removeTab,
|
removeTab,
|
||||||
closeOthers,
|
closeOthers,
|
||||||
closeLeft,
|
closeLeft,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 全站主题模式:跟随系统 / 亮色 / 暗色。
|
||||||
|
*
|
||||||
|
* 顶栏个人中心菜单与门户页共用这一份状态,避免出现两处各自维护
|
||||||
|
* 「是否暗色」而互相覆盖的情况。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODE_KEY = "yz-theme-mode";
|
||||||
|
// 兼容旧版本:此前只保存最终的亮/暗结果
|
||||||
|
const LEGACY_KEY = "element-plus-theme";
|
||||||
|
|
||||||
|
export const THEME_OPTIONS = [
|
||||||
|
{ value: "system", label: "跟随系统" },
|
||||||
|
{ value: "light", label: "亮色" },
|
||||||
|
{ value: "dark", label: "暗色" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getThemeMode() {
|
||||||
|
try {
|
||||||
|
const mode = localStorage.getItem(MODE_KEY);
|
||||||
|
if (["system", "light", "dark"].includes(mode)) return mode;
|
||||||
|
const legacy = localStorage.getItem(LEGACY_KEY);
|
||||||
|
if (legacy === "dark" || legacy === "light") return legacy;
|
||||||
|
} catch (e) {
|
||||||
|
// localStorage 不可用时退化为跟随系统
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDarkByMode(mode) {
|
||||||
|
if (mode === "dark") return true;
|
||||||
|
if (mode === "light") return false;
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!!window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用主题并返回当前是否为暗色 */
|
||||||
|
export function applyThemeMode(mode) {
|
||||||
|
const dark = isDarkByMode(mode);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MODE_KEY, mode);
|
||||||
|
localStorage.setItem(LEGACY_KEY, dark ? "dark" : "light");
|
||||||
|
} catch (e) {
|
||||||
|
// 存储失败不影响本次主题生效
|
||||||
|
}
|
||||||
|
return dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听系统配色变化,返回取消监听函数 */
|
||||||
|
export function onSystemThemeChange(handler) {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||||
|
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const listener = () => handler(query.matches);
|
||||||
|
if (query.addEventListener) {
|
||||||
|
query.addEventListener("change", listener);
|
||||||
|
return () => query.removeEventListener("change", listener);
|
||||||
|
}
|
||||||
|
query.addListener(listener);
|
||||||
|
return () => query.removeListener(listener);
|
||||||
|
}
|
||||||
+150
-21
@@ -7,12 +7,35 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { ref, watch, reactive, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
|
import { ref, watch, reactive, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||||
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
|
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminNavigation } from '@/composables/useAdminNavigation';
|
||||||
|
|
||||||
const tabsStore = useTabsStore();
|
const tabsStore = useTabsStore();
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const defaultDashboardPath = '/home';
|
const { defaultTab } = useAdminNavigation();
|
||||||
|
|
||||||
|
// 「首页」已移除:默认标签 = 当前模块的仪表盘,随模块切换自动更新
|
||||||
|
const defaultDashboardPath = computed(
|
||||||
|
() => tabsStore.defaultDashboardPath || route.fullPath
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastDefaultPath = '';
|
||||||
|
watch(
|
||||||
|
defaultTab,
|
||||||
|
(tab) => {
|
||||||
|
const path = tab?.path || '';
|
||||||
|
if (!path || path === lastDefaultPath) return;
|
||||||
|
lastDefaultPath = path;
|
||||||
|
tabsStore.setDefaultTab({
|
||||||
|
title: tab.title,
|
||||||
|
fullPath: path,
|
||||||
|
name: tab.title,
|
||||||
|
icon: tab.icon,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
// ========== 手机端侧栏抽屉控制 ==========
|
// ========== 手机端侧栏抽屉控制 ==========
|
||||||
const MOBILE_QUERY = '(max-width: 768px)';
|
const MOBILE_QUERY = '(max-width: 768px)';
|
||||||
@@ -144,9 +167,9 @@ watch(
|
|||||||
const handleAsideMenuClick = async (menuItem) => {
|
const handleAsideMenuClick = async (menuItem) => {
|
||||||
const targetPath = menuItem.path;
|
const targetPath = menuItem.path;
|
||||||
|
|
||||||
if (targetPath === '/home') {
|
if (targetPath === '/home' || targetPath === '/') {
|
||||||
tabsStore.closeAll();
|
tabsStore.closeAll();
|
||||||
router.push('/home');
|
router.push(defaultDashboardPath.value || '/');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +226,7 @@ const closeOthers = () => {
|
|||||||
};
|
};
|
||||||
const closeAll = () => {
|
const closeAll = () => {
|
||||||
tabsStore.closeAll();
|
tabsStore.closeAll();
|
||||||
router.push(defaultDashboardPath);
|
router.push(defaultDashboardPath.value || route.fullPath);
|
||||||
};
|
};
|
||||||
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
|
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
|
||||||
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
|
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
|
||||||
@@ -213,9 +236,9 @@ watch(
|
|||||||
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
|
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
|
||||||
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
|
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
|
||||||
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
|
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
|
||||||
// 如果切换到首页,关闭其他所有tab
|
// 切回默认仪表盘时,清掉其它标签页
|
||||||
if (newVal === '/home') {
|
if (newVal === defaultDashboardPath.value) {
|
||||||
tabsStore.closeAll();
|
tabsStore.closeOthers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查路由是否存在
|
// 检查路由是否存在
|
||||||
@@ -391,7 +414,7 @@ function hideContextMenu() {
|
|||||||
contextMenu.tab = null;
|
contextMenu.tab = null;
|
||||||
}
|
}
|
||||||
function closeTabContextTab() {
|
function closeTabContextTab() {
|
||||||
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) {
|
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath.value) {
|
||||||
tabsStore.removeTab(contextMenu.tab.fullPath);
|
tabsStore.removeTab(contextMenu.tab.fullPath);
|
||||||
}
|
}
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
@@ -436,15 +459,15 @@ function closeOthersContextTab() {
|
|||||||
function closeAllTabs() {
|
function closeAllTabs() {
|
||||||
tabsStore.closeAll();
|
tabsStore.closeAll();
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
router.push(defaultDashboardPath);
|
router.push(defaultDashboardPath.value || route.fullPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算是否可以关闭左侧/右侧
|
// 计算是否可以关闭左侧/右侧
|
||||||
const canCloseLeft = computed(() => {
|
const canCloseLeft = computed(() => {
|
||||||
if (!contextMenu.tab) return false;
|
if (!contextMenu.tab) return false;
|
||||||
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
|
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
|
||||||
// 至少左侧有一个可关闭的tab(排除首页)
|
// 至少左侧有一个可关闭的tab(排除默认仪表盘)
|
||||||
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath);
|
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const canCloseRight = computed(() => {
|
const canCloseRight = computed(() => {
|
||||||
@@ -478,16 +501,17 @@ const canCloseRight = computed(() => {
|
|||||||
></div>
|
></div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<el-container class="main-container">
|
<el-container class="main-container" direction="vertical">
|
||||||
<common-aside
|
<!-- 顶栏全宽:logo 位于页面真正的左上角 -->
|
||||||
class="common-aside-wrapper"
|
<el-header class="main-header">
|
||||||
:class="{ 'mobile-open': sidebarVisible }"
|
<common-header @menu-click="handleAsideMenuClick" />
|
||||||
@menu-click="handleAsideMenuClick"
|
</el-header>
|
||||||
/>
|
<el-container class="main-body">
|
||||||
<el-container>
|
<common-aside
|
||||||
<el-header class="main-header">
|
class="common-aside-wrapper"
|
||||||
<common-header />
|
:class="{ 'mobile-open': sidebarVisible }"
|
||||||
</el-header>
|
@menu-click="handleAsideMenuClick"
|
||||||
|
/>
|
||||||
<el-main class="right-main">
|
<el-main class="right-main">
|
||||||
<div class="multi-tabs-wrapper">
|
<div class="multi-tabs-wrapper">
|
||||||
<el-tabs
|
<el-tabs
|
||||||
@@ -887,3 +911,108 @@ const canCloseRight = computed(() => {
|
|||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- 浅色框架:白顶栏 + 浅蓝内容区 -->
|
||||||
|
<style scoped lang="less">
|
||||||
|
.common-layout,
|
||||||
|
.main-container {
|
||||||
|
background-color: var(--yz-page-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶栏全宽 + 下方「侧栏 / 内容」两栏,左侧保持一整块白色面板
|
||||||
|
.main-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
flex: 0 0 var(--yz-header-height);
|
||||||
|
width: 100%;
|
||||||
|
height: var(--yz-header-height) !important;
|
||||||
|
background-color: var(--yz-header-bg) !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
background-color: var(--yz-page-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-aside) {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
background-color: var(--yz-page-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-tabs-wrapper {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--yz-border) !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--yz-card-bg) !important;
|
||||||
|
box-shadow: var(--yz-shadow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-tabs {
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--yz-text-regular) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item:hover) {
|
||||||
|
border-color: transparent !important;
|
||||||
|
background: var(--yz-primary-softer) !important;
|
||||||
|
color: var(--yz-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item.is-active) {
|
||||||
|
border-color: var(--el-color-primary-light-7) !important;
|
||||||
|
background: var(--yz-primary-soft) !important;
|
||||||
|
color: var(--yz-primary) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.el-icon-close {
|
||||||
|
color: var(--yz-primary) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--yz-primary) !important;
|
||||||
|
background-color: var(--el-color-primary-light-7) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-panel {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
top: 12px;
|
||||||
|
background: var(--yz-primary) !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.right-main {
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽屉从全宽顶栏下方展开,不遮住 logo
|
||||||
|
.common-layout .common-aside-wrapper {
|
||||||
|
top: var(--yz-header-height) !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
<div class="home-top">
|
<div class="home-top">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
<div class="home-brand">
|
||||||
|
<span class="home-logo"><i></i><i></i><i></i><i></i></span>
|
||||||
|
<span class="home-brand-name">云泽管理平台</span>
|
||||||
|
</div>
|
||||||
<!-- 当前企业 + 一人多企业时可点击切换 -->
|
<!-- 当前企业 + 一人多企业时可点击切换 -->
|
||||||
<TenantSwitcher variant="plain" />
|
<TenantSwitcher variant="plain" />
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +213,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
import { ref, reactive, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
|
||||||
@@ -230,6 +234,7 @@ import { getCurrentUserProfile, logout } from "@/api/login";
|
|||||||
import { reloadMenusAndDynamicRoutes } from "@/router";
|
import { reloadMenusAndDynamicRoutes } from "@/router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useMenuStore } from "@/stores/menu";
|
import { useMenuStore } from "@/stores/menu";
|
||||||
|
import { getThemeMode, applyThemeMode, onSystemThemeChange } from "@/utils/theme";
|
||||||
import TenantSwitcher from "@/components/TenantSwitcher.vue";
|
import TenantSwitcher from "@/components/TenantSwitcher.vue";
|
||||||
|
|
||||||
interface ModuleItem {
|
interface ModuleItem {
|
||||||
@@ -271,40 +276,27 @@ const uncategorizedModules = computed(() =>
|
|||||||
const moduleList = ref<ModuleItem[]>([]);
|
const moduleList = ref<ModuleItem[]>([]);
|
||||||
const refreshLoading = ref(false);
|
const refreshLoading = ref(false);
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = "element-plus-theme";
|
// 与顶栏个人中心共用同一份主题模式(跟随系统 / 亮色 / 暗色)
|
||||||
|
const themeMode = ref(getThemeMode());
|
||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
|
|
||||||
|
const applyTheme = () => {
|
||||||
|
isDark.value = applyThemeMode(themeMode.value);
|
||||||
|
};
|
||||||
|
|
||||||
const initTheme = () => {
|
const initTheme = () => {
|
||||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
if (savedTheme) {
|
|
||||||
isDark.value = savedTheme === "dark";
|
|
||||||
} else {
|
|
||||||
const prefersDark = window.matchMedia(
|
|
||||||
"(prefers-color-scheme: dark)",
|
|
||||||
).matches;
|
|
||||||
isDark.value = prefersDark;
|
|
||||||
}
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyTheme = () => {
|
|
||||||
const htmlElement = document.documentElement;
|
|
||||||
if (isDark.value) {
|
|
||||||
htmlElement.classList.add("dark");
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, "dark");
|
|
||||||
} else {
|
|
||||||
htmlElement.classList.remove("dark");
|
|
||||||
localStorage.setItem(THEME_STORAGE_KEY, "light");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
isDark.value = !isDark.value;
|
// 门户页的快捷按钮直接切换到明确的亮/暗模式
|
||||||
|
themeMode.value = isDark.value ? "light" : "dark";
|
||||||
applyTheme();
|
applyTheme();
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
|
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
|
||||||
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
|
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
|
||||||
|
let stopSystemThemeWatch = null;
|
||||||
|
|
||||||
const userName = computed(
|
const userName = computed(
|
||||||
() => authStore.user?.name || authStore.user?.account || "用户",
|
() => authStore.user?.name || authStore.user?.account || "用户",
|
||||||
@@ -480,6 +472,9 @@ onMounted(() => {
|
|||||||
loadModules();
|
loadModules();
|
||||||
syncReturnedPayment();
|
syncReturnedPayment();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
stopSystemThemeWatch = onSystemThemeChange(() => {
|
||||||
|
if (themeMode.value === "system") applyTheme();
|
||||||
|
});
|
||||||
// 拉取当前登录用户的角色 / 部门 / 职位,用于右上角展示
|
// 拉取当前登录用户的角色 / 部门 / 职位,用于右上角展示
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
fetchUserProfile();
|
fetchUserProfile();
|
||||||
@@ -489,12 +484,16 @@ onMounted(() => {
|
|||||||
authStore.fetchCurrentUser();
|
authStore.fetchCurrentUser();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (stopSystemThemeWatch) stopSystemThemeWatch();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
// 定义一些可复用的变量(可选,建议根据项目规范调整)
|
// 定义一些可复用的变量(可选,建议根据项目规范调整)
|
||||||
@primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
@primary-gradient: linear-gradient(135deg, #4885ff 0%, #2f56e6 100%);
|
||||||
@bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
|
@bg-gradient: linear-gradient(135deg, #f4f7fd 0%, #e8effc 100%);
|
||||||
@text-main: #1a1a2e;
|
@text-main: #1a1a2e;
|
||||||
@text-regular: #606266;
|
@text-regular: #606266;
|
||||||
|
|
||||||
@@ -522,10 +521,50 @@ onMounted(() => {
|
|||||||
.toolbar-left {
|
.toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
.home-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-logo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 8px);
|
||||||
|
gap: 3px;
|
||||||
|
place-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: @primary-gradient;
|
||||||
|
|
||||||
|
i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--yz-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
// 企业切换(TenantSwitcher 组件):样式由组件自带,这里只调楼宇图标颜色
|
// 企业切换(TenantSwitcher 组件):样式由组件自带,这里只调楼宇图标颜色
|
||||||
:deep(.tenant-chip .tenant-chip-icon) {
|
:deep(.tenant-chip .tenant-chip-icon) {
|
||||||
color: #3973ff;
|
color: var(--yz-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +581,7 @@ onMounted(() => {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
border-color: #c0c4cc;
|
border-color: #c0c4cc;
|
||||||
color: #3973ff;
|
color: var(--yz-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +648,7 @@ onMounted(() => {
|
|||||||
padding: 48px 56px;
|
padding: 48px 56px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 10px 40px rgba(47, 107, 255, 0.3);
|
||||||
|
|
||||||
.welcome-content {
|
.welcome-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -691,7 +730,7 @@ onMounted(() => {
|
|||||||
color: @text-main;
|
color: @text-main;
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
border-left: 4px solid #667eea;
|
border-left: 4px solid var(--yz-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +762,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #3973ff;
|
color: var(--yz-primary);
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,16 +812,16 @@ onMounted(() => {
|
|||||||
.buy-btn {
|
.buy-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border: 1px solid #3973ff;
|
border: 1px solid var(--yz-primary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #3973ff;
|
color: var(--yz-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #3973ff;
|
background: var(--yz-primary);
|
||||||
border-color: #3973ff;
|
border-color: var(--yz-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,8 +875,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
|
box-shadow: 0 8px 24px rgba(47, 107, 255, 0.15);
|
||||||
background-color: #3973ff;
|
background-color: var(--yz-primary);
|
||||||
|
|
||||||
.card-title,
|
.card-title,
|
||||||
.card-desc {
|
.card-desc {
|
||||||
@@ -1170,7 +1209,7 @@ onMounted(() => {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #3d3d3d;
|
background: #3d3d3d;
|
||||||
border-color: #4d4d4d;
|
border-color: #4d4d4d;
|
||||||
color: #3973ff;
|
color: var(--yz-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,7 +1233,7 @@ onMounted(() => {
|
|||||||
.modules-section {
|
.modules-section {
|
||||||
.category-title {
|
.category-title {
|
||||||
color: #f5f7fa;
|
color: #f5f7fa;
|
||||||
border-left-color: #667eea;
|
border-left-color: var(--yz-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-card {
|
.module-card {
|
||||||
@@ -1252,7 +1291,7 @@ onMounted(() => {
|
|||||||
&.dark {
|
&.dark {
|
||||||
.modules-section .category-title {
|
.modules-section .category-title {
|
||||||
color: #ffffff !important;
|
color: #ffffff !important;
|
||||||
border-left-color: #667eea;
|
border-left-color: var(--yz-primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user