feat(auth): 补齐退出登录交互并改为真实退出
- 侧栏底部用户区改为按原型 #userMenu 展开的用户菜单 (个人信息 / 修改密码 / 退出登录),支持点击浮层外部与 Esc 收起 - 退出登录增加二次确认;确认后注销服务端会话并整页跳转回登录页, 确保 token / tokenExpiresAt / userInfo(含 permissions)与内存态一并清空 - clearSession() 重置跨账号内存态(院区列表与选中项), 否则 SPA 内退出后换账号,新账号会看到上一个账号的院区 - 新增只读的个人信息弹窗(后端暂无「修改本人资料」接口,不提供编辑入口) - 顺带修正 stores/login.ts 中被 prettier 判为不合规的缩进
This commit is contained in:
@@ -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>
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user