优化backend前端布局

This commit is contained in:
hero920103 committed 2026-11-04 18:09:53 +08:00
1 parent 68617335f0
commit e34543465a
11 files changed
+1899 -903

No files matched your search

+2
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AsideMenuItem: typeof import('./src/components/AsideMenuItem.vue')['default']
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
@@ -87,6 +88,7 @@ declare module 'vue' {
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
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']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+42 -3
View File
@@ -1,12 +1,51 @@
// Element Plus Message z-index
// 全局主题:浅蓝白后台框架
// 自定义变量供顶栏 / 侧栏 / 内容区统一取色,Element Plus 主色同步收敛。
:root {
--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 {
// background-color: #f5f7fa;
color: #303133;
color: var(--yz-text);
background-color: var(--yz-page-bg);
transition: background-color 0.3s ease, color 0.3s ease;
}
+41
View File
@@ -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
+37
View File
@@ -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
View File
@@ -58,27 +58,23 @@ import { ref as vueRef } from 'vue';
* }
*/
export const useTabsStore = defineTabsStore('tabs', () => {
// 固定首页tab
const defaultDashboardPath = '/home';
// 默认标签页:不再固定「首页」,而是由 Main.vue 按当前模块设置成仪表盘
const defaultDashboardPath = vueRef('');
// 从 localStorage 恢复 tabs 状态
// 从 localStorage 恢复 tabs 状态(兼容旧版本里遗留的 /home 首页标签)
function loadTabsFromStorage() {
try {
const savedTabs = localStorage.getItem('tabs_list');
const savedActiveTab = localStorage.getItem('active_tab');
if (savedTabs) {
const tabs = JSON.parse(savedTabs);
// 确保至少包含首页
const hasHome = tabs.some(t => t.fullPath === defaultDashboardPath);
if (!hasHome) {
tabs.unshift({ title: '首页', fullPath: defaultDashboardPath, name: 'Home' });
if (Array.isArray(tabs)) {
return tabs.filter(t => t && t.fullPath && t.fullPath !== '/home');
}
return tabs;
}
} catch (e) {
console.warn('恢复 tabs 失败:', e);
}
return [{ title: '首页', fullPath: defaultDashboardPath, name: 'Home' }];
return [];
}
// 保存 tabs 到 localStorage
@@ -95,7 +91,29 @@ export const useTabsStore = defineTabsStore('tabs', () => {
const tabList = vueRef(loadTabsFromStorage());
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,若已存在则激活
function addTab(tab) {
@@ -107,8 +125,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
saveTabsToStorage(tabList.value, activeTab.value);
}
// 删除指定tab并切换激活tab
// 删除指定tab并切换激活tab(默认仪表盘标签不可关闭)
function removeTab(fullPath) {
if (fullPath === defaultDashboardPath.value) return;
const idx = tabList.value.findIndex((t) => t.fullPath === fullPath);
if (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;
activeTab.value = tabList.value[newIdx].fullPath;
} else {
// 全部关闭,兜底首页
activeTab.value = defaultDashboardPath;
activeTab.value = defaultDashboardPath.value;
}
}
saveTabsToStorage(tabList.value, activeTab.value);
}
}
// 关闭其他,只留首页和当前激活tab
// 关闭其他,只留默认仪表盘和当前激活tab
function closeOthers() {
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);
}
// 关闭左侧(关闭指定tab左侧的所有tab,保留首页和目标tab)
// 关闭左侧(关闭指定tab左侧的所有tab,保留默认仪表盘和目标tab)
function closeLeft(targetFullPath) {
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
if (targetIndex > -1) {
// 保留首页和目标tab及其右侧的所有tab
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) {
tabList.value = tabList.value.filter((t, index) =>
t.fullPath === defaultDashboardPath || index >= targetIndex
t.fullPath === defaultDashboardPath.value || index >= targetIndex
);
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
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) {
const targetIndex = tabList.value.findIndex((t) => t.fullPath === targetFullPath);
if (targetIndex > -1) {
// 保留首页和目标tab及其左侧的所有tab
const afterIndex = tabList.value.slice(targetIndex + 1);
const hasCloseableRight = afterIndex.length > 0;
if (hasCloseableRight) {
tabList.value = tabList.value.filter((t, index) =>
t.fullPath === defaultDashboardPath || index <= targetIndex
t.fullPath === defaultDashboardPath.value || index <= targetIndex
);
// 如果关闭的tab中包含了当前激活的tab,则激活目标tab
if (!tabList.value.find(t => t.fullPath === activeTab.value)) {
@@ -177,10 +193,13 @@ export const useTabsStore = defineTabsStore('tabs', () => {
}
}
// 关闭全部,只留首页
// 关闭全部,只留默认仪表盘
function closeAll() {
tabList.value = tabList.value.filter((t) => t.fullPath === defaultDashboardPath);
activeTab.value = defaultDashboardPath;
const target = defaultDashboardPath.value || activeTab.value;
tabList.value = target
? tabList.value.filter((t) => t.fullPath === target)
: [];
activeTab.value = tabList.value.length ? target : '';
saveTabsToStorage(tabList.value, activeTab.value);
}
@@ -192,8 +211,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
// 重置 tabs store 到初始状态(登出时使用)
function resetTabs() {
tabList.value = [{ title: '首页', fullPath: defaultDashboardPath, name: 'Dashboard' }];
activeTab.value = defaultDashboardPath;
tabList.value = [];
activeTab.value = '';
defaultDashboardPath.value = '';
// 清除 localStorage 中的 tabs 数据
localStorage.removeItem('tabs_list');
localStorage.removeItem('active_tab');
@@ -202,7 +222,9 @@ export const useTabsStore = defineTabsStore('tabs', () => {
return {
tabList,
activeTab,
defaultDashboardPath,
addTab,
setDefaultTab,
removeTab,
closeOthers,
closeLeft,
+64
View File
@@ -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
View File
@@ -7,12 +7,35 @@ import { useRouter, useRoute } from 'vue-router';
import { ref, watch, reactive, nextTick, onMounted, onBeforeUnmount, computed } from 'vue';
import { More, Close, CircleClose, ArrowUp, Fold, Expand } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useAdminNavigation } from '@/composables/useAdminNavigation';
const tabsStore = useTabsStore();
const dictStore = useDictStore();
const router = useRouter();
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)';
@@ -144,9 +167,9 @@ watch(
const handleAsideMenuClick = async (menuItem) => {
const targetPath = menuItem.path;
if (targetPath === '/home') {
if (targetPath === '/home' || targetPath === '/') {
tabsStore.closeAll();
router.push('/home');
router.push(defaultDashboardPath.value || '/');
return;
}
@@ -203,7 +226,7 @@ const closeOthers = () => {
};
const closeAll = () => {
tabsStore.closeAll();
router.push(defaultDashboardPath);
router.push(defaultDashboardPath.value || route.fullPath);
};
// 主动监听tab激活,保证切tab时内容区切换(仅用于tab点击切换,菜单点击由handleAsideMenuClick处理)
// 使用 immediate: false 避免初始化时触发,使用 flush: 'post' 确保在 DOM 更新后执行
@@ -213,9 +236,9 @@ watch(
// 如果新值和当前路由路径不同,且不是初始化(oldVal 不为 undefined),才进行跳转
// 注意:这个watch主要用于处理tab点击切换,菜单点击由handleAsideMenuClick直接处理
if (newVal && oldVal !== undefined && router.currentRoute.value.fullPath !== newVal) {
// 如果切换到首页,关闭其他所有tab
if (newVal === '/home') {
tabsStore.closeAll();
// 切回默认仪表盘时,清掉其它标签页
if (newVal === defaultDashboardPath.value) {
tabsStore.closeOthers();
}
// 检查路由是否存在
@@ -391,7 +414,7 @@ function hideContextMenu() {
contextMenu.tab = null;
}
function closeTabContextTab() {
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath) {
if (contextMenu.tab && contextMenu.tab.fullPath !== defaultDashboardPath.value) {
tabsStore.removeTab(contextMenu.tab.fullPath);
}
hideContextMenu();
@@ -436,15 +459,15 @@ function closeOthersContextTab() {
function closeAllTabs() {
tabsStore.closeAll();
hideContextMenu();
router.push(defaultDashboardPath);
router.push(defaultDashboardPath.value || route.fullPath);
}
// 计算是否可以关闭左侧/右侧
const canCloseLeft = computed(() => {
if (!contextMenu.tab) return false;
const targetIndex = tabsStore.tabList.findIndex(t => t.fullPath === contextMenu.tab.fullPath);
// 至少左侧有一个可关闭的tab(排除首页)
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath);
// 至少左侧有一个可关闭的tab(排除默认仪表盘)
return targetIndex > 0 && tabsStore.tabList.slice(0, targetIndex).some(t => t.fullPath !== defaultDashboardPath.value);
});
const canCloseRight = computed(() => {
@@ -478,16 +501,17 @@ const canCloseRight = computed(() => {
></div>
</transition>
<el-container class="main-container">
<common-aside
class="common-aside-wrapper"
:class="{ 'mobile-open': sidebarVisible }"
@menu-click="handleAsideMenuClick"
/>
<el-container>
<el-header class="main-header">
<common-header />
</el-header>
<el-container class="main-container" direction="vertical">
<!-- 顶栏全宽:logo 位于页面真正的左上角 -->
<el-header class="main-header">
<common-header @menu-click="handleAsideMenuClick" />
</el-header>
<el-container class="main-body">
<common-aside
class="common-aside-wrapper"
:class="{ 'mobile-open': sidebarVisible }"
@menu-click="handleAsideMenuClick"
/>
<el-main class="right-main">
<div class="multi-tabs-wrapper">
<el-tabs
@@ -887,3 +911,108 @@ const canCloseRight = computed(() => {
pointer-events: auto !important;
}
</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>
+78 -39
View File
@@ -3,6 +3,10 @@
<div class="home-top">
<div class="toolbar">
<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" />
</div>
@@ -209,7 +213,7 @@
</template>
<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 { ElMessage, ElMessageBox } from "element-plus";
@@ -230,6 +234,7 @@ import { getCurrentUserProfile, logout } from "@/api/login";
import { reloadMenusAndDynamicRoutes } from "@/router";
import { useAuthStore } from "@/stores/auth";
import { useMenuStore } from "@/stores/menu";
import { getThemeMode, applyThemeMode, onSystemThemeChange } from "@/utils/theme";
import TenantSwitcher from "@/components/TenantSwitcher.vue";
interface ModuleItem {
@@ -271,40 +276,27 @@ const uncategorizedModules = computed(() =>
const moduleList = ref<ModuleItem[]>([]);
const refreshLoading = ref(false);
const THEME_STORAGE_KEY = "element-plus-theme";
// 与顶栏个人中心共用同一份主题模式(跟随系统 / 亮色 / 暗色)
const themeMode = ref(getThemeMode());
const isDark = ref(false);
const applyTheme = () => {
isDark.value = applyThemeMode(themeMode.value);
};
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();
};
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 = () => {
isDark.value = !isDark.value;
// 门户页的快捷按钮直接切换到明确的亮/暗模式
themeMode.value = isDark.value ? "light" : "dark";
applyTheme();
};
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
let stopSystemThemeWatch = null;
const userName = computed(
() => authStore.user?.name || authStore.user?.account || "用户",
@@ -480,6 +472,9 @@ onMounted(() => {
loadModules();
syncReturnedPayment();
initTheme();
stopSystemThemeWatch = onSystemThemeChange(() => {
if (themeMode.value === "system") applyTheme();
});
// 拉取当前登录用户的角色 / 部门 / 职位,用于右上角展示
if (authStore.token) {
fetchUserProfile();
@@ -489,12 +484,16 @@ onMounted(() => {
authStore.fetchCurrentUser();
}
});
onUnmounted(() => {
if (stopSystemThemeWatch) stopSystemThemeWatch();
});
</script>
<style scoped lang="less">
// 定义一些可复用的变量(可选,建议根据项目规范调整)
@primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@bg-gradient: linear-gradient(135deg, #f5f7fa 0%, #e4e8eb 100%);
@primary-gradient: linear-gradient(135deg, #4885ff 0%, #2f56e6 100%);
@bg-gradient: linear-gradient(135deg, #f4f7fd 0%, #e8effc 100%);
@text-main: #1a1a2e;
@text-regular: #606266;
@@ -522,10 +521,50 @@ onMounted(() => {
.toolbar-left {
display: flex;
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 组件):样式由组件自带,这里只调楼宇图标颜色
:deep(.tenant-chip .tenant-chip-icon) {
color: #3973ff;
color: var(--yz-primary);
}
}
@@ -542,7 +581,7 @@ onMounted(() => {
&:hover {
background: #f0f2f5;
border-color: #c0c4cc;
color: #3973ff;
color: var(--yz-primary);
}
}
@@ -609,7 +648,7 @@ onMounted(() => {
padding: 48px 56px;
margin-bottom: 40px;
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 {
position: relative;
@@ -691,7 +730,7 @@ onMounted(() => {
color: @text-main;
margin: 0 0 20px 0;
padding-left: 4px;
border-left: 4px solid #667eea;
border-left: 4px solid var(--yz-primary);
}
}
@@ -723,7 +762,7 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: center;
color: #3973ff;
color: var(--yz-primary);
font-size: 30px;
}
@@ -773,16 +812,16 @@ onMounted(() => {
.buy-btn {
flex-shrink: 0;
padding: 2px 10px;
border: 1px solid #3973ff;
border: 1px solid var(--yz-primary);
border-radius: 12px;
color: #3973ff;
color: var(--yz-primary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #3973ff;
border-color: #3973ff;
background: var(--yz-primary);
border-color: var(--yz-primary);
color: #fff;
}
}
@@ -836,8 +875,8 @@ onMounted(() => {
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
background-color: #3973ff;
box-shadow: 0 8px 24px rgba(47, 107, 255, 0.15);
background-color: var(--yz-primary);
.card-title,
.card-desc {
@@ -1170,7 +1209,7 @@ onMounted(() => {
&:hover {
background: #3d3d3d;
border-color: #4d4d4d;
color: #3973ff;
color: var(--yz-primary);
}
}
@@ -1194,7 +1233,7 @@ onMounted(() => {
.modules-section {
.category-title {
color: #f5f7fa;
border-left-color: #667eea;
border-left-color: var(--yz-primary);
}
.module-card {
@@ -1252,7 +1291,7 @@ onMounted(() => {
&.dark {
.modules-section .category-title {
color: #ffffff !important;
border-left-color: #667eea;
border-left-color: var(--yz-primary);
font-weight: 700;
}