Files
xh-medical-sign-web/clinical-web/src/components/layout/Menu.vue
T

556 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AccountPanel from './AccountPanel.vue'
import { RouterLink } from 'vue-router'
import { useLoginStore } from '@/stores/login'
const loginStore = useLoginStore()
const accountPanel = ref<InstanceType<typeof AccountPanel>>()
const accountMenuOpen = ref(false)
const logoutConfirmOpen = ref(false)
const loggingOut = ref(false)
const identity = computed(() => {
const labels = [
...(loginStore.user?.roles?.map((role) => role.name) ?? []),
loginStore.user?.position,
loginStore.user?.professionalTitle,
].filter(Boolean)
return [...new Set(labels)].join(' · ') || loginStore.department
})
function openAccount(kind: 'profile' | 'password') {
accountMenuOpen.value = false
void accountPanel.value?.open(kind)
}
const visibleMenuGroups = computed(() =>
loginStore.visibleNavigation
.map((directory) => ({
label: directory.name,
items: (directory.children ?? [])
.filter(
(menu) =>
menu.resourceType === 'MENU' &&
menu.status === 'ENABLED' &&
menu.visible &&
Boolean(menu.routePath) &&
Boolean(menu.code) &&
loginStore.hasPermission(menu.code ?? ''),
)
.map((menu) => ({
path: menu.routePath as string,
label: menu.name,
icon: menu.icon,
})),
}))
.filter((group) => group.items.length),
)
/** 打开二次确认(确认弹窗用页面通用样式,不再走 ElMessageBox 的 Element 默认外观) */
function requestLogout() {
if (loggingOut.value) return
accountMenuOpen.value = false
logoutConfirmOpen.value = true
}
function cancelLogout() {
if (loggingOut.value) return
logoutConfirmOpen.value = false
}
/**
* 退出登录:二次确认 → 注销服务端会话 → 清掉本地身份数据 → **整页**回登录页。
*
* 为什么最后一步是 `window.location.replace` 而不是 `router.push`:
* SPA 内跳转只清得掉 store 里被显式重置过的字段,而模块级缓存
* (如文档库的「哈希 → 正文」缓存)、其余 Pinia store、各页面组件持有的数据
* 都会留在内存里。整页重载一次把这些一并清空,才算真正的退出 ——
* 与 `utils/request.ts` 收到 401 时的处理是同一套。
*
* 本地身份数据(token / tokenExpiresAt / userInfo,其中 userInfo 里含 permissions)
* 由 `loginStore.logout()` → `clearSession()` → `clearAuthStorage()` 删除。
*/
async function confirmLogout() {
if (loggingOut.value) return
loggingOut.value = true
try {
await loginStore.logout()
} catch {
// 服务端注销失败不阻断退出:本地会话已在 logout() 的 finally 里清理
}
// 用 replace 而不是 assign:退出后按浏览器后退不该再回到已登录页面
window.location.replace('/login')
}
/** 确认弹窗挂在 body 上,Esc 关闭要挂 document 才收得到(与 AccountPanel 同一处理) */
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') cancelLogout()
}
watch(logoutConfirmOpen, (value) => {
if (value) document.addEventListener('keydown', handleEscape)
else document.removeEventListener('keydown', handleEscape)
})
onBeforeUnmount(() => document.removeEventListener('keydown', handleEscape))
</script>
<template>
<aside class="clinical-menu">
<div class="menu-brand">
<div class="menu-brand-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M14 3v5h5M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z"
stroke="#fff"
stroke-width="1.7"
stroke-linejoin="round"
/>
<path
d="m9 13.5 2.5 2.5 4-4.5"
stroke="#fff"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div class="menu-brand-copy">
<strong>医签通</strong>
<span>MEDISIGN</span>
</div>
</div>
<nav class="menu-nav" aria-label="主导航">
<div v-for="group in visibleMenuGroups" :key="group.label" class="menu-group">
<div class="menu-group-title">{{ group.label }}</div>
<RouterLink
v-for="item in group.items"
:key="item.path"
:to="item.path"
class="menu-item"
active-class="is-active"
>
<span class="menu-dot" aria-hidden="true">{{ item.icon?.slice(0, 1) ?? '' }}</span>
<span>{{ item.label }}</span>
</RouterLink>
</div>
</nav>
<el-popover
v-model:visible="accountMenuOpen"
trigger="click"
placement="top-start"
:width="280"
:offset="8"
popper-class="account-popover"
>
<template #reference>
<button
class="menu-profile"
type="button"
:aria-expanded="accountMenuOpen"
aria-label="打开账户菜单"
@keydown.esc="accountMenuOpen = false"
>
<span class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</span>
<span class="menu-profile-copy">
<strong>{{ loginStore.userName }}</strong>
<span :title="identity">{{ identity }}</span>
<span>ID {{ loginStore.user?.employeeId ?? '—' }}</span>
</span>
<span class="menu-profile-arrow" aria-hidden="true">⌃</span>
</button>
</template>
<div class="account-actions" @keydown.esc="accountMenuOpen = false">
<div class="account-summary">
<strong>{{ loginStore.userName }}</strong>
<span>ID {{ loginStore.user?.employeeId ?? '—' }}</span>
<span>工号 {{ loginStore.user?.employeeNo || '—' }}</span>
<span>{{ loginStore.user?.campusName || '—' }} · {{ loginStore.department }}</span>
</div>
<button type="button" @click="openAccount('profile')">个人信息</button>
<button type="button" @click="openAccount('password')">修改密码</button>
<button class="account-logout" type="button" :disabled="loggingOut" @click="requestLogout">
退出登录
</button>
</div>
</el-popover>
<AccountPanel ref="accountPanel" />
<!-- 退出二次确认:与账户弹窗同一套页面通用外壳与按钮(不再用 ElMessageBox 默认外观) -->
<Teleport to="body">
<div v-if="logoutConfirmOpen" class="logout-mask" role="presentation">
<section
class="logout-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="logout-dialog-title"
>
<header class="logout-header">
<strong id="logout-dialog-title">退出登录</strong>
<button
v-if="!loggingOut"
type="button"
class="logout-close"
aria-label="关闭"
@click="cancelLogout"
>
×
</button>
</header>
<div class="logout-body">退出后需要重新登录才能继续使用,确定退出当前账号吗?</div>
<footer class="logout-footer">
<span class="logout-spacer" />
<button
type="button"
class="action-button action-button--ghost"
:disabled="loggingOut"
@click="cancelLogout"
>
取消
</button>
<button
type="button"
class="action-button action-button--danger"
:disabled="loggingOut"
@click="confirmLogout"
>
{{ loggingOut ? '退出中…' : '退出登录' }}
</button>
</footer>
</section>
</div>
</Teleport>
</aside>
</template>
<style scoped>
.clinical-menu {
display: flex;
min-height: 0;
flex: 0 0 var(--sidebar-width);
flex-direction: column;
width: var(--sidebar-width);
color: #c8dde6;
background: var(--side);
}
.menu-brand {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 18px 16px 16px;
border-bottom: 1px solid rgb(255 255 255 / 8%);
}
.menu-brand-icon {
display: grid;
flex-shrink: 0;
width: 34px;
height: 34px;
place-items: center;
border-radius: 9px;
background: linear-gradient(135deg, #18a0c0, var(--brand));
}
.menu-brand-copy strong,
.menu-brand-copy span {
display: block;
}
.menu-brand-copy strong {
color: #fff;
font-size: 16px;
letter-spacing: 1px;
}
.menu-brand-copy span {
color: #8fb3c2;
font-size: 10px;
letter-spacing: 1px;
}
.menu-nav {
flex: 1;
min-height: 0;
padding: 12px 10px;
overflow-y: auto;
}
.menu-group-title {
padding: 10px 10px 6px;
color: #7ba2b3;
font-size: 11px;
letter-spacing: 1px;
}
.menu-item {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 2px;
padding: 10px 12px;
color: #c8dde6;
font-size: 13.5px;
text-decoration: none;
border-radius: 8px;
transition:
color 0.15s ease,
background 0.15s ease;
}
.menu-item:hover {
color: #fff;
background: rgb(255 255 255 / 7%);
}
.menu-item.is-active {
color: #fff;
font-weight: 700;
background: var(--brand);
}
.menu-dot {
display: grid;
flex-shrink: 0;
width: 14px;
height: 14px;
color: #a7c2cc;
font-size: 10px;
font-weight: 700;
place-items: center;
background: rgb(255 255 255 / 8%);
border-radius: 4px;
}
.menu-item.is-active .menu-dot {
color: #fff;
background: rgb(255 255 255 / 20%);
}
.menu-profile {
width: 100%;
text-align: left;
background: transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 14px 16px;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.menu-profile-avatar {
display: grid;
flex-shrink: 0;
width: 34px;
height: 34px;
color: #fff;
font-size: 14px;
font-weight: 700;
place-items: center;
border-radius: 50%;
background: #18a0c0;
}
.menu-profile-copy {
flex: 1;
min-width: 0;
}
.menu-profile-copy strong,
.menu-profile-copy span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-profile-copy strong {
color: #fff;
font-size: 13px;
}
.menu-profile-copy span {
margin-top: 2px;
color: #8fb3c2;
font-size: 11px;
}
.menu-profile:hover,
.menu-profile:focus-visible {
background: rgb(255 255 255 / 7%);
}
.menu-profile:focus-visible {
outline: 2px solid #18a0c0;
outline-offset: -2px;
}
.menu-profile-arrow {
color: #8fb3c2;
}
.account-summary {
display: grid;
gap: 4px;
padding: 8px 12px 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.account-summary strong {
color: var(--ink);
font-size: 13px;
}
.account-summary span {
color: var(--mut);
font-size: 11px;
overflow-wrap: anywhere;
}
.account-actions button {
display: flex;
align-items: center;
width: 100%;
padding: 9px 12px;
background: transparent;
color: var(--ink);
border-radius: 7px;
text-align: left;
font-size: 13px;
}
.account-actions button:hover,
.account-actions button:focus-visible {
background: var(--brand-l);
}
.account-actions .account-logout {
margin-top: 4px;
border-top: 1px solid var(--line);
color: #d0534f;
}
/* 退出二次确认弹窗:外壳与按钮沿用页面通用弹窗样式(与 AccountPanel 同一套) */
.logout-mask {
position: fixed;
z-index: 50;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.logout-card {
display: flex;
width: 440px;
max-width: 96vw;
max-height: calc(100vh - 32px);
flex-direction: column;
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
animation: logout-pop 0.18s ease;
}
.logout-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.logout-header strong {
color: var(--ink);
font-size: 15px;
}
.logout-close {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 20px;
line-height: 1;
background: transparent;
}
.logout-close:hover {
color: var(--ink);
}
.logout-body {
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
color: var(--ink);
font-size: 13px;
line-height: 1.7;
}
.logout-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.logout-spacer {
flex: 1;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover:not(:disabled) {
background: var(--brand-l);
}
.action-button--danger {
color: var(--err);
background: #fff;
border: 1px solid var(--err);
}
.action-button--danger:hover:not(:disabled) {
background: var(--err-l);
}
.action-button:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #eef3f5;
}
@keyframes logout-pop {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
</style>