puzhibing
2026-08-28 e92cd56bb56b111c1afe9922191f9897215ff6b0
frontend-admin/src/layouts/MainLayout.vue
@@ -1,22 +1,23 @@
<template>
  <el-container class="layout">
    <el-aside width="248px" class="side-wrap">
    <el-aside width="15.98vw" class="side-wrap">
      <div class="brand-block">
        <div class="brand-icon"></div>
        <div class="brand-name">运维运营管理平台</div>
        <img :src="siteLogo" alt="logo" class="brand-icon" />
        <div class="brand-name">红宇集团访客预约管理系统</div>
      </div>
      <el-menu
        :default-active="active"
        :default-openeds="defaultOpeneds"
        unique-opened
        router
        class="side-menu"
        background-color="#2f63d4"
        background-color="#245ECC"
        text-color="#dce8ff"
        active-text-color="#2f63d4"
        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>
          <span>首页统计</span>
        </el-menu-item>
        <el-sub-menu
@@ -32,12 +33,13 @@
        >
          <template #title>
            <el-icon><Setting /></el-icon>
            <span>系统</span>
            <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-menu-item v-if="auth.hasPermission('config:sensitive-words:page')" index="/system/sensitive-words">敏感词库</el-menu-item>
        </el-sub-menu>
        <el-sub-menu
@@ -59,7 +61,7 @@
        >
          <template #title>
            <el-icon><Grid /></el-icon>
            <span>区域</span>
            <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>
@@ -71,17 +73,16 @@
        </el-menu-item>
        <el-sub-menu
          v-if="auth.hasAnyPermission(['appointments:page', 'approvals:page', 'exports:page'])"
          v-if="auth.hasAnyPermission(['appointments:page', 'approvals: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>
              <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-sub-menu>
        <el-sub-menu
          v-if="auth.hasAnyPermission(['sys:users:page', 'sys:roles:page'])"
@@ -99,22 +100,23 @@
    <el-container class="main-shell">
      <div class="top-wrap">
        <el-header class="header">
          <div class="header-left">
            <div class="header-title">
              <span class="header-dot"></span>
              <span>运维运营管理平台</span>
            </div>
          </div>
          <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="28" :src="auth.avatarUrl" class="hdr-av" />
              <el-avatar v-else :size="28" class="hdr-av">{{ nameInitial }}</el-avatar>
              <span class="user-name">{{ auth.displayName }}</span>
            </div>
            <el-button type="primary" link @click="logout">退出</el-button>
            <el-dropdown trigger="click" @command="handleUserCommand">
              <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>
              </div>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item command="profile">账号设置</el-dropdown-item>
                  <el-dropdown-item command="password">修改密码</el-dropdown-item>
                  <el-dropdown-item command="logout">退出登录</el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
          </div>
        </el-header>
@@ -125,6 +127,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 +140,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>
@@ -145,14 +157,89 @@
      </el-main>
    </el-container>
  </el-container>
  <el-dialog v-model="profileDialogVisible" title="账号设置" width="520px" destroy-on-close>
    <el-form v-loading="profileLoading" :model="profileForm" label-width="100px">
      <el-form-item label="登录名">
        <el-input v-model="profileForm.username" disabled />
      </el-form-item>
      <el-form-item label="姓名">
        <el-input v-model="profileForm.realName" maxlength="64" />
      </el-form-item>
      <el-form-item label="手机">
        <el-input v-model="profileForm.mobile" maxlength="32" />
      </el-form-item>
      <el-form-item label="头像">
        <div class="avatar-upload-row">
          <el-avatar v-if="profileForm.avatarUrl" :size="64" :src="profileForm.avatarUrl" />
          <el-avatar v-else :size="64">{{ (profileForm.realName || profileForm.username || "?").slice(0, 1) }}</el-avatar>
          <div class="avatar-upload-actions">
            <el-upload
              :show-file-list="false"
              accept=".jpg,.jpeg,.png,.webp,.gif"
              :http-request="uploadProfileAvatar"
            >
              <el-button :loading="profileAvatarUploading">上传头像</el-button>
            </el-upload>
            <el-button v-if="profileForm.avatarUrl" link type="danger" @click="profileForm.avatarUrl = ''">清除</el-button>
            <div class="avatar-upload-tip">支持 jpg、png、webp、gif,大小不超过 5MB</div>
          </div>
        </div>
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="profileDialogVisible = false">取消</el-button>
      <el-button type="primary" :loading="profileSaving" @click="submitProfile">保存</el-button>
    </template>
  </el-dialog>
  <el-dialog v-model="passwordDialogVisible" title="修改密码" width="560px" destroy-on-close @closed="resetPasswordForm">
    <el-form :model="passwordForm" label-width="120px">
      <el-form-item label="原密码" required>
        <el-input
          v-model="passwordForm.oldPassword"
          type="password"
          show-password
          autocomplete="current-password"
          placeholder="请输入原密码"
        />
      </el-form-item>
      <el-form-item label="新密码" required>
        <el-input
          v-model="passwordForm.newPassword"
          type="password"
          show-password
          autocomplete="new-password"
          placeholder="请输入至少12位含大小写字母、特殊字符的密码"
        />
      </el-form-item>
      <el-form-item label="确认新密码" required>
        <el-input
          v-model="passwordForm.newPasswordConfirm"
          type="password"
          show-password
          autocomplete="new-password"
          placeholder="请输入至少12位含大小写字母、特殊字符的密码"
        />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="passwordDialogVisible = false">取消</el-button>
      <el-button type="primary" :loading="passwordSaving" @click="submitPassword">确定</el-button>
    </template>
  </el-dialog>
</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 { ElMessage, ElMessageBox } from "element-plus";
import type { UploadRequestOptions } from "element-plus";
import { Document, Grid, House, Setting, User, UserFilled, Wallet } from "@element-plus/icons-vue";
import { useAuthStore } from "../stores/auth";
import http from "../api/http";
import menuFoldIcon from "../static/img/MenuFold.png";
import siteLogo from "../static/logo.jpg";
type LayoutTab = {
  path: string;
@@ -160,20 +247,68 @@
  closable: boolean;
};
type CurrentUserDetail = {
  id: number;
  uuid: string;
  username: string;
  realName?: string;
  mobile?: string;
  avatarUrl?: string;
};
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const active = computed(() => route.path);
const defaultOpeneds: string[] = [];
const tabs = ref<LayoutTab[]>([{ path: "/dashboard", title: "首页", closable: false }]);
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 profileDialogVisible = ref(false);
const passwordDialogVisible = ref(false);
const profileLoading = ref(false);
const profileSaving = ref(false);
const passwordSaving = ref(false);
const profileAvatarUploading = ref(false);
const tabContextMenu = reactive({
  visible: false,
  x: 0,
  y: 0,
  path: "",
});
const profileForm = reactive({
  id: 0,
  uuid: "",
  username: "",
  realName: "",
  mobile: "",
  avatarUrl: "",
});
const passwordForm = reactive({
  oldPassword: "",
  newPassword: "",
  newPasswordConfirm: "",
});
const routeTitleMap: Record<string, string> = {
  "/dashboard": "首页",
  "/dashboard": "首页统计",
  "/appointments": "预约记录",
  "/approvals": "待办日志",
  "/approvals": "操作日志",
  "/exports": "操作日志",
  "/system/approval-settings": "审核配置",
  "/system/booking-settings": "预约配置",
  "/system/visit-reasons": "来访事由配置",
  "/system/content-settings": "内容设置",
  "/system/sensitive-words": "敏感词库",
  "/staff/depts": "部门管理",
  "/staff/employees": "员工管理",
  "/staff/groups": "人员分组",
@@ -183,11 +318,7 @@
  "/region/pass-areas": "通行区域管理",
  "/region/local-units": "属地单位管理",
};
const nameInitial = computed(() => {
  const n = auth.displayName;
  return n && n.length ? n.slice(0, 1) : "?";
});
const hiddenTabRouteNames = new Set(["staff-group-detail", "role-detail"]);
function resolveRouteTitle() {
  if (typeof route.meta.title === "string" && route.meta.title) {
    return route.meta.title;
@@ -199,6 +330,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 +354,7 @@
}
function closeTab(path: string) {
  hideTabContextMenu();
  const idx = tabs.value.findIndex((item) => item.path === path);
  if (idx < 0) {
    return;
@@ -233,6 +368,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 +428,17 @@
  },
  { immediate: true }
);
onMounted(() => {
  window.addEventListener("click", handleGlobalClick);
  if (auth.token && auth.userId == null) {
    void loadCurrentUser(false);
  }
});
onBeforeUnmount(() => {
  window.removeEventListener("click", handleGlobalClick);
});
async function logout() {
  try {
@@ -249,6 +448,171 @@
  }
  auth.clear();
  router.push("/login");
}
async function confirmLogout() {
  try {
    await ElMessageBox.confirm("确认退出当前账号吗?", "退出确认", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    });
  } catch {
    return;
  }
  await logout();
}
async function loadCurrentUser(showError = true) {
  profileLoading.value = true;
  try {
    const { data } = await http.get("/auth/me");
    const detail = (data.result || {}) as CurrentUserDetail;
    profileForm.id = detail.id || 0;
    profileForm.uuid = detail.uuid || "";
    profileForm.username = detail.username || "";
    profileForm.realName = detail.realName || "";
    profileForm.mobile = detail.mobile || "";
    profileForm.avatarUrl = detail.avatarUrl || "";
    syncAuthProfile(detail);
    return detail;
  } catch (e: unknown) {
    if (showError) {
      ElMessage.error(e instanceof Error ? e.message : "加载账号信息失败");
    }
    return null;
  } finally {
    profileLoading.value = false;
  }
}
function syncAuthProfile(detail: CurrentUserDetail) {
  if (!auth.token) {
    return;
  }
  auth.setSession(
    auth.token,
    detail.realName?.trim() || detail.username || auth.displayName || "",
    detail.avatarUrl || null,
    auth.permissions,
    detail.id,
  );
}
async function openProfileDialog() {
  profileDialogVisible.value = true;
  await loadCurrentUser();
}
function openPasswordDialog() {
  resetPasswordForm();
  passwordDialogVisible.value = true;
}
function handleUserCommand(command: string | number | object) {
  const value = String(command);
  if (value === "profile") {
    void openProfileDialog();
    return;
  }
  if (value === "password") {
    openPasswordDialog();
    return;
  }
  if (value === "logout") {
    void confirmLogout();
  }
}
async function submitProfile() {
  profileSaving.value = true;
  try {
    const { data } = await http.put("/auth/me", {
      realName: profileForm.realName.trim() || undefined,
      mobile: profileForm.mobile.trim() || undefined,
      avatarUrl: profileForm.avatarUrl.trim() || undefined,
    });
    const detail = (data.result || {}) as CurrentUserDetail;
    profileForm.id = detail.id || profileForm.id;
    profileForm.uuid = detail.uuid || profileForm.uuid;
    profileForm.username = detail.username || profileForm.username;
    profileForm.realName = detail.realName || "";
    profileForm.mobile = detail.mobile || "";
    profileForm.avatarUrl = detail.avatarUrl || "";
    syncAuthProfile({
      ...detail,
      username: detail.username || profileForm.username,
      id: detail.id || profileForm.id,
    });
    profileDialogVisible.value = false;
    ElMessage.success("账号信息已保存");
  } catch (e: unknown) {
    ElMessage.error(e instanceof Error ? e.message : "保存失败");
  } finally {
    profileSaving.value = false;
  }
}
function resetPasswordForm() {
  passwordForm.oldPassword = "";
  passwordForm.newPassword = "";
  passwordForm.newPasswordConfirm = "";
}
function isStrongPassword(value: string) {
  return (
    value.length >= 12
    && value.length <= 64
    && /[a-z]/.test(value)
    && /[A-Z]/.test(value)
    && /[^A-Za-z0-9]/.test(value)
  );
}
async function submitPassword() {
  if (!passwordForm.oldPassword.trim()) {
    ElMessage.warning("请输入原密码");
    return;
  }
  if (!isStrongPassword(passwordForm.newPassword)) {
    ElMessage.warning("请输入至少12位含大小写字母、特殊字符的密码");
    return;
  }
  if (passwordForm.newPassword !== passwordForm.newPasswordConfirm) {
    ElMessage.warning("两次输入的新密码不一致");
    return;
  }
  passwordSaving.value = true;
  try {
    await http.post("/auth/me/password", {
      oldPassword: passwordForm.oldPassword,
      newPassword: passwordForm.newPassword,
      newPasswordConfirm: passwordForm.newPasswordConfirm,
    });
    passwordDialogVisible.value = false;
    ElMessage.success("密码已修改");
  } catch (e: unknown) {
    ElMessage.error(e instanceof Error ? e.message : "修改失败");
  } finally {
    passwordSaving.value = false;
  }
}
async function uploadProfileAvatar(opt: UploadRequestOptions) {
  const fd = new FormData();
  fd.append("file", opt.file as File);
  profileAvatarUploading.value = true;
  try {
    const { data } = await http.post("/common/upload/image", fd, {
      headers: { "Content-Type": "multipart/form-data" },
    });
    profileForm.avatarUrl = String(data.result?.url || "");
    ElMessage.success("头像上传成功");
  } catch (e: unknown) {
    ElMessage.error(e instanceof Error ? e.message : "头像上传失败");
  } finally {
    profileAvatarUploading.value = false;
  }
}
</script>
@@ -264,7 +628,7 @@
}
.side-wrap {
  background: linear-gradient(180deg, #2f63d4 0%, #2d5fce 100%);
  background: #245ECC;
  color: #fff;
  padding: 0 10px 12px;
  box-sizing: border-box;
@@ -274,18 +638,18 @@
  display: flex;
  align-items: center;
  gap: 10px;
  height: 44px;
  height: 59px;
  margin: 0 -10px 10px;
  padding: 0 16px;
  background: rgba(18, 55, 138, 0.28);
  background: #245ECC;
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.brand-icon {
  width: 18px;
  height: 18px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.4);
  object-fit: cover;
  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08);
}
@@ -319,13 +683,13 @@
.side-menu :deep(.el-menu-item.is-active) {
  background: #ffffff !important;
  color: #2f63d4 !important;
  color: #245ECC !important;
}
.side-menu :deep(.el-sub-menu .el-menu-item) {
  min-width: 0;
  padding-left: 48px !important;
  font-size: 13px;
  font-size: 14px;
}
.side-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
@@ -357,34 +721,23 @@
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 44px;
  width: 100%;
  height: 60px;
  width: 84vw;
  padding: 0 12px 0 14px;
  background: #ffffff;
  border-bottom: 1px solid #e7edf8;
  box-sizing: border-box;
}
.header-left,
.header-spacer,
.header-right,
.user-box,
.header-title {
.user-box {
  display: flex;
  align-items: center;
}
.header-title {
  gap: 10px;
  color: #3d5d9e;
  font-size: 14px;
  font-weight: 600;
}
.header-dot {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: linear-gradient(135deg, #7ca9ff, #4f7dff);
.header-spacer {
  flex: 1 1 auto;
}
.header-right {
@@ -395,8 +748,20 @@
  color: #5e6f95;
}
.menu-trigger-icon {
  display: block;
  width: 18px;
  height: 18px;
  object-fit: contain;
}
.user-box {
  gap: 8px;
}
.logout-trigger {
  color: #5e6f95;
  padding: 0;
}
.user-name {
@@ -404,12 +769,32 @@
  font-size: 13px;
}
.avatar-upload-row {
  display: flex;
  align-items: center;
  gap: 14px;
}
.avatar-upload-actions {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.avatar-upload-tip {
  color: #8a97b3;
  font-size: 12px;
  line-height: 1.4;
}
.tabs-bar {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  min-height: 36px;
  min-height: 46px;
  padding: 0 10px;
  background: #edf3ff;
  box-sizing: border-box;
@@ -452,6 +837,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;