feat: 新增租户切换组件和css目录优化

This commit is contained in:
Ray.Hao
2025-12-15 08:04:05 +08:00
parent 6e0597437e
commit 62e0af68a6
23 changed files with 193 additions and 166 deletions

View File

@@ -19,7 +19,5 @@ VITE_MOCK_DEV_SERVER=false
# 多租户功能开关
# ============================================
# 是否启用多租户功能默认false
# true: 启用多租户,显示租户切换器,发送 tenant-id 请求头
# false: 禁用多租户隐藏租户相关UI不发送 tenant-id 请求头
# 注意前端开关需要与后端配置youlai.tenant.enabled保持一致
VITE_APP_TENANT_ENABLED=false
VITE_APP_TENANT_ENABLED=true

View File

@@ -1,4 +1,6 @@
import request from "@/utils/request";
import type { MenuTypeEnum } from "@/enums/business";
const MENU_BASE_URL = "/api/v1/menus";
const MenuAPI = {
@@ -42,7 +44,6 @@ export interface MenuQuery {
/** 搜索关键字 */
keywords?: string;
}
import type { MenuTypeEnum } from "@/enums/system/menu-enum";
export interface MenuVO {
/** 子菜单 */
children?: MenuVO[];

View File

@@ -20,7 +20,7 @@
</template>
<script setup lang="ts">
import { ComponentSize } from "@/enums/settings/layout-enum";
import { ComponentSize } from "@/enums/settings";
import { useAppStore } from "@/store/modules/app-store";
const { t } = useI18n();

View File

@@ -0,0 +1,35 @@
<template>
<el-select
v-if="tenantList.length > 0"
v-model="currentTenantIdRef"
placeholder="选择租户"
style="width: 180px"
@change="onChange"
>
<el-option v-for="item in tenantList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
const emit = defineEmits<{
(e: "change", tenantId: number): void;
}>();
const tenantStore = useTenantStoreHook();
const tenantList = computed(() => tenantStore.tenantList);
const currentTenantIdRef = computed<number | null>({
get: () => tenantStore.currentTenantId,
set: (val) => {
tenantStore.currentTenantId = val;
},
});
function onChange(tenantId: number) {
emit("change", tenantId);
}
</script>

View File

@@ -20,7 +20,7 @@
<script setup lang="ts">
import { type RouteLocationNormalized } from "vue-router";
import { useSettingsStore, useTagsViewStore } from "@/store";
import variables from "@/styles/variables.scss";
import variables from "@/styles/variables.module.scss";
import Error404 from "@/views/error/404.vue";
const { cachedViews } = toRefs(useTagsViewStore());

View File

@@ -33,7 +33,7 @@ import { SidebarColor } from "@/enums/settings";
import { useSettingsStore, useAppStore } from "@/store";
import { isExternal } from "@/utils/index";
import MenuItem from "./components/MenuItem.vue";
import variables from "@/styles/variables.scss";
import variables from "@/styles/variables.module.scss";
const props = defineProps({
data: {

View File

@@ -39,7 +39,7 @@ defineOptions({
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
import variables from "@/styles/variables.scss";
import variables from "@/styles/variables.module.scss";
import { SidebarColor } from "@/enums/settings";
const router = useRouter();

View File

@@ -29,7 +29,7 @@
<!-- 租户选择如果启用多租户 -->
<div v-if="showTenantSelect" class="navbar-actions__item">
<TenantSelect />
<TenantSwitcher @change="handleTenantChange" />
</div>
</template>
@@ -83,7 +83,7 @@ import Fullscreen from "@/components/Fullscreen/index.vue";
import SizeSelect from "@/components/SizeSelect/index.vue";
import LangSelect from "@/components/LangSelect/index.vue";
import Notification from "@/components/Notification/index.vue";
import TenantSelect from "@/components/TenantSelect/index.vue";
import TenantSwitcher from "@/components/TenantSwitcher/index.vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
const { t } = useI18n();
@@ -113,6 +113,18 @@ const showTenantSelect = computed(() => {
return true;
});
function handleTenantChange(tenantId: number) {
tenantStore
.switchTenant(tenantId)
.then(() => {
ElMessage.success("切换租户成功");
window.location.reload();
})
.catch((error: any) => {
ElMessage.error(error?.message || "切换租户失败");
});
}
/**
* 打开个人中心页面
*/

View File

@@ -14,7 +14,7 @@ import LeftLayout from "@/layouts/modes/left/index.vue";
import TopLayout from "@/layouts/modes/top/index.vue";
import MixLayout from "@/layouts/modes/mix/index.vue";
import Settings from "./components/Settings/index.vue";
import { LayoutMode } from "@/enums/settings/layout-enum";
import { LayoutMode } from "@/enums/settings";
import { defaultSettings } from "@/settings";
const { currentLayout } = useLayout();

View File

@@ -69,7 +69,7 @@ import TagsView from "../../components/TagsView/index.vue";
import AppMain from "../../components/AppMain/index.vue";
import MenuItem from "../../components/Menu/components/MenuItem.vue";
import Hamburger from "@/components/Hamburger/index.vue";
import variables from "@/styles/variables.scss";
import variables from "@/styles/variables.module.scss";
import { isExternal } from "@/utils/index";
import { useAppStore, usePermissionStore } from "@/store";

View File

@@ -11,7 +11,6 @@ import App from "./App.vue";
// ===== 样式导入 =====
import "element-plus/theme-chalk/dark/css-vars.css";
import "vxe-table/lib/style.css";
import "@/styles/dark/css-vars.css";
import "@/styles/index.scss";
import "uno.css";
import "animate.css";

View File

@@ -24,7 +24,7 @@ export const defaultSettings: AppSettings = {
size: ComponentSize.DEFAULT,
// 语言
language: LanguageEnum.ZH_CN,
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
// 主题颜色 - 修改此值时需同步修改 src/styles/element-plus-vars.scss
themeColor: "#4080FF",
// 是否显示水印
showWatermark: false,
@@ -52,7 +52,7 @@ export const authConfig = {
} as const;
// 主题色预设 - 经典配色方案
// 注意:修改默认主题色时,需要同步修改 src/styles/variables.scss 中的 primary.base 值
// 注意:修改默认主题色时,需要同步修改 src/styles/element-plus-vars.scss 中的 primary.base 值
export const themeColorPresets = [
"#4080FF", // Arco Design 蓝 - 现代感强
"#1890FF", // Ant Design 蓝 - 经典商务

View File

@@ -108,35 +108,38 @@ export const useTenantStore = defineStore("tenant", () => {
*
* @param tenantId 目标租户ID
*/
function switchTenant(tenantId: number) {
return new Promise<void>((resolve, reject) => {
TenantAPI.switchTenant(tenantId)
.then((tenantInfo) => {
// 后端返回切换后的租户信息
if (tenantInfo) {
setCurrentTenant(tenantInfo);
} else {
// 如果后端未返回,从租户列表中找到对应的租户信息
const tenant = tenantList.value.find((t) => t.id === tenantId);
if (tenant) {
setCurrentTenant(tenant);
async function switchTenant(tenantId: number): Promise<void> {
try {
// 调用后端切换接口
const tenantInfo = await TenantAPI.switchTenant(tenantId);
// 后端返回切换后的租户信息
if (tenantInfo) {
setCurrentTenant(tenantInfo);
} else {
// 如果后端未返回,从租户列表中找到对应的租户信息
const tenant = tenantList.value.find((t) => t.id === tenantId);
if (tenant) {
setCurrentTenant(tenant);
} else {
// 如果列表中没有,重新获取租户信息
try {
const info = await TenantAPI.getCurrentTenant();
if (info) {
setCurrentTenant(info);
} else {
// 如果列表中没有,重新获取租户信息
TenantAPI.getCurrentTenant()
.then((info) => {
if (info) {
setCurrentTenant(info);
}
})
.catch(console.error);
throw new Error("无法获取租户信息");
}
} catch (error) {
console.error("获取租户信息失败:", error);
throw new Error("切换租户后无法获取租户信息");
}
resolve();
})
.catch((error) => {
reject(error);
});
});
}
}
} catch (error) {
console.error("切换租户失败:", error);
throw error;
}
}
/**
@@ -150,6 +153,13 @@ export const useTenantStore = defineStore("tenant", () => {
localStorage.removeItem(STORAGE_KEYS.TENANT_INFO);
}
/**
* 设置租户列表
*/
function setTenantList(list: TenantInfo[]) {
tenantList.value = list || [];
}
// 恢复本地租户信息
restoreTenant();
@@ -159,6 +169,7 @@ export const useTenantStore = defineStore("tenant", () => {
tenantList,
loadTenant,
fetchTenantList,
setTenantList,
setCurrentTenant,
switchTenant,
clearTenant,

View File

@@ -1,7 +0,0 @@
/* 暗黑模式通过 CSS 自定义变量官方链接https://element-plus.org/zh-CN/guide/dark-mode.html#%E9%80%9A%E8%BF%87-css */
html.dark {
.el-table {
/* 自定义表格选中高亮时当前行的背景颜色 */
--el-table-current-row-bg-color: var(--el-fill-color-light);
}
}

View File

@@ -0,0 +1,30 @@
/**
* Element Plus 变量覆盖
*
* 此文件用于覆盖 Element Plus 的默认主题变量
* 需要在 element-plus.scss 中导入,而不是在 variables.scss 中
*/
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
"base": #4080ff,
),
"success": (
"base": #23c343,
),
"warning": (
"base": #ff9a2e,
),
"danger": (
"base": #f76560,
),
"info": (
"base": #a9aeb8,
),
),
$bg-color: (
"page": #f5f8fd,
)
);

View File

@@ -1,3 +1,6 @@
// Element Plus 变量覆盖(必须在最前面)
@use "./element-plus-vars";
$border: 1px solid var(--el-border-color-light);
/* el-dialog */

View File

@@ -1,12 +1,12 @@
// 基础变量与主题色
@use "./variables";
// 基础重置与组件细化样式
// 1. 基础重置(补充 UnoCSS 预设未覆盖的全局样式)
@use "./reset";
@use "./element-plus";
// Vxe Table 主题覆写CSS 变量 + 自定义样式
// 2. 项目自定义主题变量CSS 变量 / SCSS 变量 / JS 导出
@use "./variables" as *;
// 3. UI 框架适配Element Plus & Vxe Table
@use "./element-plus";
@use "./vxe-table";
// 业务通用样式
// 4. 业务通用样式
@use "./common";

View File

@@ -1,3 +1,5 @@
// 全局基础重置:补充 UnoCSS 预设未覆盖的项目级样式
#app {
width: 100%;
height: 100%;
@@ -36,11 +38,6 @@ body {
text-rendering: optimizelegibility;
}
a {
color: inherit;
text-decoration: inherit;
}
img,
svg {
display: inline-block;

View File

@@ -0,0 +1,16 @@
/* stylelint-disable property-no-unknown */
// 通过 SCSS 变量导出给 JS/TS 使用的模块文件
// 注意:依赖 src/styles/variables.scss 中定义的 SCSS 变量
:export {
sidebar-width: $sidebar-width;
navbar-height: $navbar-height;
tags-view-height: $tags-view-height;
menu-background: $menu-background;
menu-text: $menu-text;
menu-active-text: $menu-active-text;
menu-hover: $menu-hover;
}
/* stylelint-enable property-no-unknown */

View File

@@ -1,29 +1,9 @@
@forward "element-plus/theme-chalk/src/common/var.scss" with (
$colors: (
"primary": (
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
"base": #4080ff,
),
"success": (
"base": #23c343,
),
"warning": (
"base": #ff9a2e,
),
"danger": (
"base": #f76560,
),
"info": (
"base": #a9aeb8,
),
),
$bg-color: (
"page": #f5f8fd,
)
);
/** 全局SCSS变量 */
/**
* 项目自定义主题变量CSS 变量 / SCSS 变量 / JS 导出)
* 与 Element Plus 主题变量覆盖element-plus-vars.scss职责分离
*
* 注意:此文件以下划线开头,是 Sass partial不会被单独编译只能被其他文件导入
*/
:root {
--menu-background: #fff; // 菜单背景色
@@ -56,6 +36,11 @@ html.dark {
--sidebar-logo-background: rgb(0 0 0 / 20%);
--sidebar-logo-text-color: #fff;
.el-table {
/* 自定义表格选中高亮时当前行的背景颜色(暗黑模式) */
--el-table-current-row-bg-color: var(--el-fill-color-light);
}
/** WangEditor Dark */
/* Textarea - css vars */
--w-e-textarea-bg-color: var(--el-bg-color); /* 深色背景 */
@@ -92,7 +77,7 @@ $sidebar-width-collapsed: 54px; // 侧边栏收缩宽度
$navbar-height: 50px; // 导航栏高度
$tags-view-height: 34px; // TagsView 高度
/* 供 JS/TS 侧按需读取的变量导出(保持与原 module 一致) */
/* 供 JS/TS 侧按需读取的变量导出 */
/* stylelint-disable property-no-unknown */
:export {
sidebar-width: $sidebar-width;

View File

@@ -4,7 +4,6 @@ import { ApiCodeEnum } from "@/enums/api";
import { AuthStorage, redirectToLogin } from "@/utils/auth";
import { useTokenRefresh } from "@/composables/auth/useTokenRefresh";
import { authConfig } from "@/settings";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
// 初始化token刷新组合式函数
const { refreshTokenAndRetry } = useTokenRefresh();
@@ -20,7 +19,7 @@ const httpRequest = axios.create({
});
/**
* 请求拦截器 - 添加 Authorization 头和租户ID
* 请求拦截器 - 添加 Authorization 头
*/
httpRequest.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
@@ -33,19 +32,6 @@ httpRequest.interceptors.request.use(
delete config.headers.Authorization;
}
// 添加租户ID到请求头如果存在
// 注意只有在登录成功后tenantStore 才会初始化,所以这里需要 try-catch
try {
const tenantStore = useTenantStoreHook();
const tenantId = tenantStore.currentTenantId;
if (tenantId) {
config.headers["tenant-id"] = String(tenantId);
}
} catch (error) {
// 如果租户 store 未初始化(如登录前),忽略错误
// 这是正常的,因为多租户功能是可选的,未启用时不会初始化 tenantStore
}
return config;
},
(error) => {

View File

@@ -97,16 +97,7 @@
>
<div class="tenant-select-content">
<p class="tenant-select-tip">检测到你的账号属于多个租户请选择登录租户</p>
<el-radio-group v-model="selectedTenantId" class="tenant-radio-group">
<el-radio
v-for="tenant in pendingTenants"
:key="tenant.id"
:label="tenant.id"
class="tenant-radio"
>
{{ tenant.name }}
</el-radio>
</el-radio-group>
<TenantSwitcher @change="(id: number) => (selectedTenantId = id)" />
</div>
<template #footer>
<el-button @click="tenantDialogVisible = false">取消</el-button>
@@ -146,12 +137,15 @@ import AuthAPI from "@/api/auth";
import type { LoginRequest } from "@/types/api";
import router from "@/router";
import { useUserStore } from "@/store";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import CommonWrapper from "@/components/CommonWrapper/index.vue";
import TenantSwitcher from "@/components/TenantSwitcher/index.vue";
import { AuthStorage } from "@/utils/auth";
import { ApiCodeEnum } from "@/enums";
const { t } = useI18n();
const userStore = useUserStore();
const tenantStore = useTenantStoreHook();
const route = useRoute();
onMounted(() => getCaptcha());
@@ -219,9 +213,6 @@ function getCaptcha() {
.finally(() => (codeLoading.value = false));
}
// 待选择的租户列表
const pendingTenants = ref<Array<{ id: number; name: string }>>([]);
/**
* 登录提交
*/
@@ -243,7 +234,8 @@ async function handleLoginSubmit() {
// 检查是否是 choose_tenant 响应
if (error?.code === ApiCodeEnum.CHOOSE_TENANT && error?.data?.tenants) {
// 需要选择租户
pendingTenants.value = error.data.tenants;
tenantStore.setTenantList(error.data.tenants);
selectedTenantId.value = error.data.tenants[0]?.id || null;
tenantDialogVisible.value = true;
return; // 等待用户选择租户
}
@@ -304,12 +296,12 @@ function toOtherForm(type: "register" | "resetPwd") {
.auth-panel-form {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 0.75rem;
}
.auth-panel-form__title {
margin: 0 0 0.75rem;
font-size: 1.25rem;
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
}
@@ -317,7 +309,7 @@ function toOtherForm(type: "register" | "resetPwd") {
.divider-container {
display: flex;
align-items: center;
margin: 24px 0;
margin: 16px 0;
.divider-line {
flex: 1;
@@ -342,33 +334,5 @@ function toOtherForm(type: "register" | "resetPwd") {
font-size: 14px;
color: var(--el-text-color-regular);
}
.tenant-radio-group {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
.tenant-radio {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
transition: all 0.3s;
&:hover {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
:deep(.el-radio__input.is-checked) {
+ .el-radio__label {
font-weight: 500;
color: var(--el-color-primary);
}
}
}
}
}
</style>

View File

@@ -123,9 +123,7 @@ onBeforeUnmount(() => {
height: 100%;
padding: clamp(1rem, 3vw, 2rem);
overflow: hidden;
background:
radial-gradient(circle at 20% 20%, rgba(64, 128, 255, 0.18), transparent 55%),
radial-gradient(circle at 80% 80%, rgba(22, 93, 255, 0.16), transparent 50%);
background-color: #f5f7ff;
&::before {
position: fixed;
@@ -201,13 +199,11 @@ onBeforeUnmount(() => {
flex-direction: column;
justify-content: center;
padding: clamp(1.5rem, 3vw, 3rem);
color: rgba(20, 40, 80, 0.95);
text-shadow: 0 4px 16px rgba(15, 60, 110, 0.12);
color: var(--el-text-color-primary);
animation: featureFade 0.8s ease-out;
@media (prefers-color-scheme: dark) {
color: rgba(236, 242, 255, 0.92);
text-shadow: none;
}
}
@@ -272,7 +268,7 @@ onBeforeUnmount(() => {
margin-bottom: 1.5rem;
font-size: 1rem;
line-height: 1.7;
color: rgba(35, 40, 65, 0.85);
color: var(--el-text-color-regular);
@media (prefers-color-scheme: dark) {
color: rgba(220, 230, 255, 0.75);
@@ -292,8 +288,8 @@ onBeforeUnmount(() => {
align-items: flex-start;
padding: 0.75rem 1rem;
font-weight: 500;
color: rgba(32, 37, 60, 0.9);
background: rgba(255, 255, 255, 0.55);
color: var(--el-text-color-primary);
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(64, 128, 255, 0.08);
border-radius: 12px;
backdrop-filter: blur(6px);
@@ -321,11 +317,12 @@ onBeforeUnmount(() => {
.auth-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 1rem;
align-self: center;
justify-content: flex-start;
justify-self: end;
width: min(520px, 100%);
padding: clamp(2rem, 3vw, 2.75rem);
width: min(420px, 100%);
padding: clamp(1.5rem, 3vw, 2rem);
margin-inline: auto;
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(22, 93, 255, 0.1);
@@ -359,11 +356,11 @@ onBeforeUnmount(() => {
.auth-panel__brand {
display: flex;
gap: 1rem;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding-bottom: 1.25rem;
margin-bottom: 1.5rem;
padding-bottom: 0.875rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(22, 93, 255, 0.06);
@media (prefers-color-scheme: dark) {
@@ -449,7 +446,7 @@ onBeforeUnmount(() => {
margin-inline: auto;
:deep(.el-form-item) {
margin-bottom: 1.25rem;
margin-bottom: 1rem;
}
:deep(.el-input__wrapper) {
@@ -472,8 +469,8 @@ onBeforeUnmount(() => {
}
.auth-panel__footer {
padding-top: 1.25rem;
margin-top: 0.25rem;
padding-top: 0.875rem;
margin-top: 0.125rem;
font-size: 0.875rem;
text-align: center;
border-top: 1px solid rgba(22, 93, 255, 0.06);