增加租户切换功能

This commit is contained in:
2026-09-29 15:38:00 +08:00
parent 1685b22e1e
commit 0dc5c2f075
8 changed files with 305 additions and 60 deletions
+1
View File
@@ -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']
+3 -22
View File
@@ -69,9 +69,8 @@
</template>
</el-dropdown>
<span v-if="companyName" class="company-name" title="当前登录的企业">
{{ companyName }}
</span>
<!-- 当前企业 + 一人多企业时可点击切换 -->
<TenantSwitcher />
<el-dropdown trigger="click" @command="handleCommand">
<span class="el-dropdown-link" style="cursor: pointer;">
<img :src="getImageUrl('user')" class="user" />
@@ -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;
+221
View File
@@ -0,0 +1,221 @@
<template>
<!-- 当前企业 + 切换:企业名右侧带箭头,点击弹出该账号可进入的企业列表 -->
<el-dropdown
v-if="companyName"
trigger="click"
class="tenant-switcher"
popper-class="tenant-switcher-popper"
@command="handleSwitch"
>
<span class="tenant-chip" :class="[variantClass, 'is-switchable']" :title="`当前企业:${companyName},点击切换`">
<el-icon v-if="iconVisible" class="tenant-chip-icon"><OfficeBuilding /></el-icon>
<span class="tenant-chip-text">{{ companyName }}</span>
<el-icon class="tenant-chip-caret"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in tenantOptions"
:key="item.tid"
:command="item.tid"
:disabled="isCurrent(item.tid)"
>
<span class="tenant-option">
<span class="tenant-option-name">{{ item.tenant_name }}</span>
<el-icon v-if="isCurrent(item.tid)" class="tenant-option-check"><Check /></el-icon>
</span>
</el-dropdown-item>
<el-dropdown-item v-if="tenantOptions.length === 1" disabled>
<span class="tenant-option-tip">当前账号未关联其他企业</span>
</el-dropdown-item>
<el-dropdown-item v-if="!tenantOptions.length" :command="RELOAD_COMMAND" :disabled="loading">
<span class="tenant-option-tip">
{{ loading ? "企业列表加载中…" : "未获取到企业列表,点击重试" }}
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { ArrowDown, Check, OfficeBuilding } from "@element-plus/icons-vue";
import { useAuthStore } from "@/stores/auth";
import { fetchUserInfo, resolveTenantName, switchTenant } from "@/utils/authClient";
/**
* 当前企业名 + 多企业切换。
*
* variant:
* chip —— 浅色标签样式(顶部栏使用)
* plain —— 无底色 + 楼宇图标(home 门户顶部使用)
*
* 只要拿到企业列表(≥1 家)就渲染「企业名 + 箭头」的可点击下拉,
* 只有一家时列表内给出说明;列表为空时退化为纯文本企业名。
*/
const props = withDefaults(defineProps<{ variant?: "chip" | "plain" }>(), {
variant: "chip",
});
const authStore = useAuthStore();
// 下拉里「重新拉取企业列表」的命令值
const RELOAD_COMMAND = "__reload__";
const tenantOptions = ref<Array<{ tid: number | string; tenant_name: string }>>([]);
const loading = ref(false);
const companyName = computed(() => authStore.user?.tenant_name || "");
const currentTid = computed(() => authStore.user?.tid || "");
const variantClass = computed(() => (props.variant === "plain" ? "is-plain" : "is-chip"));
const iconVisible = computed(() => props.variant === "plain");
function isCurrent(tid: number | string) {
return !!tid && String(tid) === String(currentTid.value);
}
async function loadTenants() {
if (!authStore.token) return;
loading.value = true;
try {
// 用认证中心 userinfo 取「可进入的企业」;同时兜底:本地缓存缺企业名时补上,
// 避免顶部企业名为空白(老会话、缓存被清等场景)
const info = await fetchUserInfo();
const list = Array.isArray(info?.tenants) ? info.tenants : [];
tenantOptions.value = list.map((t: any) => ({
tid: t.tid,
tenant_name: t.tenant_name || `企业${t.tid}`,
}));
const name = resolveTenantName(info);
if (name && !companyName.value) {
authStore.updateUserInfo({
...authStore.user,
tid: info?.tid ?? authStore.user?.tid,
tenant_name: name,
});
}
} catch (e) {
// 拉取失败不影响展示,下拉里会给出「点击重试」
tenantOptions.value = [];
console.error("加载可进入企业列表失败:", e);
} finally {
loading.value = false;
}
}
async function handleSwitch(tid: number | string) {
if (tid === RELOAD_COMMAND) {
await loadTenants();
return;
}
if (!tid || isCurrent(tid)) return;
const target = tenantOptions.value.find((item) => String(item.tid) === String(tid));
try {
await switchTenant(tid);
ElMessage.success(`已切换到「${target?.tenant_name || "新企业"}」`);
// 菜单、按钮权限、模块套餐均按企业隔离,整页重载确保全部按新企业重新初始化
setTimeout(() => window.location.reload(), 300);
} catch (e: any) {
ElMessage.error(e?.message || "切换企业失败");
}
}
onMounted(loadTenants);
</script>
<style scoped lang="less">
.tenant-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 220px;
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;
&.is-switchable {
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-8);
}
}
.tenant-chip-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-chip-caret {
flex: 0 0 auto;
font-size: 12px;
}
.tenant-chip-icon {
flex: 0 0 auto;
font-size: 15px;
}
}
// 顶部栏:与右侧圆形操作按钮等高对齐
.tenant-chip.is-chip {
height: 35px;
}
// home 门户顶部:无底色,图标 + 常规文字色(跟随主题变量自动适配暗色)
.tenant-chip.is-plain {
gap: 6px;
padding: 0;
max-width: 260px;
font-size: 15px;
color: var(--el-text-color-regular);
background-color: transparent;
border: none;
&.is-switchable:hover {
color: var(--el-color-primary);
background-color: transparent;
}
}
</style>
<!-- 下拉弹层由 el-dropdown 挂到 body,scoped 样式够不到,故用 popper 类名前缀限定 -->
<style lang="less">
.tenant-switcher-popper {
.tenant-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 150px;
.tenant-option-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tenant-option-check {
flex: 0 0 auto;
color: var(--el-color-primary);
}
}
.tenant-option-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>
+5 -1
View File
@@ -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) {
+42 -4
View File
@@ -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 {
+18 -1
View File
@@ -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;
+4 -2
View File
@@ -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 || "登录失败";
+9 -28
View File
@@ -3,12 +3,8 @@
<div class="home-top">
<div class="toolbar">
<div class="toolbar-left">
<template v-if="tenantName">
<span class="tenant-name" :title="tenantName">
<el-icon class="tenant-icon"><OfficeBuilding /></el-icon>
{{ tenantName }}
</span>
</template>
<!-- 当前企业 + 一人多企业时可点击切换 -->
<TenantSwitcher variant="plain" />
</div>
<div class="toolbar-right">
<el-tooltip content="刷新菜单" placement="bottom">
@@ -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,24 +523,11 @@ 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;
// 企业切换(TenantSwitcher 组件):样式由组件自带,这里只调楼宇图标颜色
:deep(.tenant-chip .tenant-chip-icon) {
color: #3973ff;
}
}
}
.toolbar-right {
display: flex;
@@ -1119,7 +1102,8 @@ onMounted(() => {
}
.toolbar-left {
.tenant-name {
// 超窄屏隐藏企业名,避免挤压右侧按钮
:deep(.tenant-chip) {
display: none;
}
}
@@ -1172,13 +1156,10 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.toolbar {
.toolbar-left .tenant-name {
color: #cfd3dc;
.tenant-icon {
// 暗色下企业名文字颜色由组件内的主题变量自适应,这里只覆盖图标色
.toolbar-left :deep(.tenant-chip .tenant-chip-icon) {
color: #5a8dff;
}
}
.toolbar-right {
.toolbar-btn {