longz
2026-04-27 25df88d0ab9d7444ed1d162b9efa1259ac2f6f66
frontend-admin/src/layouts/MainLayout.vue
@@ -8,6 +8,7 @@
      <el-menu
        :default-active="active"
        :default-openeds="defaultOpeneds"
        unique-opened
        router
        class="side-menu"
        background-color="#2f63d4"
@@ -76,12 +77,12 @@
        >
          <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>
              <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'])"
@@ -125,6 +126,7 @@
            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>
@@ -137,6 +139,15 @@
              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>
@@ -148,7 +159,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Document, Grid, House, Operation, Setting, User, UserFilled, Wallet } from "@element-plus/icons-vue";
import { useAuthStore } from "../stores/auth";
@@ -164,12 +175,27 @@
const router = useRouter();
const auth = useAuthStore();
const active = computed(() => route.path);
const defaultOpeneds: string[] = [];
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": "待办日志",
  "/approvals": "运维日志",
  "/exports": "操作日志",
  "/system/approval-settings": "审核配置",
  "/system/booking-settings": "预约配置",
  "/system/visit-reasons": "来访事由配置",
@@ -183,6 +209,7 @@
  "/region/pass-areas": "通行区域管理",
  "/region/local-units": "属地单位管理",
};
const hiddenTabRouteNames = new Set(["staff-group-detail", "role-detail"]);
const nameInitial = computed(() => {
  const n = auth.displayName;
  return n && n.length ? n.slice(0, 1) : "?";
@@ -199,6 +226,9 @@
}
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);
@@ -220,6 +250,7 @@
}
function closeTab(path: string) {
  hideTabContextMenu();
  const idx = tabs.value.findIndex((item) => item.path === path);
  if (idx < 0) {
    return;
@@ -233,6 +264,59 @@
  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,
  () => {
@@ -240,6 +324,14 @@
  },
  { immediate: true }
);
onMounted(() => {
  window.addEventListener("click", handleGlobalClick);
});
onBeforeUnmount(() => {
  window.removeEventListener("click", handleGlobalClick);
});
async function logout() {
  try {
@@ -405,6 +497,7 @@
}
.tabs-bar {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
@@ -452,6 +545,34 @@
  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;