mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-02 16:37:53 +00:00
refactor: 重构项目模块结构与代码细节优化
1. 调整后端模块路由与插件文件结构,迁移部分模块代码至api/v1目录 2. 优化代码注释格式与文档字符串,简化冗余代码 3. 添加监控仪表盘、工单评论等新模块功能 4. 修复前端部分组件交互逻辑与类型定义 5. 清理冗余的初始化数据与废弃配置文件 6. 新增租户注册表单字段与国际化支持 7. 优化HTTP请求拦截器与快捷键功能
This commit is contained in:
@@ -42,9 +42,34 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { commentList, Comment } from "@/mock/temp/commentDetail";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
interface Comment {
|
||||
id: number;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
replies: Comment[];
|
||||
}
|
||||
|
||||
const mockCommentList: Comment[] = [
|
||||
{
|
||||
id: 1,
|
||||
author: "白夜",
|
||||
content: "黑神话悟空的打斗场面真的燃爆了!期待上线!",
|
||||
timestamp: "2024-09-04 09:00",
|
||||
replies: [
|
||||
{
|
||||
id: 101,
|
||||
author: "星河",
|
||||
content: "是啊,特别是那些技能特效,简直帅炸!",
|
||||
timestamp: "2024-09-04 09:15",
|
||||
replies: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
defineOptions({ name: "FaCommentWidget" });
|
||||
|
||||
interface Props {
|
||||
@@ -65,7 +90,7 @@ const emit = defineEmits<Emits>();
|
||||
|
||||
/** 内部评论数据(优先使用 props,否则使用 mock 数据) */
|
||||
const internalComments = ref<Comment[]>(
|
||||
props.comments.length > 0 ? props.comments : commentList.value
|
||||
props.comments.length > 0 ? props.comments : mockCommentList
|
||||
);
|
||||
|
||||
const newComment = ref<Partial<Comment>>({
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
@keyup.enter="emit('search', modelValue)"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow :gutter="gutter">
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<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="[
|
||||
@@ -134,7 +142,14 @@
|
||||
class="notice-button relative"
|
||||
@click="visibleNotice"
|
||||
>
|
||||
<div class="absolute top-2 right-2 size-1.5 bg-danger! rounded-full"></div>
|
||||
<ElBadge
|
||||
v-if="noticeStore.total > 0"
|
||||
:value="noticeStore.total > 99 ? '99+' : noticeStore.total"
|
||||
:max="99"
|
||||
class="absolute top-0 right-0"
|
||||
>
|
||||
<div class="size-1.5"></div>
|
||||
</ElBadge>
|
||||
</FaIconButton>
|
||||
|
||||
<!-- 聊天按钮 -->
|
||||
@@ -195,7 +210,13 @@ import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useFullscreen, useWindowSize } from "@vueuse/core";
|
||||
import { LanguageEnum, MenuTypeEnum } from "@/enums/appEnum";
|
||||
import { useSettingsStore, useMenuStore, useUserStore, useConfigStore } from "@stores";
|
||||
import {
|
||||
useSettingsStore,
|
||||
useMenuStore,
|
||||
useUserStore,
|
||||
useNoticeStore,
|
||||
useConfigStore,
|
||||
} from "@stores";
|
||||
import AppConfig from "@/config";
|
||||
import { languageOptions } from "@/locales";
|
||||
import { mittBus, themeAnimation } from "@utils";
|
||||
@@ -217,6 +238,7 @@ const settingStore = useSettingsStore();
|
||||
const userStore = useUserStore();
|
||||
const menuStore = useMenuStore();
|
||||
const configStore = useConfigStore();
|
||||
const noticeStore = useNoticeStore();
|
||||
|
||||
/** 租户配置:tenant_logo / tenant_name */
|
||||
const headerLogoSrc = computed(() => {
|
||||
@@ -251,11 +273,21 @@ const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle
|
||||
storeToRefs(settingStore);
|
||||
|
||||
const { language } = storeToRefs(userStore);
|
||||
const { menuList } = storeToRefs(menuStore);
|
||||
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,21 +1,22 @@
|
||||
<!-- 顶栏租户切换器:头像左侧,始终可见 -->
|
||||
<!-- 工作区模式切换器:平台模式 → 进入租户 | 租户模式 → 返回平台 -->
|
||||
<template>
|
||||
<!-- 平台管理模式:"进入租户"按钮 -->
|
||||
<ElDropdown
|
||||
v-if="tenantList.length > 1"
|
||||
v-if="isPlatformMode && tenantList.length > 0"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:disabled="switching"
|
||||
@command="handleSwitch"
|
||||
@command="handleEnterTenant"
|
||||
@visible-change="(v) => (dropdownVisible = v)"
|
||||
popper-class="fa-tenant-dropdown"
|
||||
>
|
||||
<div
|
||||
class="tenant-switcher"
|
||||
class="tenant-switcher workspace-entry"
|
||||
:class="{ 'is-active': dropdownVisible, 'is-switching': switching }"
|
||||
:title="`当前租户:${currentTenantName}`"
|
||||
title="进入租户工作区"
|
||||
>
|
||||
<FaSvgIcon icon="ri:building-2-fill" class="icon" />
|
||||
<span class="name">{{ currentTenantName }}</span>
|
||||
<span class="name">进入租户</span>
|
||||
<FaSvgIcon
|
||||
v-if="!switching"
|
||||
icon="ri:arrow-down-s-line"
|
||||
@@ -27,37 +28,83 @@
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div class="dropdown-header">
|
||||
<span class="dropdown-title">切换租户</span>
|
||||
<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"
|
||||
:class="{ 'is-active': t.id === currentTenant?.id }"
|
||||
:disabled="switching"
|
||||
>
|
||||
<div class="dropdown-item" :class="{ 'is-current': t.id === currentTenant?.id }">
|
||||
<ElDropdownItem v-for="t in tenantList" :key="t.id" :command="t.id" :disabled="switching">
|
||||
<div class="dropdown-item">
|
||||
<div class="item-main">
|
||||
<FaSvgIcon
|
||||
:icon="t.id === currentTenant?.id ? 'ri:check-line' : 'ri:building-2-line'"
|
||||
class="item-icon"
|
||||
:class="{ active: t.id === currentTenant?.id }"
|
||||
/>
|
||||
<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>
|
||||
<ElTag v-if="t.id === currentTenant?.id" type="primary" size="small" effect="plain">
|
||||
当前
|
||||
</ElTag>
|
||||
<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>
|
||||
<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>
|
||||
@@ -65,42 +112,78 @@
|
||||
</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 { ElMessage } from "element-plus";
|
||||
import { Loading } from "@element-plus/icons-vue";
|
||||
import { useUserStore } from "@stores";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
defineOptions({ name: "FaTenantSwitcher" });
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { tenantList, currentTenant } = storeToRefs(userStore);
|
||||
const { tenantList, isPlatformMode, workspaceMode, workspaceTenant, currentTenant, info } =
|
||||
storeToRefs(userStore);
|
||||
|
||||
const dropdownVisible = ref(false);
|
||||
const switching = ref(false);
|
||||
|
||||
const currentTenantName = computed(
|
||||
() => currentTenant.value?.name || tenantList.value[0]?.name || "—"
|
||||
);
|
||||
/** 是否为超管(判断是否显示"返回平台"入口) */
|
||||
const isSuperuser = computed(() => !!info.value?.is_superuser);
|
||||
|
||||
async function handleSwitch(tenantId: number) {
|
||||
/** 当前显示的工作区名称 */
|
||||
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;
|
||||
// 点击当前租户:给个明确提示(不再静默忽略)
|
||||
if (tenantId === currentTenant.value?.id) {
|
||||
ElMessage.info("已是当前租户");
|
||||
return;
|
||||
}
|
||||
const tenant = tenantList.value.find((t) => t.id === tenantId);
|
||||
if (!tenant) return;
|
||||
switching.value = true;
|
||||
dropdownVisible.value = false;
|
||||
try {
|
||||
await userStore.selectTenant(tenantId);
|
||||
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>
|
||||
@@ -144,6 +227,22 @@ async function handleSwitch(tenantId: number) {
|
||||
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;
|
||||
@@ -170,7 +269,7 @@ async function handleSwitch(tenantId: number) {
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 全局样式:下拉面板(scoped 无法深入 ElDropdownMenu) */
|
||||
/* 全局样式:下拉面板 */
|
||||
.fa-tenant-dropdown {
|
||||
min-width: 240px;
|
||||
max-width: calc(100vw - 20px);
|
||||
@@ -184,11 +283,6 @@ async function handleSwitch(tenantId: number) {
|
||||
.el-dropdown-menu__item {
|
||||
padding: 0 !important;
|
||||
|
||||
&.is-active {
|
||||
color: inherit;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&:not(.is-disabled):hover {
|
||||
background: var(--fa-gray-200) !important;
|
||||
}
|
||||
@@ -209,6 +303,44 @@ async function handleSwitch(tenantId: number) {
|
||||
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;
|
||||
@@ -216,16 +348,14 @@ async function handleSwitch(tenantId: number) {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&.is-current {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-left-color: var(--el-color-primary);
|
||||
|
||||
&.exit-action {
|
||||
&:hover {
|
||||
background: var(--el-color-primary-light-8);
|
||||
.exit-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,10 +387,18 @@ async function handleSwitch(tenantId: number) {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.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 {
|
||||
|
||||
@@ -52,6 +52,21 @@
|
||||
</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"
|
||||
@@ -126,7 +141,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from "@/config";
|
||||
import { useConfigStore, useSettingsStore, useMenuStore } from "@stores";
|
||||
import { useConfigStore, useSettingsStore, useMenuStore, useUserStore } from "@stores";
|
||||
import { MenuTypeEnum, MenuWidth } from "@/enums/appEnum";
|
||||
import { isIframe, handleMenuJump } from "@utils";
|
||||
import SidebarSubmenu from "./widgets/FaSidebarSubmenu.vue";
|
||||
@@ -143,6 +158,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingsStore();
|
||||
const configStore = useConfigStore();
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** 租户配置:tenant_logo / tenant_name */
|
||||
const sidebarLogoSrc = computed(() => {
|
||||
@@ -166,6 +182,8 @@ const {
|
||||
showAppLogo,
|
||||
} = storeToRefs(settingStore);
|
||||
|
||||
const { isPlatformMode, workspaceTenant, currentTenant } = storeToRefs(userStore);
|
||||
|
||||
// 组件内部状态
|
||||
const defaultOpenedMenus = ref<string[]>([]);
|
||||
const showMobileModal = ref(false);
|
||||
@@ -193,12 +211,12 @@ const routerPath = computed(() => String(route.meta.activePath || route.path));
|
||||
|
||||
// 菜单数据
|
||||
const firstLevelMenus = computed(() => {
|
||||
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide);
|
||||
return useMenuStore().visibleMenus.filter((menu) => !menu.meta.isHide);
|
||||
});
|
||||
|
||||
const menuList = computed(() => {
|
||||
const menuStore = useMenuStore();
|
||||
const allMenus = menuStore.menuList;
|
||||
const allMenus = menuStore.visibleMenus;
|
||||
|
||||
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
||||
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
||||
@@ -542,6 +560,34 @@ 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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- 通知组件 -->
|
||||
<template>
|
||||
<div
|
||||
class="fa-notification-panel fa-card-sm !shadow-xl flex flex-col"
|
||||
class="fa-notification-panel fa-card-sm shadow-xl! flex flex-col"
|
||||
:style="{
|
||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||
opacity: show ? 1 : 0,
|
||||
@@ -9,88 +9,40 @@
|
||||
v-show="visible"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex-cb px-3.5 mt-3.5 flex-shrink-0">
|
||||
<div class="flex-cb px-3.5 mt-3.5 shrink-0">
|
||||
<span class="text-base font-medium text-g-800">{{ $t("notice.title") }}</span>
|
||||
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
||||
{{ $t("notice.btnRead") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d flex-shrink-0">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
||||
:class="{ 'bar-active': barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ElScrollbar class="flex-1 min-h-0 overflow-y-scroll scrollbar-thin">
|
||||
<!-- 通知 -->
|
||||
<ul v-show="barActiveIndex === 0">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(item, index) in noticeList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
@click="handleMarkAsRead(index)"
|
||||
>
|
||||
<div
|
||||
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
||||
:class="[getNoticeStyle(item.type).iconClass]"
|
||||
>
|
||||
<FaSvgIcon class="text-lg bg-transparent!" :icon="getNoticeStyle(item.type).icon" />
|
||||
<div class="size-9 leading-9 text-center rounded-lg flex-cc bg-theme/12 text-theme">
|
||||
<FaSvgIcon class="text-lg bg-transparent!" icon="ri:notification-3-line" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 消息 -->
|
||||
<ul v-show="barActiveIndex === 1">
|
||||
<li
|
||||
v-for="(item, index) in msgList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
>
|
||||
<div class="w-9 h-9">
|
||||
<FaSvgIcon class="w-full h-full rounded-lg" icon="ri:message-3-line" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 待办 -->
|
||||
<ul v-show="barActiveIndex === 2">
|
||||
<li
|
||||
v-for="(item, index) in pendingList"
|
||||
:key="index"
|
||||
class="box-border px-5 py-3.5 last:border-b-0"
|
||||
>
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p class="text-xs text-g-500">{{ item.time }}</p>
|
||||
<div v-if="!item.read" class="ml-2 size-2 rounded-full bg-danger shrink-0"></div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-show="currentTabIsEmpty"
|
||||
class="h-full text-g-500 text-center !bg-transparent flex flex-col items-center justify-center mt-12"
|
||||
v-show="noticeList.length === 0"
|
||||
class="h-full text-g-500 text-center bg-transparent! flex flex-col items-center justify-center mt-12"
|
||||
>
|
||||
<FaSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
||||
<p class="mt-3.5 text-xs !bg-transparent">
|
||||
{{ $t("notice.text[0]") }}{{ barList[barActiveIndex]?.name }}
|
||||
</p>
|
||||
<p class="mt-3.5 text-xs bg-transparent!">{{ $t("notice.empty") }}</p>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<div class="box-border w-full px-3.5 pt-2 pb-3.5 flex-shrink-0">
|
||||
<div class="box-border w-full px-3.5 pt-2 pb-3.5 shrink-0">
|
||||
<ElButton class="w-full" @click="handleViewAll" v-ripple>
|
||||
{{ $t("notice.viewAll") }}
|
||||
</ElButton>
|
||||
@@ -99,60 +51,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, type Ref, type ComputedRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import NoticeAPI, {
|
||||
type NoticeTable,
|
||||
type NotificationPanelMessage,
|
||||
} from "@/api/module_system/notice";
|
||||
import NoticeAPI, { type NoticeTable } from "@/api/module_system/notice";
|
||||
|
||||
defineOptions({ name: "FaNotification" });
|
||||
|
||||
interface NoticeItem {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 时间 */
|
||||
time: string;
|
||||
/** 类型 */
|
||||
type: NoticeType;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface MessageItem {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 时间 */
|
||||
time: string;
|
||||
/** 内容 */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface PendingItem {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 时间 */
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface BarItem {
|
||||
/** 名称 */
|
||||
name: ComputedRef<string>;
|
||||
/** 数量 */
|
||||
num: number;
|
||||
}
|
||||
|
||||
interface NoticeStyle {
|
||||
/** 图标 */
|
||||
icon: string;
|
||||
/** icon 样式 */
|
||||
iconClass: string;
|
||||
}
|
||||
|
||||
type NoticeType = "email" | "message" | "collection" | "user" | "notice";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
value: boolean;
|
||||
}
|
||||
@@ -167,221 +78,60 @@ const emit = defineEmits<Emits>();
|
||||
|
||||
const show = ref(false);
|
||||
const visible = ref(false);
|
||||
const barActiveIndex = ref(0);
|
||||
const noticeList = ref<NoticeItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const useNotificationData = () => {
|
||||
const panelLoading = ref(false);
|
||||
const noticeList = ref<NoticeItem[]>([]);
|
||||
const msgList = ref<MessageItem[]>([]);
|
||||
const pendingList = ref<PendingItem[]>([]);
|
||||
|
||||
const fetchPanel = async () => {
|
||||
panelLoading.value = true;
|
||||
try {
|
||||
const res = await NoticeAPI.getNotificationPanel();
|
||||
const data = (res.data as any)?.data ?? res.data ?? {};
|
||||
|
||||
// 通知
|
||||
const notices: any[] = data.notices || [];
|
||||
noticeList.value = notices.map((n: NoticeTable) => ({
|
||||
title: n.notice_title || "",
|
||||
time: n.created_time || "",
|
||||
type: "notice" as NoticeType,
|
||||
}));
|
||||
|
||||
// 消息
|
||||
const messages: NotificationPanelMessage[] = data.messages || [];
|
||||
msgList.value = messages.map((m) => ({
|
||||
title: m.title || "",
|
||||
time: m.time || "",
|
||||
content: m.content || "",
|
||||
}));
|
||||
|
||||
// 待办
|
||||
const pendings: NotificationPanelMessage[] = data.pendings || [];
|
||||
pendingList.value = pendings.map((p) => ({
|
||||
title: p.title || "",
|
||||
time: p.time || "",
|
||||
}));
|
||||
} catch {
|
||||
noticeList.value = [];
|
||||
msgList.value = [];
|
||||
pendingList.value = [];
|
||||
} finally {
|
||||
panelLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchPanel());
|
||||
watch(visible, (v) => {
|
||||
if (v) fetchPanel();
|
||||
});
|
||||
|
||||
// 标签栏数据
|
||||
const barList = computed<BarItem[]>(() => [
|
||||
{
|
||||
name: computed(() => t("notice.bar[0]")),
|
||||
num: noticeList.value.length,
|
||||
},
|
||||
{
|
||||
name: computed(() => t("notice.bar[1]")),
|
||||
num: msgList.value.length,
|
||||
},
|
||||
{
|
||||
name: computed(() => t("notice.bar[2]")),
|
||||
num: pendingList.value.length,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
barList,
|
||||
};
|
||||
};
|
||||
|
||||
// 样式管理
|
||||
const useNotificationStyles = () => {
|
||||
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
|
||||
email: {
|
||||
icon: "ri:mail-line",
|
||||
iconClass: "bg-warning/12 text-warning",
|
||||
},
|
||||
message: {
|
||||
icon: "ri:volume-down-line",
|
||||
iconClass: "bg-success/12 text-success",
|
||||
},
|
||||
collection: {
|
||||
icon: "ri:heart-3-line",
|
||||
iconClass: "bg-danger/12 text-danger",
|
||||
},
|
||||
user: {
|
||||
icon: "ri:volume-down-line",
|
||||
iconClass: "bg-info/12 text-info",
|
||||
},
|
||||
notice: {
|
||||
icon: "ri:notification-3-line",
|
||||
iconClass: "bg-theme/12 text-theme",
|
||||
},
|
||||
};
|
||||
|
||||
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
|
||||
const defaultStyle: NoticeStyle = {
|
||||
icon: "ri:arrow-right-circle-line",
|
||||
iconClass: "bg-theme/12 text-theme",
|
||||
};
|
||||
|
||||
return noticeStyleMap[type] || defaultStyle;
|
||||
};
|
||||
|
||||
return {
|
||||
getNoticeStyle,
|
||||
};
|
||||
};
|
||||
|
||||
// 动画管理
|
||||
const useNotificationAnimation = () => {
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = true;
|
||||
setTimeout(() => {
|
||||
show.value = true;
|
||||
}, 5);
|
||||
} else {
|
||||
show.value = false;
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, 350);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showNotice,
|
||||
};
|
||||
};
|
||||
|
||||
// 标签页管理
|
||||
const useTabManagement = (
|
||||
noticeList: Ref<NoticeItem[]>,
|
||||
msgList: Ref<MessageItem[]>,
|
||||
pendingList: Ref<PendingItem[]>,
|
||||
businessHandlers: {
|
||||
handleNoticeAll: () => void;
|
||||
handleMsgAll: () => void;
|
||||
handlePendingAll: () => void;
|
||||
const fetchNotices = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await NoticeAPI.listNoticeAvailable();
|
||||
const data = (res.data as any)?.data ?? res.data ?? {};
|
||||
const items: any[] = data.items || data.list || [];
|
||||
noticeList.value = items.map((n: NoticeTable) => ({
|
||||
title: n.notice_title || "",
|
||||
time: n.created_time || "",
|
||||
read: false,
|
||||
}));
|
||||
} catch {
|
||||
noticeList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
) => {
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index;
|
||||
};
|
||||
|
||||
// 检查当前标签页是否为空
|
||||
const currentTabIsEmpty = computed(() => {
|
||||
const tabDataMap = [noticeList.value, msgList.value, pendingList.value];
|
||||
|
||||
const currentData = tabDataMap[barActiveIndex.value];
|
||||
return currentData && currentData.length === 0;
|
||||
});
|
||||
|
||||
const handleViewAll = () => {
|
||||
// 查看全部处理器映射
|
||||
const viewAllHandlers: Record<number, () => void> = {
|
||||
0: businessHandlers.handleNoticeAll,
|
||||
1: businessHandlers.handleMsgAll,
|
||||
2: businessHandlers.handlePendingAll,
|
||||
};
|
||||
|
||||
const handler = viewAllHandlers[barActiveIndex.value];
|
||||
handler?.();
|
||||
|
||||
// 关闭通知面板
|
||||
emit("update:value", false);
|
||||
};
|
||||
|
||||
return {
|
||||
changeBar,
|
||||
currentTabIsEmpty,
|
||||
handleViewAll,
|
||||
};
|
||||
};
|
||||
|
||||
// 业务逻辑处理
|
||||
const useBusinessLogic = () => {
|
||||
const handleNoticeAll = () => {
|
||||
// 跳转到公告管理页面
|
||||
const router = useRouter();
|
||||
router.push("/module_system/notice");
|
||||
};
|
||||
onMounted(() => fetchNotices());
|
||||
watch(visible, (v) => {
|
||||
if (v) fetchNotices();
|
||||
});
|
||||
|
||||
const handleMsgAll = () => {
|
||||
console.info("[TODO] 查看全部消息");
|
||||
};
|
||||
|
||||
const handlePendingAll = () => {
|
||||
console.info("[TODO] 查看全部待办");
|
||||
};
|
||||
|
||||
return {
|
||||
handleNoticeAll,
|
||||
handleMsgAll,
|
||||
handlePendingAll,
|
||||
};
|
||||
const handleViewAll = () => {
|
||||
const router = useRouter();
|
||||
router.push("/module_system/notice");
|
||||
emit("update:value", false);
|
||||
};
|
||||
|
||||
// 组合所有逻辑
|
||||
const { noticeList, msgList, pendingList, barList } = useNotificationData();
|
||||
const { getNoticeStyle } = useNotificationStyles();
|
||||
const { showNotice } = useNotificationAnimation();
|
||||
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic();
|
||||
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
{ handleNoticeAll, handleMsgAll, handlePendingAll }
|
||||
);
|
||||
const handleMarkAsRead = (index: number) => {
|
||||
const item = noticeList.value[index];
|
||||
if (item && !item.read) {
|
||||
item.read = true;
|
||||
// 本地标记已读;全局计数由 noticeStore 管理
|
||||
}
|
||||
};
|
||||
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = true;
|
||||
setTimeout(() => {
|
||||
show.value = true;
|
||||
}, 5);
|
||||
} else {
|
||||
show.value = false;
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, 350);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
@@ -414,11 +164,6 @@ watch(
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
}
|
||||
|
||||
.bar-active {
|
||||
color: var(--theme-color) important;
|
||||
border-bottom: 2px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
<!-- 弹窗底部操作按钮 -->
|
||||
<template #footer>
|
||||
<div :style="'padding-right: var(--el-dialog-padding-primary)'">
|
||||
<ElButton type="primary" @click="handleExportsSubmit">确 定</ElButton>
|
||||
<ElButton type="primary" :loading="loadingRef" @click="handleExportsSubmit"
|
||||
>确 定</ElButton
|
||||
>
|
||||
<ElButton @click="handleCloseExportsModal">取 消</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,13 +236,18 @@ async function handleExports() {
|
||||
}
|
||||
|
||||
// 导出确认
|
||||
const handleExportsSubmit = useThrottleFn(() => {
|
||||
exportsFormRef.value?.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
handleExports();
|
||||
handleCloseExportsModal();
|
||||
}
|
||||
});
|
||||
const loadingRef = ref(false);
|
||||
const handleExportsSubmit = useThrottleFn(async () => {
|
||||
try {
|
||||
await exportsFormRef.value?.validate();
|
||||
loadingRef.value = true;
|
||||
await handleExports();
|
||||
handleCloseExportsModal();
|
||||
} catch {
|
||||
// 校验失败
|
||||
} finally {
|
||||
loadingRef.value = false;
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 浏览器保存文件
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
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"
|
||||
@@ -80,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, User } from "@element-plus/icons-vue";
|
||||
import { Lock, OfficeBuilding, User } from "@element-plus/icons-vue";
|
||||
import type { ForgetPasswordForm } from "@/api/module_system/user";
|
||||
import type { FormRules } from "element-plus";
|
||||
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
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"
|
||||
@@ -108,7 +120,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, Message, User } from "@element-plus/icons-vue";
|
||||
import { Lock, Message, OfficeBuilding, User } from "@element-plus/icons-vue";
|
||||
import type { RegisterForm } from "@/api/module_system/user";
|
||||
import type { FormRules } from "element-plus";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user