<template>
|
<el-container class="layout">
|
<el-aside width="15.98vw" class="side-wrap">
|
<div class="brand-block">
|
<div class="brand-icon"></div>
|
<div class="brand-name">运维运营管理平台</div>
|
</div>
|
<el-menu
|
:default-active="active"
|
:default-openeds="defaultOpeneds"
|
unique-opened
|
router
|
class="side-menu"
|
background-color="#245ECC"
|
text-color="#dce8ff"
|
active-text-color="#245ECC"
|
>
|
<el-menu-item v-if="auth.hasPermission('dashboard:page')" index="/dashboard" class="home-item">
|
<el-icon><House /></el-icon>
|
<span>首页</span>
|
</el-menu-item>
|
|
<el-sub-menu
|
v-if="
|
auth.hasAnyPermission([
|
'config:approval-settings:page',
|
'config:booking-settings:page',
|
'config:visit-reasons:page',
|
'config:content-settings:page',
|
])
|
"
|
index="system"
|
>
|
<template #title>
|
<el-icon><Setting /></el-icon>
|
<span>系统</span>
|
</template>
|
<el-menu-item v-if="auth.hasPermission('config:approval-settings:page')" index="/system/approval-settings">审核配置</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('config:booking-settings:page')" index="/system/booking-settings">预约管理</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('config:visit-reasons:page')" index="/system/visit-reasons">来访事由配置</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('config:content-settings:page')" index="/system/content-settings">内容设置</el-menu-item>
|
</el-sub-menu>
|
|
<el-sub-menu
|
v-if="auth.hasAnyPermission(['sys:depts:page', 'sys:staff:page', 'sys:staff-groups:page'])"
|
index="staff"
|
>
|
<template #title>
|
<el-icon><User /></el-icon>
|
<span>员工管理</span>
|
</template>
|
<el-menu-item v-if="auth.hasPermission('sys:depts:page')" index="/staff/depts">部门</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('sys:staff:page')" index="/staff/employees">员工管理</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('sys:staff-groups:page')" index="/staff/groups">人员分组</el-menu-item>
|
</el-sub-menu>
|
|
<el-sub-menu
|
v-if="auth.hasAnyPermission(['sys:pass-areas:page', 'sys:local-units:page'])"
|
index="region"
|
>
|
<template #title>
|
<el-icon><Grid /></el-icon>
|
<span>区域</span>
|
</template>
|
<el-menu-item v-if="auth.hasPermission('sys:pass-areas:page')" index="/region/pass-areas">通行区域管理</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('sys:local-units:page')" index="/region/local-units">属地单位管理</el-menu-item>
|
</el-sub-menu>
|
|
<el-menu-item v-if="auth.hasPermission('sys:visitor-users:page')" index="/visitor-info" class="group-link">
|
<el-icon><UserFilled /></el-icon>
|
<span>访客信息</span>
|
</el-menu-item>
|
|
<el-sub-menu
|
v-if="auth.hasAnyPermission(['appointments:page', 'approvals:page', 'exports:page'])"
|
index="logs"
|
>
|
<template #title>
|
<el-icon><Document /></el-icon>
|
<span>记录与日志</span>
|
</template>
|
<el-menu-item v-if="auth.hasPermission('appointments:page')" index="/appointments">预约记录</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('approvals:page')" index="/approvals">运维日志</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('exports:page')" index="/exports">操作日志</el-menu-item>
|
</el-sub-menu>
|
|
<el-sub-menu
|
v-if="auth.hasAnyPermission(['sys:users:page', 'sys:roles:page'])"
|
index="account"
|
>
|
<template #title>
|
<el-icon><Wallet /></el-icon>
|
<span>账号管理</span>
|
</template>
|
<el-menu-item v-if="auth.hasPermission('sys:users:page')" index="/users">平台管理员</el-menu-item>
|
<el-menu-item v-if="auth.hasPermission('sys:roles:page')" index="/roles">角色</el-menu-item>
|
</el-sub-menu>
|
</el-menu>
|
</el-aside>
|
<el-container class="main-shell">
|
<div class="top-wrap">
|
<el-header class="header">
|
<div class="header-spacer"></div>
|
<div class="header-right">
|
<el-button class="menu-trigger" text>
|
<el-icon><Operation /></el-icon>
|
</el-button>
|
<div class="user-box">
|
<el-avatar v-if="auth.avatarUrl" :size="30" :src="auth.avatarUrl" class="hdr-av" />
|
<el-avatar v-else :size="30" class="hdr-av">{{ nameInitial }}</el-avatar>
|
<span class="user-name">{{ auth.displayName }}</span>
|
<el-button class="logout-trigger" text @click="confirmLogout">
|
<el-icon><SwitchButton /></el-icon>
|
</el-button>
|
</div>
|
</div>
|
</el-header>
|
|
<div class="tabs-bar">
|
<div
|
v-for="tab in tabs"
|
:key="tab.path"
|
class="route-tab"
|
:class="{ active: tab.path === active }"
|
@click="goTab(tab.path)"
|
@contextmenu.prevent="openTabContextMenu($event, tab.path)"
|
>
|
<el-icon class="tab-home" v-if="tab.path === '/dashboard'"><House /></el-icon>
|
<span>{{ tab.title }}</span>
|
<button
|
v-if="tab.closable"
|
type="button"
|
class="tab-close"
|
@click.stop="closeTab(tab.path)"
|
>
|
x
|
</button>
|
</div>
|
<div
|
v-if="tabContextMenu.visible"
|
class="tab-context-menu"
|
:style="{ left: `${tabContextMenu.x}px`, top: `${tabContextMenu.y}px` }"
|
>
|
<button type="button" class="tab-context-item" @click="closeLeftTabs">关闭左边</button>
|
<button type="button" class="tab-context-item" @click="closeRightTabs">关闭右边</button>
|
<button type="button" class="tab-context-item" @click="closeOtherTabs">关闭其他</button>
|
</div>
|
</div>
|
</div>
|
|
<el-main class="main-area">
|
<router-view />
|
</el-main>
|
</el-container>
|
</el-container>
|
</template>
|
|
<script setup lang="ts">
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
import { useRoute, useRouter } from "vue-router";
|
import { ElMessageBox } from "element-plus";
|
import { Document, Grid, House, Operation, Setting, SwitchButton, User, UserFilled, Wallet } from "@element-plus/icons-vue";
|
import { useAuthStore } from "../stores/auth";
|
import http from "../api/http";
|
|
type LayoutTab = {
|
path: string;
|
title: string;
|
closable: boolean;
|
};
|
|
const route = useRoute();
|
const router = useRouter();
|
const auth = useAuthStore();
|
const active = computed(() => route.path);
|
const nameInitial = computed(() => {
|
const n = auth.displayName;
|
return n && n.length ? n.slice(0, 1) : "?";
|
});
|
const defaultOpeneds = computed(() => {
|
const path = route.path;
|
if (path.startsWith("/system/")) return ["system"];
|
if (path.startsWith("/staff/")) return ["staff"];
|
if (path.startsWith("/region/")) return ["region"];
|
if (path === "/appointments" || path === "/approvals" || path === "/exports") return ["logs"];
|
if (path === "/users" || path.startsWith("/roles")) return ["account"];
|
return [];
|
});
|
const tabs = ref<LayoutTab[]>([{ path: "/dashboard", title: "首页", closable: false }]);
|
const tabContextMenu = reactive({
|
visible: false,
|
x: 0,
|
y: 0,
|
path: "",
|
});
|
const routeTitleMap: Record<string, string> = {
|
"/dashboard": "首页",
|
"/appointments": "预约记录",
|
"/approvals": "运维日志",
|
"/exports": "操作日志",
|
"/system/approval-settings": "审核配置",
|
"/system/booking-settings": "预约配置",
|
"/system/visit-reasons": "来访事由配置",
|
"/system/content-settings": "内容设置",
|
"/staff/depts": "部门管理",
|
"/staff/employees": "员工管理",
|
"/staff/groups": "人员分组",
|
"/visitor-info": "访客信息",
|
"/users": "平台管理员",
|
"/roles": "角色",
|
"/region/pass-areas": "通行区域管理",
|
"/region/local-units": "属地单位管理",
|
};
|
const hiddenTabRouteNames = new Set(["staff-group-detail", "role-detail"]);
|
function resolveRouteTitle() {
|
if (typeof route.meta.title === "string" && route.meta.title) {
|
return route.meta.title;
|
}
|
if (routeTitleMap[route.path]) {
|
return routeTitleMap[route.path];
|
}
|
return "页面";
|
}
|
|
function ensureTab() {
|
if (typeof route.name === "string" && hiddenTabRouteNames.has(route.name)) {
|
return;
|
}
|
const path = route.path;
|
const title = resolveRouteTitle();
|
const idx = tabs.value.findIndex((item) => item.path === path);
|
if (idx >= 0) {
|
tabs.value[idx] = { ...tabs.value[idx], title };
|
return;
|
}
|
tabs.value.push({
|
path,
|
title,
|
closable: path !== "/dashboard",
|
});
|
}
|
|
function goTab(path: string) {
|
if (path !== route.path) {
|
router.push(path);
|
}
|
}
|
|
function closeTab(path: string) {
|
hideTabContextMenu();
|
const idx = tabs.value.findIndex((item) => item.path === path);
|
if (idx < 0) {
|
return;
|
}
|
const isCurrent = route.path === path;
|
tabs.value.splice(idx, 1);
|
if (!isCurrent) {
|
return;
|
}
|
const fallback = tabs.value[idx] || tabs.value[idx - 1] || tabs.value[0];
|
router.push(fallback?.path || "/dashboard");
|
}
|
|
function openTabContextMenu(event: MouseEvent, path: string) {
|
tabContextMenu.visible = true;
|
tabContextMenu.x = event.clientX;
|
tabContextMenu.y = event.clientY;
|
tabContextMenu.path = path;
|
}
|
|
function hideTabContextMenu() {
|
tabContextMenu.visible = false;
|
tabContextMenu.path = "";
|
}
|
|
function closeTabsByFilter(keep: (tab: LayoutTab, index: number) => boolean) {
|
const currentPath = route.path;
|
tabs.value = tabs.value.filter((tab, index) => !tab.closable || keep(tab, index));
|
if (!tabs.value.some((tab) => tab.path === currentPath)) {
|
const fallback = tabs.value[tabs.value.length - 1] || tabs.value[0];
|
router.push(fallback?.path || "/dashboard");
|
}
|
hideTabContextMenu();
|
}
|
|
function closeLeftTabs() {
|
const targetIndex = tabs.value.findIndex((tab) => tab.path === tabContextMenu.path);
|
if (targetIndex < 0) {
|
hideTabContextMenu();
|
return;
|
}
|
closeTabsByFilter((tab, index) => index >= targetIndex || !tab.closable);
|
}
|
|
function closeRightTabs() {
|
const targetIndex = tabs.value.findIndex((tab) => tab.path === tabContextMenu.path);
|
if (targetIndex < 0) {
|
hideTabContextMenu();
|
return;
|
}
|
closeTabsByFilter((tab, index) => index <= targetIndex || !tab.closable);
|
}
|
|
function closeOtherTabs() {
|
const targetPath = tabContextMenu.path;
|
if (!targetPath) {
|
hideTabContextMenu();
|
return;
|
}
|
closeTabsByFilter((tab) => tab.path === targetPath || !tab.closable);
|
}
|
|
function handleGlobalClick() {
|
hideTabContextMenu();
|
}
|
|
watch(
|
() => route.path,
|
() => {
|
ensureTab();
|
},
|
{ immediate: true }
|
);
|
|
onMounted(() => {
|
window.addEventListener("click", handleGlobalClick);
|
});
|
|
onBeforeUnmount(() => {
|
window.removeEventListener("click", handleGlobalClick);
|
});
|
|
async function logout() {
|
try {
|
await http.post("/auth/logout");
|
} catch {
|
/* ignore */
|
}
|
auth.clear();
|
router.push("/login");
|
}
|
|
async function confirmLogout() {
|
try {
|
await ElMessageBox.confirm("确认退出当前账号吗?", "退出确认", {
|
confirmButtonText: "确认",
|
cancelButtonText: "取消",
|
type: "warning",
|
});
|
} catch {
|
return;
|
}
|
await logout();
|
}
|
</script>
|
|
<style scoped>
|
.layout {
|
height: 100%;
|
}
|
|
.main-shell {
|
display: flex;
|
flex-direction: column;
|
min-width: 0;
|
}
|
|
.side-wrap {
|
background: #245ECC;
|
color: #fff;
|
padding: 0 10px 12px;
|
box-sizing: border-box;
|
}
|
|
.brand-block {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
height: 44px;
|
margin: 0 -10px 10px;
|
padding: 0 16px;
|
background: rgba(18, 55, 138, 0.28);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
}
|
|
.brand-icon {
|
width: 18px;
|
height: 18px;
|
border-radius: 50%;
|
background: rgba(255, 255, 255, 0.4);
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08);
|
}
|
|
.brand-name {
|
font-size: 16px;
|
font-weight: 600;
|
}
|
|
.side-menu {
|
border-right: 0;
|
background: transparent;
|
}
|
|
.side-menu :deep(.el-menu) {
|
border-right: 0;
|
background: transparent;
|
}
|
|
.side-menu :deep(.el-sub-menu__title),
|
.side-menu :deep(.el-menu-item) {
|
height: 42px;
|
line-height: 42px;
|
border-radius: 8px;
|
margin: 4px 8px;
|
}
|
|
.side-menu :deep(.el-sub-menu__title:hover),
|
.side-menu :deep(.el-menu-item:hover) {
|
background: rgba(255, 255, 255, 0.08) !important;
|
}
|
|
.side-menu :deep(.el-menu-item.is-active) {
|
background: #ffffff !important;
|
color: #245ECC !important;
|
}
|
|
.side-menu :deep(.el-sub-menu .el-menu-item) {
|
min-width: 0;
|
padding-left: 48px !important;
|
font-size: 13px;
|
}
|
|
.side-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
color: #ffffff !important;
|
}
|
|
.home-item {
|
margin-bottom: 8px !important;
|
font-weight: 600;
|
}
|
|
.group-link {
|
color: #dce8ff !important;
|
}
|
|
.top-wrap {
|
display: flex;
|
flex-direction: column;
|
width: 100%;
|
background: #eef3ff;
|
border-bottom: 1px solid #dbe5fb;
|
}
|
|
.hdr-av {
|
flex-shrink: 0;
|
}
|
|
.header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
height: 60px;
|
width: 84vw;
|
padding: 0 12px 0 14px;
|
background: #ffffff;
|
border-bottom: 1px solid #e7edf8;
|
box-sizing: border-box;
|
}
|
|
.header-spacer,
|
.header-right,
|
.user-box {
|
display: flex;
|
align-items: center;
|
}
|
|
.header-spacer {
|
flex: 1 1 auto;
|
}
|
|
.header-right {
|
gap: 10px;
|
}
|
|
.menu-trigger {
|
color: #5e6f95;
|
}
|
|
.user-box {
|
gap: 8px;
|
}
|
|
.logout-trigger {
|
color: #5e6f95;
|
padding: 0;
|
}
|
|
.user-name {
|
color: #47587c;
|
font-size: 13px;
|
}
|
|
.tabs-bar {
|
position: relative;
|
display: flex;
|
align-items: center;
|
gap: 6px;
|
width: 100%;
|
min-height: 36px;
|
padding: 0 10px;
|
background: #edf3ff;
|
box-sizing: border-box;
|
overflow-x: auto;
|
overflow-y: hidden;
|
}
|
|
.route-tab {
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
flex: 0 0 auto;
|
height: 28px;
|
padding: 0 12px;
|
border-radius: 6px 6px 0 0;
|
color: #7b8cae;
|
font-size: 13px;
|
cursor: pointer;
|
user-select: none;
|
border: 1px solid transparent;
|
border-bottom: 0;
|
}
|
|
.route-tab.active {
|
background: #ffffff;
|
color: #4f74d8;
|
border-color: #d8e3fb;
|
}
|
|
.tab-home {
|
font-size: 12px;
|
}
|
|
.tab-close {
|
border: 0;
|
padding: 0;
|
background: transparent;
|
color: inherit;
|
font-size: 12px;
|
cursor: pointer;
|
}
|
|
.tab-context-menu {
|
position: fixed;
|
z-index: 1200;
|
min-width: 120px;
|
padding: 6px 0;
|
border: 1px solid #d8e3fb;
|
border-radius: 8px;
|
background: #ffffff;
|
box-shadow: 0 8px 24px rgba(52, 84, 148, 0.16);
|
}
|
|
.tab-context-item {
|
display: block;
|
width: 100%;
|
border: 0;
|
padding: 8px 14px;
|
background: transparent;
|
color: #4e5f82;
|
font-size: 13px;
|
text-align: left;
|
cursor: pointer;
|
}
|
|
.tab-context-item:hover {
|
background: #f3f7ff;
|
color: #4f74d8;
|
}
|
|
.main-area {
|
background: linear-gradient(180deg, #f3f7ff 0%, #eef3fd 100%);
|
padding: 14px;
|
}
|
|
.side-menu :deep(.el-icon) {
|
margin-right: 8px;
|
}
|
</style>
|