From 439af9caaf57e4f3a42a680cebf41ac6ddb73234 Mon Sep 17 00:00:00 2001 From: yelan Date: Tue, 6 Oct 2026 16:30:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E8=A1=A5=E9=BD=90=E9=80=80?= =?UTF-8?q?=E5=87=BA=E7=99=BB=E5=BD=95=E4=BA=A4=E4=BA=92=E5=B9=B6=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E7=9C=9F=E5=AE=9E=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧栏底部用户区改为按原型 #userMenu 展开的用户菜单 (个人信息 / 修改密码 / 退出登录),支持点击浮层外部与 Esc 收起 - 退出登录增加二次确认;确认后注销服务端会话并整页跳转回登录页, 确保 token / tokenExpiresAt / userInfo(含 permissions)与内存态一并清空 - clearSession() 重置跨账号内存态(院区列表与选中项), 否则 SPA 内退出后换账号,新账号会看到上一个账号的院区 - 新增只读的个人信息弹窗(后端暂无「修改本人资料」接口,不提供编辑入口) - 顺带修正 stores/login.ts 中被 prettier 判为不合规的缩进 --- clinical-web/src/components/layout/Menu.vue | 81 +---- .../src/components/layout/ProfileDialog.vue | 155 ++++++++++ .../src/components/layout/UserMenu.vue | 280 ++++++++++++++++++ clinical-web/src/stores/app.ts | 37 ++- clinical-web/src/stores/login.ts | 58 ++-- clinical-web/src/styles/index.css | 9 + 6 files changed, 510 insertions(+), 110 deletions(-) create mode 100644 clinical-web/src/components/layout/ProfileDialog.vue create mode 100644 clinical-web/src/components/layout/UserMenu.vue diff --git a/clinical-web/src/components/layout/Menu.vue b/clinical-web/src/components/layout/Menu.vue index bf165e6..f71e8a7 100644 --- a/clinical-web/src/components/layout/Menu.vue +++ b/clinical-web/src/components/layout/Menu.vue @@ -1,11 +1,11 @@ @@ -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; -} diff --git a/clinical-web/src/components/layout/ProfileDialog.vue b/clinical-web/src/components/layout/ProfileDialog.vue new file mode 100644 index 0000000..3dc76a2 --- /dev/null +++ b/clinical-web/src/components/layout/ProfileDialog.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/clinical-web/src/components/layout/UserMenu.vue b/clinical-web/src/components/layout/UserMenu.vue new file mode 100644 index 0000000..cc2f426 --- /dev/null +++ b/clinical-web/src/components/layout/UserMenu.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/clinical-web/src/stores/app.ts b/clinical-web/src/stores/app.ts index 7beb71b..2b2b02b 100644 --- a/clinical-web/src/stores/app.ts +++ b/clinical-web/src/stores/app.ts @@ -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( - useMockData ? getEnabledCampuses(mockCampusRecords) : [], - ) - const selectedCampusId = ref(useMockData ? (campusRecords.value[0]?.id ?? '') : '') + const initialCampusState = createInitialCampusState() + const campusRecords = ref(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 | 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, } }) diff --git a/clinical-web/src/stores/login.ts b/clinical-web/src/stores/login.ts index 8c59f5e..cc992be 100644 --- a/clinical-web/src/stores/login.ts +++ b/clinical-web/src/stores/login.ts @@ -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,36 +49,36 @@ export const useLoginStore = defineStore('login', () => { ) } -/** - * 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。 - * - * 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架, - * 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走, - * 避免出现点进去只有「前端暂未接入组件」占位页的死链。 - * 后端下架该菜单资源后,这个集合可以删掉。 - */ -const RETIRED_MENU_PATHS = new Set(['/report/index']) + /** + * 已下线菜单:后端菜单资源里仍然下发、但前端已经不再提供页面的菜单。 + * + * 「系统管理 → 报表分析」(`/report/index`)已整体下线,后端菜单资源暂时无法同步下架, + * 所以在前端统一屏蔽:侧边栏不展示,路由守卫也会把它当成无权限菜单重定向走, + * 避免出现点进去只有「前端暂未接入组件」占位页的死链。 + * 后端下架该菜单资源后,这个集合可以删掉。 + */ + const RETIRED_MENU_PATHS = new Set(['/report/index']) -function isRetiredMenu(menu: PermissionResourceNode) { - return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '') -} + function isRetiredMenu(menu: PermissionResourceNode) { + return RETIRED_MENU_PATHS.has(menu.routePath?.replace(/\/$/, '') ?? '') + } -const accessibleMenus = computed(() => - (authorization.value?.navigation ?? []) - .filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED') - .flatMap((directory) => directory.children ?? []) - .filter((menu) => !isRetiredMenu(menu)), -) + 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(() => - (authorization.value?.navigation ?? []) - .filter((directory) => directory.resourceType === 'DIR' && directory.status === 'ENABLED') - .map((directory) => ({ - ...directory, - children: (directory.children ?? []).filter((menu) => !isRetiredMenu(menu)), - })), -) + /** 侧边栏用的导航树:结构与后端一致,只剔除已下线菜单(空目录由使用方过滤) */ + 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) { diff --git a/clinical-web/src/styles/index.css b/clinical-web/src/styles/index.css index f2095d1..bc72772 100644 --- a/clinical-web/src/styles/index.css +++ b/clinical-web/src/styles/index.css @@ -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); +}