| | |
| | | <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> |
| | | <img :src="siteLogo" alt="logo" class="brand-icon" /> |
| | | <div class="brand-name">红宇集团访客预约管理系统</div> |
| | | </div> |
| | | <el-menu |
| | | :default-active="active" |
| | |
| | | > |
| | | <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> |
| | |
| | | <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-menu-item v-if="auth.hasPermission('approvals:page')" index="/approvals">操作日志</el-menu-item> |
| | | </el-sub-menu> |
| | | |
| | | <el-sub-menu |
| | |
| | | <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> |
| | | |
| | | <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> |
| | | |
| | |
| | | </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, 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 { 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; |
| | | title: string; |
| | | closable: boolean; |
| | | }; |
| | | |
| | | type CurrentUserDetail = { |
| | | id: number; |
| | | uuid: string; |
| | | username: string; |
| | | realName?: string; |
| | | mobile?: string; |
| | | avatarUrl?: string; |
| | | }; |
| | | |
| | | const route = useRoute(); |
| | |
| | | if (path === "/users" || path.startsWith("/roles")) return ["account"]; |
| | | return []; |
| | | }); |
| | | const tabs = ref<LayoutTab[]>([{ path: "/dashboard", title: "首页", closable: false }]); |
| | | 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": "人员分组", |
| | |
| | | |
| | | onMounted(() => { |
| | | window.addEventListener("click", handleGlobalClick); |
| | | if (auth.token && auth.userId == null) { |
| | | void loadCurrentUser(false); |
| | | } |
| | | }); |
| | | |
| | | onBeforeUnmount(() => { |
| | |
| | | } |
| | | 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> |
| | | |
| | | <style scoped> |
| | |
| | | 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-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) { |
| | |
| | | color: #5e6f95; |
| | | } |
| | | |
| | | .menu-trigger-icon { |
| | | display: block; |
| | | width: 18px; |
| | | height: 18px; |
| | | object-fit: contain; |
| | | } |
| | | |
| | | .user-box { |
| | | gap: 8px; |
| | | } |
| | |
| | | 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; |