From 0dc5c2f0756873dfbfc374823491f7ac75df5d05 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Tue, 29 Sep 2026 15:38:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A7=9F=E6=88=B7=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/components.d.ts | 1 + backend/src/components/CommonHeader.vue | 25 +-- backend/src/components/TenantSwitcher.vue | 221 ++++++++++++++++++++++ backend/src/stores/auth.js | 6 +- backend/src/utils/authClient.js | 46 ++++- backend/src/utils/request.js | 19 +- backend/src/views/auth/callback.vue | 6 +- backend/src/views/home/index.vue | 41 ++-- 8 files changed, 305 insertions(+), 60 deletions(-) create mode 100644 backend/src/components/TenantSwitcher.vue diff --git a/backend/components.d.ts b/backend/components.d.ts index bd7992f..1765f3d 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -86,6 +86,7 @@ declare module 'vue' { MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] + TenantSwitcher: typeof import('./src/components/TenantSwitcher.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/backend/src/components/CommonHeader.vue b/backend/src/components/CommonHeader.vue index cfa58a8..6e139de 100644 --- a/backend/src/components/CommonHeader.vue +++ b/backend/src/components/CommonHeader.vue @@ -69,9 +69,8 @@ - - {{ companyName }} - + + @@ -120,6 +119,7 @@ import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Loading, Me import { ElMessage } from 'element-plus'; import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder"; import MessageDetailDialog from "./MessageDetailDialog.vue"; +import TenantSwitcher from "./TenantSwitcher.vue"; import { formatTime } from "@/utils/datetime"; const router = useRouter(); @@ -290,11 +290,6 @@ const displayName = computed(() => { return user.account || ''; }); -// 当前登录的企业(租户)名称 -const companyName = computed(() => { - return authStore.user?.tenant_name || ''; -}); - // 当前登录用户的角色 / 部门 / 职位(来自后端 /backend/getCurrentUserProfile) const userProfile = reactive({ department: '', position: '', role_name: '' }); @@ -579,20 +574,6 @@ onUnmounted(() => { } } - .company-name { - max-width: 200px; - padding: 4px 12px; - font-size: 13px; - font-weight: 500; - color: var(--el-color-primary); - background-color: var(--el-color-primary-light-9); - border: 1px solid var(--el-color-primary-light-7); - border-radius: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .el-dropdown-link { display: flex; align-items: center; diff --git a/backend/src/components/TenantSwitcher.vue b/backend/src/components/TenantSwitcher.vue new file mode 100644 index 0000000..a2bb26c --- /dev/null +++ b/backend/src/components/TenantSwitcher.vue @@ -0,0 +1,221 @@ + + + + + + {{ companyName }} + + + + + + + {{ item.tenant_name }} + + + + + 当前账号未关联其他企业 + + + + {{ loading ? "企业列表加载中…" : "未获取到企业列表,点击重试" }} + + + + + + + + + + + + + diff --git a/backend/src/stores/auth.js b/backend/src/stores/auth.js index 3e7ab9a..3c8886a 100644 --- a/backend/src/stores/auth.js +++ b/backend/src/stores/auth.js @@ -38,7 +38,11 @@ export const useAuthStore = defineStore('auth', () => { try { const res = await getCurrentUser() if (res && res.code === 200 && res.data) { - Object.assign(user, res.data) + // 后端没给出企业名时不要用空值覆盖本地已有的名称, + // 否则顶部「当前企业」会突然消失(统一认证会话下尤其容易出现) + const data = { ...res.data } + if (!data.tenant_name) delete data.tenant_name + Object.assign(user, data) localStorage.setItem('userInfo', JSON.stringify({ ...user })) } } catch (e) { diff --git a/backend/src/utils/authClient.js b/backend/src/utils/authClient.js index 25358e9..626cee1 100644 --- a/backend/src/utils/authClient.js +++ b/backend/src/utils/authClient.js @@ -164,8 +164,22 @@ export async function handleAuthorizeCallback(query = {}) { return data; } -/** 静默刷新访问令牌 */ +// 刷新中的令牌请求(single-flight):并发 401 时共用同一次刷新。 +// 认证中心的 refresh_token 是一次性轮换的,同一个 refresh_token 被使用两次会被 +// 判定为重放攻击并吊销整个会话(见 services/auth/token.go 的 RefreshTokens), +// 因此这里必须保证「任一时刻只有一个刷新请求在飞」。 +let refreshingPromise = null; + +/** 静默刷新访问令牌(带并发去重) */ export async function refreshAccessToken() { + if (refreshingPromise) return refreshingPromise; + refreshingPromise = doRefreshAccessToken().finally(() => { + refreshingPromise = null; + }); + return refreshingPromise; +} + +async function doRefreshAccessToken() { const refresh = getRefreshToken(); if (!refresh) throw new Error("无刷新令牌"); @@ -180,7 +194,9 @@ export async function refreshAccessToken() { body, }); const data = await res.json().catch(() => ({})); - if (!res.ok || !data.access_token) throw new Error("刷新令牌失败"); + if (!res.ok || !data.access_token) { + throw new Error(data.error_description || data.error || "刷新令牌失败"); + } setTokens(data); return data.access_token; } @@ -266,9 +282,21 @@ export async function ensureUserInfo() { /** 拉取当前登录用户(认证中心视角) */ export async function fetchUserInfo() { - const res = await fetch(`${AUTH_BASE}/userinfo`, { + let res = await fetch(`${AUTH_BASE}/userinfo`, { headers: { Authorization: `Bearer ${getAccessToken()}` }, }); + if (res.status === 401) { + // 访问令牌可能刚过期:先静默续期再重试一次, + // 否则调用方(路由守卫的 ensureUserInfo)会把用户直接踢回认证中心登录 + try { + const newToken = await refreshAccessToken(); + res = await fetch(`${AUTH_BASE}/userinfo`, { + headers: { Authorization: `Bearer ${newToken}` }, + }); + } catch (e) { + // 续期失败:保持原响应,交给调用方按「未登录」处理 + } + } if (!res.ok) throw new Error("获取用户信息失败"); return res.json(); } @@ -284,7 +312,17 @@ export async function switchTenant(tid) { body: JSON.stringify({ tid, client_id: CLIENT_ID }), }); const data = await res.json().catch(() => ({})); - if (!res.ok || data.code !== 200) throw new Error(data.msg || "切换企业失败"); + if (!res.ok || data.code !== 200) { + const msg = data.msg || "切换企业失败"; + // 会话被吊销/过期:本端已无法继续,清掉登录态回认证中心重新登录, + // 否则用户会卡在「点了没反应」的状态 + if (res.status === 401 || msg.includes("会话") || msg.includes("重新登录")) { + clearTokens(); + localStorage.removeItem("userInfo"); + redirectToAuthorize().catch((err) => console.error("跳转统一认证失败:", err)); + } + throw new Error(msg); + } if (data.data?.tokens) setTokens(data.data.tokens); // 切换后同步本地缓存的企业信息,避免顶部仍显示切换前的企业名 try { diff --git a/backend/src/utils/request.js b/backend/src/utils/request.js index 6aaac0a..caf1431 100644 --- a/backend/src/utils/request.js +++ b/backend/src/utils/request.js @@ -50,12 +50,28 @@ service.interceptors.response.use( // 避免页面只显示 "Request failed with status code xxx" const bizMsg = error.response.data?.msg || error.response.data?.message; switch (error.response.status) { - case 401: + case 401: { + // 每个请求最多用新令牌重放一次,避免「401 → 刷新 → 仍 401」反复刷新 + if (error.config?._tokenRetried) { + return Promise.reject(new Error(bizMsg || 'token无效,请重新登录')); + } + // 并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的 + // (登录回跳、另一请求的静默续期都会改 localStorage)。 + // 此时直接用最新令牌重放即可,既省掉一次刷新,也不会误报登录失效。 + const latestToken = localStorage.getItem('token') || ''; + const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, ''); + if (latestToken && latestToken !== sentToken) { + console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url); + error.config.headers['Authorization'] = `Bearer ${latestToken}`; + error.config._tokenRetried = true; + return service.request(error.config); + } // 统一认证:先用 refresh_token 静默换取新令牌并重放原请求, // 刷新失败(令牌过期/被吊销/旧本地登录遗留 token)才真正登出。 try { const newToken = await refreshAccessToken(); error.config.headers['Authorization'] = `Bearer ${newToken}`; + error.config._tokenRetried = true; return service.request(error.config); } catch (e) { // 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环) @@ -70,6 +86,7 @@ service.interceptors.response.use( } return Promise.reject(new Error('token无效,请重新登录')); } + } case 404: console.error('请求的资源不存在'); break; diff --git a/backend/src/views/auth/callback.vue b/backend/src/views/auth/callback.vue index aaa8555..43ead9b 100644 --- a/backend/src/views/auth/callback.vue +++ b/backend/src/views/auth/callback.vue @@ -60,8 +60,10 @@ async function finish() { try { await handleAuthorizeCallback(query); await saveUserInfo(); - // 登录成功:清掉 URL 上的 code/state,进入首页 - window.location.href = "#/home"; + // 登录成功:用 replaceState 改写 hash(不触发路由跳转),再整页重载。 + // 若先 window.location.href = '#/home' 再 reload,会在「旧会话状态」下先渲染一次 + // 首页并发出请求(部分请求会带登录前的旧令牌 → 401),重载后才恢复。 + window.history.replaceState({}, document.title, `${window.location.pathname}#/home`); window.location.reload(); } catch (e) { error.value = e?.message || "登录失败"; diff --git a/backend/src/views/home/index.vue b/backend/src/views/home/index.vue index b6108a8..15c309a 100644 --- a/backend/src/views/home/index.vue +++ b/backend/src/views/home/index.vue @@ -3,12 +3,8 @@ - - - - {{ tenantName }} - - + + @@ -225,7 +221,6 @@ import { RefreshRight, Sunny, Moon, - OfficeBuilding, Lock, } from "@element-plus/icons-vue"; @@ -235,6 +230,7 @@ import { getCurrentUserProfile, logout } from "@/api/login"; import { reloadMenusAndDynamicRoutes } from "@/router"; import { useAuthStore } from "@/stores/auth"; import { useMenuStore } from "@/stores/menu"; +import TenantSwitcher from "@/components/TenantSwitcher.vue"; interface ModuleItem { id: number; @@ -527,22 +523,9 @@ onMounted(() => { display: flex; align-items: center; - .tenant-name { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 15px; - font-weight: 500; - color: @text-regular; - max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - .tenant-icon { - font-size: 16px; - color: #3973ff; - } + // 企业切换(TenantSwitcher 组件):样式由组件自带,这里只调楼宇图标颜色 + :deep(.tenant-chip .tenant-chip-icon) { + color: #3973ff; } } @@ -1119,7 +1102,8 @@ onMounted(() => { } .toolbar-left { - .tenant-name { + // 超窄屏隐藏企业名,避免挤压右侧按钮 + :deep(.tenant-chip) { display: none; } } @@ -1172,12 +1156,9 @@ onMounted(() => { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); .toolbar { - .toolbar-left .tenant-name { - color: #cfd3dc; - - .tenant-icon { - color: #5a8dff; - } + // 暗色下企业名文字颜色由组件内的主题变量自适应,这里只覆盖图标色 + .toolbar-left :deep(.tenant-chip .tenant-chip-icon) { + color: #5a8dff; } .toolbar-right {