From e92cd56bb56b111c1afe9922191f9897215ff6b0 Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期五, 28 八月 2026 17:55:27 +0800
Subject: [PATCH] 提交数据库和服务器部署说明文档

---
 frontend-admin/src/layouts/MainLayout.vue |  547 ++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 480 insertions(+), 67 deletions(-)

diff --git a/frontend-admin/src/layouts/MainLayout.vue b/frontend-admin/src/layouts/MainLayout.vue
index 839f2a9..5aa619b 100644
--- a/frontend-admin/src/layouts/MainLayout.vue
+++ b/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;

--
Gitblit v1.7.1