refactor: 项目结构优化调整

This commit is contained in:
Ray.Hao
2025-12-20 21:56:48 +08:00
parent 5851976c5d
commit 65ad4fe59f
68 changed files with 2463 additions and 1761 deletions

View File

@@ -3,16 +3,28 @@
<div class="ai-assistant">
<!-- AI 助手图标按钮 -->
<el-button
v-if="!dialogVisible"
v-if="!dialogVisible && !fabCollapsed"
class="ai-fab-button"
type="primary"
circle
size="large"
:style="fabStyle"
@contextmenu.prevent="fabCollapsed = true"
@click="handleOpen"
>
<div class="i-svg:ai ai-icon" />
</el-button>
<!-- 收缩态贴边小标签避免遮挡表单控件 -->
<div
v-if="!dialogVisible && fabCollapsed"
class="ai-fab-tab"
:style="fabStyle"
@click="fabCollapsed = false"
>
AI
</div>
<!-- AI 对话框 -->
<el-dialog
v-model="dialogVisible"
@@ -107,7 +119,7 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { onBeforeUnmount, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import AiCommandApi from "@/api/ai";
@@ -151,6 +163,111 @@ const command = ref("");
const loading = ref(false);
const response = ref<AiResponse | null>(null);
const fabCollapsed = useStorage<boolean>("vea:ui:ai_assistant_fab_collapsed", false);
const fabRight = ref(30);
const fabBottom = ref(80);
const fabStyle = computed(() => ({
right: `${fabRight.value}px`,
bottom: `${fabBottom.value}px`,
}));
const isElementVisible = (el: Element) => {
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
return (el as HTMLElement).getClientRects().length > 0;
};
const getActiveRightDrawerWidth = (): number => {
const drawers = Array.from(document.querySelectorAll(".el-drawer"));
for (let i = drawers.length - 1; i >= 0; i--) {
const drawer = drawers[i] as HTMLElement;
if (!isElementVisible(drawer)) {
continue;
}
const rect = drawer.getBoundingClientRect();
if (rect.width > 0 && rect.right >= window.innerWidth - 1) {
return rect.width;
}
}
return 0;
};
const updateFabPosition = () => {
const safeMargin = 24;
const drawerWidth = getActiveRightDrawerWidth() || 0;
const baseRight = drawerWidth + 30;
// base position
const nextRight = baseRight;
let nextBottom = 80;
// Avoid Element Plus popper overlays (select dropdown, icon picker, date picker, etc.)
// If the FAB would overlap any visible popper, push it upward.
const fabSize = fabCollapsed.value ? 42 : 60;
const computeFabRect = (rightPx: number, bottomPx: number) => {
const right = window.innerWidth - rightPx;
const left = right - fabSize;
const bottom = window.innerHeight - bottomPx;
const top = bottom - fabSize;
return { left, right, top, bottom };
};
const intersects = (
a: { left: number; right: number; top: number; bottom: number },
b: DOMRect
) => {
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
};
const poppers = Array.from(document.querySelectorAll(".el-popper"));
for (const popper of poppers) {
if (!isElementVisible(popper)) {
continue;
}
const rect = (popper as HTMLElement).getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
continue;
}
const candidateFabRect = computeFabRect(nextRight, nextBottom);
if (intersects(candidateFabRect, rect)) {
const requiredBottom = Math.ceil(window.innerHeight - rect.top + safeMargin);
nextBottom = Math.max(nextBottom, requiredBottom);
}
}
// clamp so the button doesn't get pushed off-screen
const maxBottom = window.innerHeight - fabSize - safeMargin;
nextBottom = Math.min(nextBottom, Math.max(0, maxBottom));
fabRight.value = nextRight + (drawerWidth > 0 ? safeMargin : 0);
fabBottom.value = nextBottom;
};
watch(
fabCollapsed,
() => {
updateFabPosition();
},
{ flush: "post" }
);
let domObserver: MutationObserver | null = null;
let rafId: number | null = null;
const scheduleUpdateFabPosition = () => {
if (rafId != null) {
return;
}
rafId = window.requestAnimationFrame(() => {
rafId = null;
updateFabPosition();
});
};
// 快捷命令示例
const examples = [
"修改test用户的姓名为测试人员",
@@ -550,7 +667,32 @@ const executeAction = async (action: AiAction) => {
};
// 组件卸载时清理定时器
onMounted(() => {
updateFabPosition();
window.addEventListener("resize", updateFabPosition);
domObserver = new MutationObserver(() => {
scheduleUpdateFabPosition();
});
domObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style"],
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateFabPosition);
if (domObserver) {
domObserver.disconnect();
domObserver = null;
}
if (rafId != null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (navigationTimer) {
clearTimeout(navigationTimer);
navigationTimer = null;
@@ -566,8 +708,6 @@ onBeforeUnmount(() => {
.ai-assistant {
.ai-fab-button {
position: fixed;
right: 30px;
bottom: 80px;
z-index: 9999;
width: 60px;
height: 60px;
@@ -584,6 +724,24 @@ onBeforeUnmount(() => {
height: 32px;
}
}
.ai-fab-tab {
position: fixed;
z-index: 9999;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
font-size: 14px;
font-weight: 600;
color: #fff;
cursor: pointer;
user-select: none;
background: var(--el-color-primary);
border-radius: 999px;
box-shadow: 0 4px 12px rgba(2, 119, 252, 0.35);
}
}
.ai-assistant-dialog {

View File

@@ -1,152 +0,0 @@
<template>
<el-dropdown trigger="click" @command="handleTenantSwitch">
<div class="tenant-select">
<el-icon class="tenant-select__icon"><OfficeBuilding /></el-icon>
<span class="tenant-select__name">{{ currentTenantName }}</span>
<el-icon class="tenant-select__arrow"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="tenant in tenantList"
:key="tenant.id"
:command="tenant.id"
:class="{ 'is-active': tenant.id === currentTenantId }"
>
<div class="tenant-item">
<span class="tenant-item__name">{{ tenant.name }}</span>
<el-icon v-if="tenant.id === currentTenantId" class="tenant-item__check">
<Check />
</el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { ElMessage } from "element-plus";
import { OfficeBuilding, ArrowDown, Check } from "@element-plus/icons-vue";
const tenantStore = useTenantStoreHook();
// 当前租户名称
const currentTenantName = computed(() => {
if (tenantStore.currentTenant?.name) {
return tenantStore.currentTenant.name;
}
// 如果当前租户信息不存在,尝试从租户列表中查找
if (tenantStore.currentTenantId) {
const tenant = tenantStore.tenantList.find((t) => t.id === tenantStore.currentTenantId);
if (tenant) {
return tenant.name;
}
}
return "未选择租户";
});
// 当前租户ID
const currentTenantId = computed(() => tenantStore.currentTenantId);
// 租户列表
const tenantList = computed(() => tenantStore.tenantList);
/**
* 切换租户
*/
async function handleTenantSwitch(tenantId: number) {
if (tenantId === currentTenantId.value) {
return;
}
try {
await tenantStore.switchTenant(tenantId);
ElMessage.success("切换租户成功");
// 刷新页面以重新加载菜单和权限
window.location.reload();
} catch (error: any) {
ElMessage.error(error.message || "切换租户失败");
}
}
// 初始化:获取租户列表
onMounted(() => {
tenantStore.fetchTenantList().catch((error) => {
console.error("获取租户列表失败:", error);
});
});
</script>
<style lang="scss" scoped>
.tenant-select {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
&__icon {
margin-right: 6px;
font-size: 18px;
color: inherit;
transition: color 0.3s;
}
&__name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
color: inherit;
white-space: nowrap;
transition: color 0.3s;
}
&__arrow {
margin-left: 6px;
font-size: 12px;
color: inherit;
opacity: 0.7;
transition: all 0.3s;
}
&:hover {
background: rgba(0, 0, 0, 0.04);
.tenant-select__icon,
.tenant-select__name {
color: var(--el-color-primary);
}
.tenant-select__arrow {
color: var(--el-color-primary);
opacity: 1;
}
}
}
.tenant-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
&__name {
flex: 1;
}
&__check {
margin-left: 8px;
color: var(--el-color-primary);
}
}
:deep(.el-dropdown-menu__item.is-active) {
background-color: var(--el-color-primary-light-9);
}
</style>

View File

@@ -1,187 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="选择租户"
width="400px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
>
<div v-if="loading" class="tenant-dialog-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载租户列表...</span>
</div>
<div v-else-if="tenantList.length === 0" class="tenant-dialog-empty">
<el-empty description="暂无可用租户" />
</div>
<el-radio-group v-else v-model="selectedTenantId" class="tenant-radio-group">
<el-radio
v-for="tenant in tenantList"
:key="tenant.id"
:label="tenant.id"
class="tenant-radio-item"
>
<div class="tenant-radio-content">
<div class="tenant-radio-content__name">{{ tenant.name }}</div>
<div v-if="tenant.code" class="tenant-radio-content__code">{{ tenant.code }}</div>
</div>
</el-radio>
</el-radio-group>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" :loading="switching" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
import { ElMessage } from "element-plus";
import { Loading } from "@element-plus/icons-vue";
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
confirm: [];
}>();
const tenantStore = useTenantStoreHook();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
const loading = ref(false);
const switching = ref(false);
const selectedTenantId = ref<number | null>(null);
const tenantList = computed(() => tenantStore.tenantList);
// 监听对话框打开,加载租户列表
watch(visible, (newVal) => {
if (newVal) {
loadTenantList();
// 默认选择当前租户或第一个租户
selectedTenantId.value = tenantStore.currentTenantId || tenantList.value[0]?.id || null;
}
});
/**
* 加载租户列表
*/
async function loadTenantList() {
loading.value = true;
try {
await tenantStore.fetchTenantList();
// 如果列表为空,自动关闭对话框
if (tenantList.value.length === 0) {
visible.value = false;
ElMessage.warning("您暂无可用租户");
}
} catch (error: any) {
ElMessage.error(error.message || "获取租户列表失败");
} finally {
loading.value = false;
}
}
/**
* 确认选择
*/
async function handleConfirm() {
if (!selectedTenantId.value) {
ElMessage.warning("请选择租户");
return;
}
switching.value = true;
try {
await tenantStore.switchTenant(selectedTenantId.value);
ElMessage.success("切换租户成功");
visible.value = false;
emit("confirm");
} catch (error: any) {
ElMessage.error(error.message || "切换租户失败");
} finally {
switching.value = false;
}
}
/**
* 取消
*/
function handleCancel() {
// 如果用户没有租户,不允许取消
if (tenantList.value.length === 0) {
return;
}
visible.value = false;
}
</script>
<style lang="scss" scoped>
.tenant-dialog-loading {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
padding: 40px 0;
color: var(--el-text-color-secondary);
}
.tenant-dialog-empty {
padding: 20px 0;
}
.tenant-radio-group {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.tenant-radio-item {
width: 100%;
padding: 12px;
margin: 0;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
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__inner {
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
}
}
.tenant-radio-content {
display: flex;
flex-direction: column;
gap: 4px;
&__name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
&__code {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -1,124 +0,0 @@
<template>
<el-dropdown v-if="showTenantSelector" trigger="click" @command="handleSwitchTenant">
<div class="tenant-selector">
<el-icon><OfficeBuilding /></el-icon>
<span class="tenant-name">{{ currentTenantName }}</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="tenant in tenantList"
:key="tenant.id"
:command="tenant.id"
:disabled="tenant.id === currentTenantId"
>
<div class="tenant-item">
<span>{{ tenant.name }}</span>
<el-icon v-if="tenant.id === currentTenantId" class="check-icon">
<Check />
</el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ElMessage } from "element-plus";
import { OfficeBuilding, ArrowDown, Check } from "@element-plus/icons-vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
/**
* 租户切换器组件
*
* 功能:
* - 显示当前租户名称
* - 下拉列表展示所有可访问的租户
* - 点击切换租户
* - 切换后刷新页面以重新加载数据
*
* 使用条件:
* - 需要在 .env 中设置 VITE_APP_TENANT_ENABLED=true
* - 后端需要启用多租户功能
* - 用户至少属于一个租户
*/
// 多租户开关
const TENANT_ENABLED = import.meta.env.VITE_APP_TENANT_ENABLED === "true";
const tenantStore = useTenantStoreHook();
// 当前租户ID
const currentTenantId = computed(() => tenantStore.currentTenantId);
// 当前租户名称
const currentTenantName = computed(() => {
return tenantStore.currentTenant?.name || "未选择租户";
});
// 租户列表
const tenantList = computed(() => tenantStore.tenantList);
// 是否显示租户切换器(多租户开关启用 且 有租户列表)
const showTenantSelector = computed(() => {
return TENANT_ENABLED && tenantList.value.length > 0;
});
/**
* 切换租户
*/
const handleSwitchTenant = async (tenantId: number) => {
if (tenantId === currentTenantId.value) {
return;
}
try {
await tenantStore.switchTenant(tenantId);
ElMessage.success("切换租户成功");
// 刷新页面以重新加载数据(确保所有数据都基于新租户)
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error: any) {
ElMessage.error(error?.msg || "切换租户失败");
}
};
</script>
<style scoped lang="scss">
.tenant-selector {
display: flex;
align-items: center;
height: 50px;
padding: 0 15px;
cursor: pointer;
user-select: none;
transition: background-color 0.3s;
&:hover {
background-color: var(--el-fill-color-light);
}
.tenant-name {
margin: 0 8px;
font-size: 14px;
font-weight: 500;
}
}
.tenant-item {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 150px;
.check-icon {
margin-left: 10px;
color: var(--el-color-primary);
}
}
</style>

View File

@@ -1,17 +1,33 @@
<template>
<el-select
<el-dropdown
v-if="tenantList.length > 0"
v-model="currentTenantIdRef"
placeholder="选择租户"
style="width: 180px"
@change="onChange"
class="tenant-switcher"
trigger="click"
:hide-on-click="true"
@command="onCommand"
>
<el-option v-for="item in tenantList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<div class="tenant-switcher__trigger">
<span class="tenant-switcher__label">{{ currentTenantName }}</span>
<el-icon class="tenant-switcher__icon"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu class="tenant-switcher__menu">
<el-dropdown-item
v-for="item in tenantList"
:key="item.id"
:command="item.id"
:class="{ 'is-active': item.id === currentTenantIdRef }"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
import { useTenantStoreHook } from "@/store/modules/tenant-store";
const emit = defineEmits<{
@@ -29,7 +45,62 @@ const currentTenantIdRef = computed<number | null>({
},
});
function onChange(tenantId: number) {
const currentTenantName = computed(() => {
const currentId = currentTenantIdRef.value;
const fromList = tenantList.value.find((t) => t.id === currentId)?.name;
return fromList || tenantStore.currentTenant?.name || "切换租户";
});
function onCommand(tenantId: number) {
if (tenantId === currentTenantIdRef.value) {
return;
}
emit("change", tenantId);
}
</script>
<style scoped lang="scss">
.tenant-switcher {
display: flex;
align-items: center;
height: 100%;
&__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 10px;
cursor: pointer;
user-select: none;
border-radius: 8px;
transition: background 0.2s;
}
&__label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
&__icon {
margin-left: 6px;
font-size: 12px;
opacity: 0.8;
}
&:hover {
.tenant-switcher__trigger {
background: rgba(0, 0, 0, 0.04);
}
}
:deep(.el-dropdown-menu__item.is-active) {
font-weight: 600;
color: var(--el-color-primary);
}
}
</style>