| | |
| | | <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 |
| | |
| | | > |
| | | <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 |
| | |
| | | > |
| | | <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> |
| | |
| | | </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'])" |
| | |
| | | <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> |
| | | |
| | |
| | | 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> |
| | |
| | | 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> |
| | | </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; |
| | |
| | | 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": "人员分组", |
| | |
| | | "/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; |
| | |
| | | } |
| | | |
| | | 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); |
| | |
| | | } |
| | | |
| | | function closeTab(path: string) { |
| | | hideTabContextMenu(); |
| | | const idx = tabs.value.findIndex((item) => item.path === path); |
| | | if (idx < 0) { |
| | | return; |
| | |
| | | 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, |
| | | () => { |
| | |
| | | }, |
| | | { 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 { |
| | |
| | | } |
| | | 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> |
| | | |
| | |
| | | } |
| | | |
| | | .side-wrap { |
| | | background: linear-gradient(180deg, #2f63d4 0%, #2d5fce 100%); |
| | | background: #245ECC; |
| | | color: #fff; |
| | | padding: 0 10px 12px; |
| | | box-sizing: border-box; |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | |
| | | .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) { |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | 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; |