增加租户切换功能
This commit is contained in:
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || "登录失败";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user