556 lines
13 KiB
Vue
556 lines
13 KiB
Vue
<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>
|