feat(auth): 补齐退出登录交互并改为真实退出

- 侧栏底部用户区改为按原型 #userMenu 展开的用户菜单
  (个人信息 / 修改密码 / 退出登录),支持点击浮层外部与 Esc 收起
- 退出登录增加二次确认;确认后注销服务端会话并整页跳转回登录页,
  确保 token / tokenExpiresAt / userInfo(含 permissions)与内存态一并清空
- clearSession() 重置跨账号内存态(院区列表与选中项),
  否则 SPA 内退出后换账号,新账号会看到上一个账号的院区
- 新增只读的个人信息弹窗(后端暂无「修改本人资料」接口,不提供编辑入口)
- 顺带修正 stores/login.ts 中被 prettier 判为不合规的缩进
This commit is contained in:
yelan
2026-10-06 16:30:57 +08:00
parent eb4693cf65
commit 439af9caaf
6 changed files with 510 additions and 110 deletions
+3 -78
View File
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink } from 'vue-router'
import UserMenu from '@/components/layout/UserMenu.vue'
import { useLoginStore } from '@/stores/login' import { useLoginStore } from '@/stores/login'
const loginStore = useLoginStore() const loginStore = useLoginStore()
const router = useRouter()
const visibleMenuGroups = computed(() => const visibleMenuGroups = computed(() =>
loginStore.visibleNavigation loginStore.visibleNavigation
@@ -29,16 +29,6 @@ const visibleMenuGroups = computed(() =>
})) }))
.filter((group) => group.items.length), .filter((group) => group.items.length),
) )
async function handleLogout() {
try {
await loginStore.logout()
} catch {
// 即使服务暂时不可用,也要清理本地会话。
} finally {
await router.push('/login')
}
}
</script> </script>
<template> <template>
@@ -83,14 +73,7 @@ async function handleLogout() {
</div> </div>
</nav> </nav>
<div class="menu-profile"> <UserMenu />
<div class="menu-profile-avatar">{{ loginStore.userName.slice(0, 1) }}</div>
<div class="menu-profile-copy">
<strong>{{ loginStore.userName }}</strong>
<span>{{ loginStore.department }}</span>
</div>
<button class="menu-logout" type="button" @click="handleLogout">退出</button>
</div>
</aside> </aside>
</template> </template>
@@ -196,62 +179,4 @@ async function handleLogout() {
color: #fff; color: #fff;
background: rgb(255 255 255 / 20%); background: rgb(255 255 255 / 20%);
} }
.menu-profile {
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 {
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-logout {
flex-shrink: 0;
margin-left: auto;
padding: 0;
color: #8fb3c2;
font-size: 11px;
background: transparent;
}
.menu-logout:hover {
color: #fff;
}
</style> </style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getCurrentUser } from '@/api/auth'
import { useLoginStore } from '@/stores/login'
import { toUserMessage } from '@/utils/api-response'
import type { CurrentUserResponse } from '@/types/auth'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const loginStore = useLoginStore()
const loading = ref(false)
const errorMessage = ref('')
const profile = ref<CurrentUserResponse | null>(null)
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (input: number) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
* 只读展示当前登录账户。
*
* 原型里手机号 / 邮箱可以本人修改,但后端没有「修改本人资料」接口,
* 所以这里只展示 `/v1/auth/me` 返回的真实字段,不提供编辑入口,
* 也不在没有数据时编造占位值。
*/
const rows = computed(() => {
const detail = profile.value
const user = loginStore.user
const roleNames = (detail?.roles ?? user?.roles ?? [])
.map((role) => role.name)
.filter((name): name is string => Boolean(name))
return [
{ label: '姓名', value: detail?.displayName || user?.displayName || '—' },
{ label: '登录账号', value: detail?.username || user?.username || '—' },
{ label: '工号', value: detail?.employeeNo || '—' },
{ label: '角色', value: roleNames.length ? roleNames.join(' / ') : '—' },
{ label: '所属院区', value: detail?.campusName || user?.campusName || '—' },
{ label: '所属科室', value: detail?.departmentName || user?.departmentName || '—' },
{ label: '手机号', value: detail?.phone || '—' },
{ label: '上次登录', value: formatDateTime(detail?.lastLoginAt) },
]
})
async function loadProfile() {
loading.value = true
errorMessage.value = ''
try {
profile.value = await getCurrentUser()
} catch (error: unknown) {
errorMessage.value = toUserMessage(error, '个人信息加载失败,请稍后重试')
} finally {
loading.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
void loadProfile()
}
},
)
</script>
<template>
<el-dialog v-model="visible" title="个人信息" width="520px" destroy-on-close>
<p class="profile-hint">账号资料由系统管理员维护,如需变更请联系管理员。</p>
<p v-if="loading" class="profile-state">加载中…</p>
<p v-else-if="errorMessage" class="profile-state is-error" role="alert">
{{ errorMessage }}
</p>
<dl v-else class="profile-list">
<div v-for="row in rows" :key="row.label" class="profile-row">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
<template #footer>
<el-button type="primary" @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.profile-hint {
margin: 0 0 12px;
padding: 8px 10px;
color: var(--mut);
font-size: 12px;
background: var(--brand-l);
border-radius: 7px;
}
.profile-state {
margin: 16px 0;
color: var(--mut);
font-size: 13px;
text-align: center;
}
.profile-state.is-error {
color: var(--err);
}
.profile-list {
margin: 0;
}
.profile-row {
display: flex;
gap: 12px;
padding: 9px 0;
border-bottom: 1px dashed var(--line);
}
.profile-row:last-child {
border-bottom: 0;
}
.profile-row dt {
flex-shrink: 0;
width: 84px;
color: var(--mut);
font-size: 13px;
}
.profile-row dd {
flex: 1;
margin: 0;
color: var(--ink);
font-size: 13px;
word-break: break-all;
}
</style>
@@ -0,0 +1,280 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import ProfileDialog from '@/components/layout/ProfileDialog.vue'
import { useLoginStore } from '@/stores/login'
const router = useRouter()
const loginStore = useLoginStore()
const rootRef = ref<HTMLElement | null>(null)
const menuOpen = ref(false)
const profileVisible = ref(false)
const displayName = computed(() => loginStore.userName)
const avatarText = computed(() => displayName.value.slice(0, 1) || '医')
const department = computed(() => loginStore.department)
const roleLabel = computed(() => {
const names = (loginStore.user?.roles ?? [])
.map((role) => role.name)
.filter((name): name is string => Boolean(name))
return names.length ? names.join(' / ') : '—'
})
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
/** 点击浮层以外任意位置收起菜单(按钮自身的点击已由 rootRef.contains 排除) */
function handleDocumentClick(event: MouseEvent) {
if (!menuOpen.value) return
const target = event.target as Node | null
if (rootRef.value && target && !rootRef.value.contains(target)) {
closeMenu()
}
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('keydown', handleDocumentKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('keydown', handleDocumentKeydown)
})
function openProfile() {
closeMenu()
profileVisible.value = true
}
async function openChangePassword() {
closeMenu()
await router.push({ name: 'change-password' })
}
/**
* 退出登录:二次确认 → 注销服务端会话 → 清掉本地身份数据 → **整页**回登录页。
*
* 为什么最后一步是 `window.location.replace` 而不是 `router.replace`:
* SPA 内跳转只清得掉 store 里被显式重置过的字段,而模块级缓存
* (如文档库的「哈希 → 正文」缓存)、其余 Pinia store、各页面组件持有的数据
* 都会留在内存里。整页重载一次把这些一并清空,才算真正的退出。
* 这与 `utils/request.ts` 收到 401 时的处理方式是同一套。
*
* 本地身份数据(token / tokenExpiresAt / userInfo,其中 userInfo 里含 permissions)
* 由 `loginStore.logout()` → `clearSession()` → `clearAuthStorage()` 删除。
*/
async function handleLogout() {
closeMenu()
try {
await ElMessageBox.confirm('退出后需要重新登录才能继续使用,确定退出当前账号吗?', '退出登录', {
confirmButtonText: '退出登录',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'logout-confirm-button',
})
} catch {
// 用户点了取消,保持在当前页面
return
}
try {
await loginStore.logout()
} catch {
// 服务端注销失败不阻断退出流程,本地会话已在 logout() 的 finally 里清理
}
// 用 replace 而不是 assign:退出后按浏览器后退不该再回到已登录页面
window.location.replace('/login')
}
</script>
<template>
<div ref="rootRef" class="menu-profile">
<button
class="menu-profile-trigger"
type="button"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@click="toggleMenu"
>
<span class="menu-profile-avatar" aria-hidden="true">{{ avatarText }}</span>
<span class="menu-profile-copy">
<strong>{{ displayName }}</strong>
<span>{{ department }}</span>
</span>
<span class="menu-profile-caret" :class="{ 'is-open': menuOpen }" aria-hidden="true">▾</span>
</button>
<div v-if="menuOpen" class="user-menu" role="menu" aria-label="账号菜单">
<div class="user-menu-head">
<strong>{{ displayName }}</strong>
<span>{{ roleLabel }}</span>
</div>
<button class="user-menu-item" type="button" role="menuitem" @click="openProfile">
<span class="user-menu-icon" aria-hidden="true">👤</span>个人信息
</button>
<button class="user-menu-item" type="button" role="menuitem" @click="openChangePassword">
<span class="user-menu-icon" aria-hidden="true">🔑</span>修改密码
</button>
<button class="user-menu-item is-danger" type="button" role="menuitem" @click="handleLogout">
<span class="user-menu-icon" aria-hidden="true">⏻</span>退出登录
</button>
</div>
<ProfileDialog v-model="profileVisible" />
</div>
</template>
<style scoped>
.menu-profile {
position: relative;
flex-shrink: 0;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.menu-profile-trigger {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
padding: 14px 16px;
color: inherit;
text-align: left;
background: transparent;
transition: background 0.15s ease;
}
.menu-profile-trigger:hover {
background: rgb(255 255 255 / 6%);
}
.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 {
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-caret {
flex-shrink: 0;
margin-left: auto;
color: #8fb3c2;
font-size: 10px;
transition: transform 0.15s ease;
}
.menu-profile-caret.is-open {
transform: rotate(180deg);
}
/* 浮层贴在用户区上方展开,对齐原型 #userMenu 的位置与投影 */
.user-menu {
position: absolute;
right: 12px;
bottom: calc(100% + 6px);
left: 12px;
z-index: 60;
padding: 6px;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 34px rgb(10 40 60 / 28%);
}
.user-menu-head {
padding: 8px 12px;
margin-bottom: 4px;
border-bottom: 1px solid var(--line);
}
.user-menu-head strong {
display: block;
color: var(--ink);
font-size: 13px;
}
.user-menu-head span {
display: block;
margin-top: 2px;
color: var(--mut);
font-size: 11.5px;
}
.user-menu-item {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
padding: 9px 12px;
color: var(--ink);
font-size: 13px;
text-align: left;
background: transparent;
border-radius: 7px;
}
.user-menu-item:hover {
background: var(--brand-l);
}
.user-menu-item.is-danger {
margin-top: 4px;
color: var(--err);
border-top: 1px solid var(--line);
border-radius: 0 0 7px 7px;
}
.user-menu-item.is-danger:hover {
background: var(--err-l);
}
.user-menu-icon {
font-size: 13px;
}
</style>
+32 -5
View File
@@ -44,13 +44,22 @@ function getEnabledCampuses(records: CampusRecord[]) {
) )
} }
function createInitialCampusState() {
const records = useMockData ? getEnabledCampuses(mockCampusRecords) : []
return {
campusRecords: records,
selectedCampusId: records[0]?.id ?? '',
campusLoaded: useMockData,
}
}
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
const campusRecords = ref<CampusRecord[]>( const initialCampusState = createInitialCampusState()
useMockData ? getEnabledCampuses(mockCampusRecords) : [], const campusRecords = ref<CampusRecord[]>(initialCampusState.campusRecords)
) const selectedCampusId = ref(initialCampusState.selectedCampusId)
const selectedCampusId = ref(useMockData ? (campusRecords.value[0]?.id ?? '') : '')
const campusLoading = ref(false) const campusLoading = ref(false)
const campusLoaded = ref(useMockData) const campusLoaded = ref(initialCampusState.campusLoaded)
const campusLoadFailed = ref(false) const campusLoadFailed = ref(false)
let campusRequest: Promise<void> | null = null let campusRequest: Promise<void> | null = null
@@ -106,6 +115,23 @@ export const useAppStore = defineStore('app', () => {
return request return request
} }
/**
* 重置院区状态,供退出登录 / 会话失效时调用。
*
* 院区列表与选中项是纯内存态,而 SPA 内退出登录不会重载页面:
* 不重置的话 `campusLoaded` 仍是 true,下一个账号的 `loadCampuses()` 会直接返回,
* 于是新账号看到的是**上一个账号**的院区列表和选中项(选中项甚至可能越权)。
*/
function reset() {
const next = createInitialCampusState()
campusRecords.value = next.campusRecords
selectedCampusId.value = next.selectedCampusId
campusLoading.value = false
campusLoaded.value = next.campusLoaded
campusLoadFailed.value = false
campusRequest = null
}
return { return {
campuses, campuses,
campusRecords, campusRecords,
@@ -116,5 +142,6 @@ export const useAppStore = defineStore('app', () => {
campusLoadFailed, campusLoadFailed,
loadCampuses, loadCampuses,
selectCampus, selectCampus,
reset,
} }
}) })
+31 -27
View File
@@ -17,6 +17,7 @@ import type {
PasswordChangeRequest, PasswordChangeRequest,
} from '@/types/auth' } from '@/types/auth'
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage' import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
import { useAppStore } from '@/stores/app'
export const useLoginStore = defineStore('login', () => { export const useLoginStore = defineStore('login', () => {
const storedSession = readStoredAuthSession() const storedSession = readStoredAuthSession()
@@ -48,36 +49,36 @@ export const useLoginStore = defineStore('login', () => {
) )
} }
/** /**
* 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。 * 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。
* *
* 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架, * 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架,
* 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走, * 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走,
* 避免出现点进去只有「前端暂未接入组件」占位页的死链。 * 避免出现点进去只有「前端暂未接入组件」占位页的死链。
* 后端下架该菜单资源后,这个集合可以删掉。 * 后端下架该菜单资源后,这个集合可以删掉。
*/ */
const RETIRED_MENU_PATHS = new Set(['/report/index']) const RETIRED_MENU_PATHS = new Set(['/report/index'])
function isRetiredMenu(menu: PermissionResourceNode) { function isRetiredMenu(menu: PermissionResourceNode) {
return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '') return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '')
} }
const accessibleMenus = computed(() => const accessibleMenus = computed(() =>
(authorization.value?.navigation ?? []) (authorization.value?.navigation ?? [])
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED') .filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
.flatMap((directory) => directory.children ?? []) .flatMap((directory) => directory.children ?? [])
.filter((menu) => !isRetiredMenu(menu)), .filter((menu) => !isRetiredMenu(menu)),
) )
/** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */ /** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */
const visibleNavigation = computed(() => const visibleNavigation = computed(() =>
(authorization.value?.navigation ?? []) (authorization.value?.navigation ?? [])
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED') .filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
.map((directory) => ({ .map((directory) => ({
...directory, ...directory,
children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)), children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)),
})), })),
) )
function menuForPath(path: string): PermissionResourceNode | null { function menuForPath(path: string): PermissionResourceNode | null {
const normalizedPath = path.replace(/\/$/, '') || '/' const normalizedPath = path.replace(/\/$/, '') || '/'
@@ -130,6 +131,9 @@ const visibleNavigation = computed(() =>
authorizationSyncedAt = 0 authorizationSyncedAt = 0
currentUserReady.value = false currentUserReady.value = false
currentUserRequest = null currentUserRequest = null
// 会话结束后清掉跨账号的内存态(当前是院区列表与选中项),
// 否则 SPA 内退出登录后换账号登录,新账号会看到上一个账号的院区。
useAppStore().reset()
} }
async function login(payload: LoginRequest) { async function login(payload: LoginRequest) {
+9
View File
@@ -72,3 +72,12 @@ a {
min-width: 0; min-width: 0;
} }
} }
/* 「退出登录」二次确认的确认按钮:终止会话不可逆,按危险操作着色。
MessageBox 挂在 body 上,组件里的 scoped 选择器命中不了,只能放全局。 */
.el-message-box .logout-confirm-button,
.el-message-box .logout-confirm-button:hover,
.el-message-box .logout-confirm-button:focus {
background-color: var(--err);
border-color: var(--err);
}