refactor: 重构项目模块结构与代码细节优化

1.  调整后端模块路由与插件文件结构,迁移部分模块代码至api/v1目录
2.  优化代码注释格式与文档字符串,简化冗余代码
3.  添加监控仪表盘、工单评论等新模块功能
4.  修复前端部分组件交互逻辑与类型定义
5.  清理冗余的初始化数据与废弃配置文件
6.  新增租户注册表单字段与国际化支持
7.  优化HTTP请求拦截器与快捷键功能
This commit is contained in:
zhangtao
2026-07-10 00:18:11 +08:00
parent 349ab7b525
commit 5ff72b086f
323 changed files with 7633 additions and 15306 deletions
@@ -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";