feat: 添加标签页管理功能和全局加载动画

This commit is contained in:
FalingCliff 2025-06-15 00:58:17 +08:00
parent 87c0e1e130
commit b7a012967e
1 changed files with 281 additions and 2 deletions

View File

@ -94,6 +94,39 @@
</div>
</a-layout-header>
<!-- 标签页 -->
<div class="admin-tabs">
<a-tabs
v-model:activeKey="activeTab"
type="editable-card"
:hide-add="true"
@edit="onTabEdit"
@tabClick="onTabClick"
>
<a-tab-pane
v-for="tab in tabs"
:key="tab.path"
:tab="tab.title"
:closable="tabs.length > 1"
>
<template #closeIcon>
<a-dropdown :trigger="['hover']">
<CloseOutlined />
<template #overlay>
<a-menu @click="({ key }) => handleTabAction(key, tab)">
<a-menu-item key="current">关闭当前标签</a-menu-item>
<a-menu-item key="others">关闭其它标签</a-menu-item>
<a-menu-item key="left">关闭左侧标签</a-menu-item>
<a-menu-item key="right">关闭右侧标签</a-menu-item>
<a-menu-item key="all">关闭所有标签</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-tab-pane>
</a-tabs>
</div>
<!-- 内容 -->
<a-layout-content class="admin-content">
<router-view v-slot="{ Component }">
@ -101,6 +134,9 @@
<component :is="Component" />
</transition>
</router-view>
<a-spin :spinning="loading" class="global-loading" tip="加载中...">
<div class="spin-content"></div>
</a-spin>
</a-layout-content>
<!-- 页脚 -->
@ -112,7 +148,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
MenuUnfoldOutlined,
@ -123,6 +159,7 @@ import {
TeamOutlined,
MenuOutlined,
LogoutOutlined,
CloseOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import Breadcrumb from '@/components/common/Breadcrumb.vue'
@ -133,6 +170,201 @@ const route = useRoute()
//
const collapsed = ref(false)
//
const loading = ref(false)
//
interface TabItem {
title: string;
path: string;
name: string;
query?: Record<string, any>;
params?: Record<string, any>;
}
const tabs = ref<TabItem[]>([]);
const activeTab = ref('');
//
const addTab = (route: any) => {
const { path, meta, name, query, params } = route;
const title = meta?.title || '未命名页面';
//
const isExist = tabs.value.some(tab => tab.path === path);
if (!isExist) {
tabs.value.push({
title,
path,
name,
query,
params
});
}
activeTab.value = path;
};
//
const closeTab = (targetPath: string) => {
const targetIndex = tabs.value.findIndex(tab => tab.path === targetPath);
if (targetIndex === -1) return;
//
if (activeTab.value === targetPath) {
//
if (targetIndex < tabs.value.length - 1) {
activeTab.value = tabs.value[targetIndex + 1].path;
router.push(tabs.value[targetIndex + 1]);
} else if (targetIndex > 0) {
activeTab.value = tabs.value[targetIndex - 1].path;
router.push(tabs.value[targetIndex - 1]);
}
}
tabs.value.splice(targetIndex, 1);
saveTabsToStorage();
};
//
const onTabEdit = (targetKey: string, action: 'add' | 'remove') => {
if (action === 'remove') {
closeTab(targetKey);
}
};
//
const onTabClick = (key: string) => {
//
const tab = tabs.value.find(item => item.path === key);
if (tab) {
//
router.push({
path: tab.path,
query: tab.query,
params: tab.params
});
}
};
//
const handleTabAction = (action: string, tab: TabItem) => {
const currentIndex = tabs.value.findIndex(item => item.path === tab.path);
switch (action) {
case 'current':
closeTab(tab.path);
break;
case 'others':
tabs.value = tabs.value.filter(item => item.path === tab.path);
break;
case 'left':
if (currentIndex > 0) {
tabs.value = tabs.value.filter((item, index) =>
index >= currentIndex || item.path === '/dashboard'
);
}
break;
case 'right':
if (currentIndex < tabs.value.length - 1) {
tabs.value = tabs.value.filter((item, index) =>
index <= currentIndex || item.path === '/dashboard'
);
}
break;
case 'all':
//
const homePage = tabs.value.find(item => item.path === '/dashboard');
tabs.value = homePage ? [homePage] : [];
if (homePage) {
activeTab.value = homePage.path;
router.push(homePage);
}
break;
}
};
//
router.beforeEach((to, from, next) => {
loading.value = true;
// meta.title
if (to.meta?.title) {
addTab(to);
}
next();
});
router.afterEach(() => {
//
setTimeout(() => {
loading.value = false;
}, 300);
});
//
const saveTabsToStorage = () => {
localStorage.setItem('admin-tabs', JSON.stringify(tabs.value));
localStorage.setItem('admin-active-tab', activeTab.value);
};
//
const loadTabsFromStorage = () => {
const savedTabs = localStorage.getItem('admin-tabs');
const savedActiveTab = localStorage.getItem('admin-active-tab');
if (savedTabs) {
try {
const parsedTabs = JSON.parse(savedTabs);
tabs.value = parsedTabs;
//
if (savedActiveTab) {
activeTab.value = savedActiveTab;
//
const activeTabData = tabs.value.find(tab => tab.path === savedActiveTab);
if (activeTabData && activeTabData.path !== route.path) {
router.push({
path: activeTabData.path,
query: activeTabData.query,
params: activeTabData.params
});
return true; //
}
}
} catch (e) {
console.error('Failed to parse saved tabs:', e);
}
}
return false; //
};
//
watch(tabs, () => {
saveTabsToStorage();
}, { deep: true });
watch(activeTab, () => {
localStorage.setItem('admin-active-tab', activeTab.value);
});
//
onMounted(() => {
//
const restored = loadTabsFromStorage();
//
if (!restored || tabs.value.length === 0) {
if (route.meta?.title) {
addTab(route);
}
}
});
//
const selectedKeys = ref<string[]>([])
const openKeys = ref<string[]>([])
@ -302,7 +534,7 @@ onMounted(() => {
}
.admin-content {
margin: 24px;
margin: 8px 16px 16px;
padding: 24px;
background: #fff;
flex: 1;
@ -310,6 +542,8 @@ onMounted(() => {
overflow-x: hidden;
position: relative;
height: 0; /* 让flex: 1生效 */
border-radius: 2px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.admin-footer {
@ -319,6 +553,51 @@ onMounted(() => {
background: #fff;
}
.global-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.spin-content {
width: 100%;
height: 100%;
}
.admin-tabs {
background: #fff;
padding: 6px 16px 0;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 8;
}
:deep(.ant-tabs) {
.ant-tabs-nav {
margin-bottom: 0;
}
.ant-tabs-tab {
padding: 8px 16px;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
}
.ant-tabs-tab-remove {
margin: 0;
padding: 0;
}
}
/* 路由过渡动画 */
.fade-enter-active,
.fade-leave-active {