refactor: 完成多租户架构移除与系统模块重构

1. 移除所有租户相关逻辑:删除租户ID查询参数、租户字段、租户存储相关代码
2. 重构菜单系统:合并platform/system菜单模块,调整菜单作用域为web/app
3. 修复导出接口:统一post请求使用body传参,替换get导出为post
4. 优化权限标识:更新权限路由前缀为module_system
5. 简化菜单过滤逻辑:移除工作区模式判断,直接展示所有菜单
6. 移除租户切换、代签入等无关功能组件与状态
7. 新增用户注册功能与工单导出接口
This commit is contained in:
zhangtao
2026-07-18 01:09:23 +08:00
parent 158c14a46d
commit 096e2216d6
79 changed files with 592 additions and 5816 deletions
@@ -11,11 +11,7 @@
@touchend="dragFinish"
>
<!-- 进度条 -->
<div
class="dv_progress_bar"
ref="progressBar"
:style="progressBarStyle"
></div>
<div class="dv_progress_bar" ref="progressBar" :style="progressBarStyle"></div>
<!-- 提示文本 -->
<div class="dv_text" :style="textStyle" ref="messageRef">
@@ -295,11 +291,11 @@ defineExpose({
position: absolute;
top: 0;
left: 0;
z-index: 9;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
z-index: 9;
i {
padding-left: 0;
@@ -6,7 +6,6 @@ export type AuditSearchFormParams = {
updated_id?: number | null;
created_time?: string[];
updated_time?: string[];
tenant_id?: number | null;
};
export interface GetAuditSearchFormItemsOptions {
@@ -16,17 +15,13 @@ export interface GetAuditSearchFormItemsOptions {
updatedByLabel?: string;
createdTimeLabel?: string;
updatedTimeLabel?: string;
tenantIdLabel?: string;
createdByPlaceholder?: string;
updatedByPlaceholder?: string;
tenantIdPlaceholder?: string;
/** 日期时间范围 valueFormat,默认 YYYY-MM-DD HH:mm:ss */
valueFormat?: string;
rangeSeparator?: string;
startPlaceholder?: string;
endPlaceholder?: string;
/** 是否显示租户ID字段,默认 false */
showTenantId?: boolean;
/** 是否显示创建人字段,默认 true */
showCreatedBy?: boolean;
/** 是否显示更新人字段,默认 true */
@@ -38,7 +33,7 @@ export interface GetAuditSearchFormItemsOptions {
}
/**
* 常见「创建人 / 更新人 / 创建时间 / 更新时间 / 租户ID」搜索项,供 FaSearchBar `items` 使用。
* 常见「创建人 / 更新人 / 创建时间 / 更新时间」搜索项,供 FaSearchBar `items` 使用。
* 当 FaSearchBar 设置 `includeAudit` 为 true 时,会自动追加这些字段。
* 创建人、更新人插槽由 FaSearchBar 内置提供,也可通过 `#created_id`、`#updated_id` 覆盖。
*/
@@ -50,7 +45,6 @@ export function getAuditSearchFormItems(
const rangeSep = options?.rangeSeparator ?? "至";
const sp = options?.startPlaceholder ?? "开始";
const ep = options?.endPlaceholder ?? "结束";
const showTenantId = options?.showTenantId ?? false;
const showCreatedBy = options?.showCreatedBy ?? true;
const showUpdatedBy = options?.showUpdatedBy ?? true;
const showCreatedTime = options?.showCreatedTime ?? true;
@@ -109,17 +103,6 @@ export function getAuditSearchFormItems(
span,
expandOnly: true, // 标记为展开专属字段
},
tenant_id: {
label: options?.tenantIdLabel ?? "租户ID",
key: "tenant_id",
type: "input",
props: {
placeholder: options?.tenantIdPlaceholder ?? "请输入租户ID",
style: { width: "100%" },
},
span,
expandOnly: true, // 标记为展开专属字段
},
};
const fieldOrder: string[] = [];
@@ -127,7 +110,6 @@ export function getAuditSearchFormItems(
if (showUpdatedBy) fieldOrder.push("updated_id");
if (showCreatedTime) fieldOrder.push("created_time");
if (showUpdatedTime) fieldOrder.push("updated_time");
if (showTenantId) fieldOrder.push("tenant_id");
return fieldOrder
.map((key) => fieldMap[key])
@@ -8,14 +8,6 @@
: '',
]"
>
<div
v-if="isImpersonate"
class="h-8 bg-warning/10 flex items-center justify-center gap-2 text-sm text-warning border-b border-warning/20"
>
<FaSvgIcon icon="ri:shield-alert-line" class="text-warning" />
<span>当前为平台管理员代签入模式 · 租户:{{ impersonateTenantName }}</span>
<ElButton type="primary" size="small" @click="handleExitImpersonate">退出工作区</ElButton>
</div>
<div
class="relative box-border flex justify-between h-15 leading-15 select-none"
:class="[
@@ -189,9 +181,6 @@
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
<!-- 租户切换器(全局可见,1步切换) -->
<FaTenantSwitcher />
<!-- 用户头像、菜单 -->
<FaUserMenu />
</div>
@@ -223,7 +212,6 @@ import { mittBus, themeAnimation } from "@utils";
import { useCommon } from "@/hooks/core/useCommon";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import FaUserMenu from "./widgets/FaUserMenu.vue";
import FaTenantSwitcher from "./widgets/FaTenantSwitcher.vue";
defineOptions({ name: "FaHeaderBar" });
@@ -278,16 +266,6 @@ const { visibleMenus: menuList } = storeToRefs(menuStore);
const showNotice = ref(false);
const notice = ref(null);
const isImpersonate = computed(() => userStore.info.is_impersonate === true);
const impersonateTenantName = computed(() => {
return userStore.workspaceTenant?.name || userStore.currentTenant?.name || "未知";
});
async function handleExitImpersonate() {
await userStore.exitTenantWorkspace();
}
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT);
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU);
@@ -1,437 +0,0 @@
<!-- 工作区模式切换器:平台模式 → 进入租户 | 租户模式 → 返回平台 -->
<template>
<!-- 平台管理模式:"进入租户"按钮 -->
<ElDropdown
v-if="isPlatformMode && tenantList.length > 0"
trigger="click"
placement="bottom-start"
:disabled="switching"
@command="handleEnterTenant"
@visible-change="(v) => (dropdownVisible = v)"
popper-class="fa-tenant-dropdown"
>
<div
class="tenant-switcher workspace-entry"
:class="{ 'is-active': dropdownVisible, 'is-switching': switching }"
title="进入租户工作区"
>
<FaSvgIcon icon="ri:building-2-fill" class="icon" />
<span class="name">进入租户</span>
<FaSvgIcon
v-if="!switching"
icon="ri:arrow-down-s-line"
class="arrow"
:class="{ rotated: dropdownVisible }"
/>
<ElIcon v-else class="arrow is-loading"><Loading /></ElIcon>
</div>
<template #dropdown>
<ElDropdownMenu>
<div class="dropdown-header">
<span class="dropdown-title">选择要进入的租户</span>
<ElTag size="small" effect="plain" type="info"> 共 {{ tenantList.length }} 个 </ElTag>
</div>
<ElDropdownItem v-for="t in tenantList" :key="t.id" :command="t.id" :disabled="switching">
<div class="dropdown-item">
<div class="item-main">
<FaSvgIcon icon="ri:building-2-line" class="item-icon" />
<div class="item-text">
<div class="item-name">{{ t.name }}</div>
<div v-if="t.code" class="item-code">{{ t.code }}</div>
</div>
</div>
<FaSvgIcon icon="ri:arrow-right-s-line" class="item-enter-icon" />
</div>
</ElDropdownItem>
<div class="dropdown-footer-hint">
<FaSvgIcon icon="ri:information-line" class="hint-icon" />
<span>进入后可查看该租户的数据和菜单</span>
</div>
<div v-if="switching" class="dropdown-footer">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>进入中...</span>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 租户工作区模式:显示当前租户 + 返回平台(仅超管可见) -->
<ElDropdown
v-else-if="!isPlatformMode && isSuperuser"
trigger="click"
placement="bottom-start"
:disabled="switching"
@command="handleCommand"
@visible-change="(v) => (dropdownVisible = v)"
popper-class="fa-tenant-dropdown"
>
<div
class="tenant-switcher"
:class="{ 'is-active': dropdownVisible, 'is-switching': switching }"
:title="'当前工作区:' + displayTenantName"
>
<FaSvgIcon icon="ri:building-2-fill" class="icon" />
<span class="name">{{ displayTenantName }}</span>
<FaSvgIcon
v-if="!switching"
icon="ri:arrow-down-s-line"
class="arrow"
:class="{ rotated: dropdownVisible }"
/>
<ElIcon v-else class="arrow is-loading"><Loading /></ElIcon>
</div>
<template #dropdown>
<ElDropdownMenu>
<div class="dropdown-header">
<span class="dropdown-title">当前工作区</span>
<ElTag size="small" effect="plain" type="success"> 租户 </ElTag>
</div>
<div class="dropdown-current-info">
<FaSvgIcon icon="ri:building-2-fill" class="current-icon" />
<div class="current-text">
<div class="current-name">{{ displayTenantName }}</div>
<div v-if="workspaceTenant?.code" class="current-code">
{{ workspaceTenant.code }}
</div>
</div>
</div>
<div class="dropdown-divider" />
<ElDropdownItem command="exitPlatform" :disabled="switching">
<div class="dropdown-item exit-action">
<FaSvgIcon icon="ri:arrow-go-back-fill" class="item-icon exit-icon" />
<div class="item-text">
<div class="item-name">返回平台管理</div>
<div class="item-code">回到平台管理视角</div>
</div>
</div>
</ElDropdownItem>
<div v-if="switching" class="dropdown-footer">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>切换中...</span>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 普通租户用户:仅显示当前租户名(无下拉) -->
<div
v-else-if="!isPlatformMode"
class="tenant-switcher tenant-display"
:title="'当前租户:' + displayTenantName"
>
<FaSvgIcon icon="ri:building-2-fill" class="icon" />
<span class="name">{{ displayTenantName }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useUserStore } from "@stores";
import { storeToRefs } from "pinia";
defineOptions({ name: "FaTenantSwitcher" });
const userStore = useUserStore();
const { tenantList, isPlatformMode, workspaceMode, workspaceTenant, currentTenant, info } =
storeToRefs(userStore);
const dropdownVisible = ref(false);
const switching = ref(false);
/** 是否为超管(判断是否显示"返回平台"入口) */
const isSuperuser = computed(() => !!info.value?.is_superuser);
/** 当前显示的工作区名称 */
const displayTenantName = computed(() => {
if (isPlatformMode.value) return "平台管理";
return (
workspaceTenant.value?.name || currentTenant.value?.name || info.value?.tenant_by?.name || "—"
);
});
/** 平台模式:进入租户工作区(平台管理员使用代签入) */
async function handleEnterTenant(tenantId: number) {
if (switching.value) return;
const tenant = tenantList.value.find((t) => t.id === tenantId);
if (!tenant) return;
switching.value = true;
dropdownVisible.value = false;
try {
if (isSuperuser.value) {
await userStore.impersonate(tenantId);
} else {
await userStore.selectTenant(tenantId);
}
workspaceMode.value = "tenant";
workspaceTenant.value = tenant;
setTimeout(() => window.location.reload(), 200);
} catch {
switching.value = false;
}
}
/** 租户模式:处理下拉命令 */
async function handleCommand(command: string) {
if (command === "exitPlatform") {
if (switching.value) return;
switching.value = true;
dropdownVisible.value = false;
try {
await userStore.exitTenantWorkspace();
setTimeout(() => window.location.reload(), 200);
} catch {
switching.value = false;
}
}
}
</script>
<style scoped>
.tenant-switcher {
display: inline-flex;
gap: 6px;
align-items: center;
max-width: 180px;
height: 32px;
padding: 0 10px;
font-size: 13px;
@media (width <= 640px) {
max-width: 120px;
padding: 0 8px;
font-size: 12px;
}
color: var(--el-text-color-primary);
cursor: pointer;
user-select: none;
background: var(--fa-gray-100);
border: 1px solid var(--fa-gray-300);
border-radius: 6px;
transition: all 0.15s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
&.is-active {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
}
&.is-switching {
cursor: wait;
opacity: 0.7;
}
&.workspace-entry {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
&:hover {
color: #fff;
background: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
&.tenant-display {
cursor: default;
}
.icon {
flex-shrink: 0;
font-size: 14px;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
white-space: nowrap;
}
.arrow {
flex-shrink: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
}
</style>
<style lang="scss">
/* 全局样式:下拉面板 */
.fa-tenant-dropdown {
min-width: 240px;
max-width: calc(100vw - 20px);
padding: 0 !important;
@media (width <= 640px) {
width: 90vw;
min-width: 200px;
}
.el-dropdown-menu__item {
padding: 0 !important;
&:not(.is-disabled):hover {
background: var(--fa-gray-200) !important;
}
}
.dropdown-header {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.dropdown-title {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.dropdown-divider {
height: 1px;
margin: 4px 0;
background: var(--el-border-color-lighter);
}
.dropdown-current-info {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
.current-icon {
flex-shrink: 0;
font-size: 20px;
color: var(--el-color-primary);
}
.current-text {
min-width: 0;
}
.current-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.current-code {
margin-top: 2px;
font-size: 11px;
color: var(--el-text-color-secondary);
}
}
.dropdown-item {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border-radius: 0;
transition: all 0.15s;
&.exit-action {
&:hover {
.exit-icon {
color: var(--el-color-danger);
}
}
}
}
.dropdown-footer-hint {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 6px 12px 8px;
font-size: 11px;
color: var(--el-text-color-secondary);
border-top: 1px solid var(--el-border-color-lighter);
.hint-icon {
font-size: 12px;
}
}
.item-main {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
min-width: 0;
}
.item-icon {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
}
.item-enter-icon {
flex-shrink: 0;
font-size: 14px;
color: var(--el-color-primary);
}
.exit-icon {
flex-shrink: 0;
font-size: 15px;
color: var(--el-text-color-secondary);
}
.item-text {
min-width: 0;
}
.item-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.item-code {
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.dropdown-footer {
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
padding: 8px 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
border-top: 1px solid var(--el-border-color-lighter);
}
}
</style>
@@ -52,21 +52,6 @@
</ul>
</ElScrollbar>
<!-- 工作区模式指示器 -->
<div class="workspace-indicator" :class="{ 'is-collapsed': !menuOpen }">
<FaSvgIcon
:icon="isPlatformMode ? 'ri:shield-user-fill' : 'ri:building-2-fill'"
class="indicator-icon"
/>
<span v-show="menuOpen" class="indicator-label">
{{
isPlatformMode
? "平台管理"
: workspaceTenant?.name || currentTenant?.name || sidebarTitle
}}
</span>
</div>
<FaIconButton
class="switch-btn size-10"
icon="ri:arrow-left-right-fill"
@@ -141,7 +126,7 @@
<script setup lang="ts">
import AppConfig from "@/config";
import { useConfigStore, useSettingsStore, useMenuStore, useUserStore } from "@stores";
import { useConfigStore, useSettingsStore, useMenuStore } from "@stores";
import { MenuTypeEnum, MenuWidth } from "@/enums/appEnum";
import { isIframe, handleMenuJump } from "@utils";
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
@@ -158,8 +143,6 @@ const route = useRoute();
const router = useRouter();
const settingStore = useSettingsStore();
const configStore = useConfigStore();
const userStore = useUserStore();
/** 租户配置:logo_url / name */
const sidebarLogoSrc = computed(() => {
const raw = configStore.configData.logo_url?.config_value;
@@ -182,8 +165,6 @@ const {
showAppLogo,
} = storeToRefs(settingStore);
const { isPlatformMode, workspaceTenant, currentTenant } = storeToRefs(userStore);
// 组件内部状态
const defaultOpenedMenus = ref<string[]>([]);
const showMobileModal = ref(false);
@@ -560,34 +541,6 @@ watch(menuOpen, (isMenuOpen: boolean) => {
.menu-model {
display: none;
}
.workspace-indicator {
display: flex;
gap: 8px;
align-items: center;
height: 40px;
padding: 0 20px;
color: var(--el-text-color-secondary);
border-top: 1px solid var(--fa-card-border);
.indicator-icon {
flex-shrink: 0;
font-size: 16px;
}
.indicator-label {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
&.is-collapsed {
justify-content: center;
padding: 0;
}
}
}
@media only screen and (width <= 800px) {
@@ -9,19 +9,6 @@
class="login-page-form mt-4"
@keyup.enter="$emit('submit')"
>
<ElFormItem prop="tenant_name">
<ElInput
v-model.trim="forgetForm.tenant_name"
class="custom-height"
clearable
:placeholder="$t('login.placeholder.tenantName')"
@keyup.enter="$emit('submit')"
>
<template #prefix>
<ElIcon><OfficeBuilding /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElFormItem prop="username">
<ElInput
v-model.trim="forgetForm.username"
@@ -93,7 +80,7 @@
</template>
<script setup lang="ts">
import { Lock, OfficeBuilding, User } from "@element-plus/icons-vue";
import { Lock, User } from "@element-plus/icons-vue";
import type { ForgetPasswordForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus";
@@ -9,18 +9,6 @@
class="login-page-form"
@keyup.enter="$emit('submit')"
>
<ElFormItem prop="tenant_name">
<ElInput
class="custom-height"
v-model.trim="registerForm.tenant_name"
clearable
:placeholder="$t('login.placeholder.tenantName')"
>
<template #prefix>
<ElIcon><OfficeBuilding /></ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElFormItem prop="username">
<ElInput
class="custom-height"
@@ -120,7 +108,7 @@
</template>
<script setup lang="ts">
import { Lock, Message, OfficeBuilding, User } from "@element-plus/icons-vue";
import { Lock, Message, User } from "@element-plus/icons-vue";
import type { RegisterForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus";
@@ -18,7 +18,6 @@
<div
class="auth-top-bar-actions-panel pointer-events-auto flex shrink-0 items-center justify-center gap-1.5 px-2 py-1.5 max-sm:mr-1"
>
<div class="color-picker-expandable relative flex items-center max-sm:hidden!">
<div
class="color-dots absolute right-0 rounded-full flex items-center gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
@@ -106,38 +105,12 @@
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
<!-- 租户切换 -->
<ElDropdown
@command="onTenantChange"
popper-class="langDropDownStyle"
trigger="hover"
>
<div
class="btn tenant-btn auth-top-bar__action h-8 w-8 cursor-pointer flex items-center justify-center transition duration-300"
:title="currentTenantName"
>
<FaSvgIcon icon="ri:building-line" class="text-xl transition-colors duration-300 text-g-800" />
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="t in tenantOptions" :key="t.id" class="lang-btn-item">
<ElDropdownItem
:command="t.id"
:class="{ 'is-selected': t.id === (currentTenantId || 1) }"
>
<span class="menu-txt">{{ t.name }}</span>
<FaSvgIcon icon="ri:check-fill" class="text-base" v-if="t.id === (currentTenantId || 1)" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import { useSettingsStore, useUserStore, useConfigStore } from "@stores";
@@ -147,7 +120,6 @@ import { languageOptions } from "@/locales";
import { LanguageEnum } from "@/enums/appEnum";
import AppConfig from "@/config";
import { LoginPanelAlign } from "@/components/views/fa-login/composables/useLoginPanelAlign";
import AuthAPI from "@/api/module_system/auth";
defineOptions({ name: "AuthTopBar" });
@@ -162,7 +134,6 @@ const props = withDefaults(defineProps<Props>(), {});
interface Emits {
"update:panelAlign": [value: LoginPanelAlign];
"tenantChange": [tenantId: number];
}
const emit = defineEmits<Emits>();
@@ -220,41 +191,6 @@ const changeLanguage = (lang: LanguageEnum) => {
userStore.setLanguage(lang);
};
// ── 租户切换 ──
interface TenantItem {
id: number;
name: string;
code: string;
}
const tenantOptions = ref<TenantItem[]>([]);
const currentTenantId = computed(() => {
const val = configStore.configData?.tenant_id?.config_value;
return val ? Number(val) : undefined;
});
const currentTenantName = computed(() => {
return configStore.configData?.name?.config_value?.trim() || "选择租户";
});
async function fetchTenantOptions() {
try {
const { data } = await AuthAPI.getTenantOptions();
tenantOptions.value = data?.data || [];
} catch {
tenantOptions.value = [];
}
}
function onTenantChange(tenantId: number) {
const numId = Number(tenantId);
if (numId === currentTenantId.value) return;
emit("tenantChange", numId);
}
onMounted(() => {
fetchTenantOptions();
});
const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return;
settingStore.setElementTheme(color);