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">
|
||||
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'
|
||||
|
||||
const loginStore = useLoginStore()
|
||||
const router = useRouter()
|
||||
|
||||
const visibleMenuGroups = computed(() =>
|
||||
loginStore.visibleNavigation
|
||||
@@ -29,16 +29,6 @@ const visibleMenuGroups = computed(() =>
|
||||
}))
|
||||
.filter((group) => group.items.length),
|
||||
)
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await loginStore.logout()
|
||||
} catch {
|
||||
// 即使服务暂时不可用,也要清理本地会话。
|
||||
} finally {
|
||||
await router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,14 +73,7 @@ async function handleLogout() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="menu-profile">
|
||||
<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>
|
||||
<UserMenu />
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -196,62 +179,4 @@ async function handleLogout() {
|
||||
color: #fff;
|
||||
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>
|
||||
|
||||
@@ -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', () => {
|
||||
const campusRecords = ref<CampusRecord[]>(
|
||||
useMockData ? getEnabledCampuses(mockCampusRecords) : [],
|
||||
)
|
||||
const selectedCampusId = ref(useMockData ? (campusRecords.value[0]?.id ?? '') : '')
|
||||
const initialCampusState = createInitialCampusState()
|
||||
const campusRecords = ref<CampusRecord[]>(initialCampusState.campusRecords)
|
||||
const selectedCampusId = ref(initialCampusState.selectedCampusId)
|
||||
const campusLoading = ref(false)
|
||||
const campusLoaded = ref(useMockData)
|
||||
const campusLoaded = ref(initialCampusState.campusLoaded)
|
||||
const campusLoadFailed = ref(false)
|
||||
let campusRequest: Promise<void> | null = null
|
||||
|
||||
@@ -106,6 +115,23 @@ export const useAppStore = defineStore('app', () => {
|
||||
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 {
|
||||
campuses,
|
||||
campusRecords,
|
||||
@@ -116,5 +142,6 @@ export const useAppStore = defineStore('app', () => {
|
||||
campusLoadFailed,
|
||||
loadCampuses,
|
||||
selectCampus,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PasswordChangeRequest,
|
||||
} from '@/types/auth'
|
||||
import { clearAuthStorage, readStoredAuthSession, saveAuthSession } from '@/utils/auth-storage'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export const useLoginStore = defineStore('login', () => {
|
||||
const storedSession = readStoredAuthSession()
|
||||
@@ -48,7 +49,7 @@ export const useLoginStore = defineStore('login', () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。
|
||||
*
|
||||
* 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架,
|
||||
@@ -56,28 +57,28 @@ export const useLoginStore = defineStore('login', () => {
|
||||
* 避免出现点进去只有「前端暂未接入组件」占位页的死链。
|
||||
* 后端下架该菜单资源后,这个集合可以删掉。
|
||||
*/
|
||||
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(/\/$/, '') ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
const accessibleMenus = computed(() =>
|
||||
const accessibleMenus = computed(() =>
|
||||
(authorization.value?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
.flatMap((directory) => directory.children ?? [])
|
||||
.filter((menu) => !isRetiredMenu(menu)),
|
||||
)
|
||||
)
|
||||
|
||||
/** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */
|
||||
const visibleNavigation = computed(() =>
|
||||
/** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */
|
||||
const visibleNavigation = computed(() =>
|
||||
(authorization.value?.navigation ?? [])
|
||||
.filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED')
|
||||
.map((directory) => ({
|
||||
...directory,
|
||||
children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)),
|
||||
})),
|
||||
)
|
||||
)
|
||||
|
||||
function menuForPath(path: string): PermissionResourceNode | null {
|
||||
const normalizedPath = path.replace(/\/$/, '') || '/'
|
||||
@@ -130,6 +131,9 @@ const visibleNavigation = computed(() =>
|
||||
authorizationSyncedAt = 0
|
||||
currentUserReady.value = false
|
||||
currentUserRequest = null
|
||||
// 会话结束后清掉跨账号的内存态(当前是院区列表与选中项),
|
||||
// 否则 SPA 内退出登录后换账号登录,新账号会看到上一个账号的院区。
|
||||
useAppStore().reset()
|
||||
}
|
||||
|
||||
async function login(payload: LoginRequest) {
|
||||
|
||||
@@ -72,3 +72,12 @@ a {
|
||||
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