feat: 增加菜单标签页功能 (#3267) (#4510)

This commit is contained in:
Langel 2024-04-15 15:10:24 +08:00 committed by GitHub
parent a3312331d2
commit 5a8d6db43e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 423 additions and 11 deletions

View File

@ -19,6 +19,7 @@ type SettingInfo struct {
BindAddress string `json:"bindAddress"`
PanelName string `json:"panelName"`
Theme string `json:"theme"`
MenuTabs string `json:"menuTabs"`
Language string `json:"language"`
DefaultNetwork string `json:"defaultNetwork"`
LastCleanTime string `json:"lastCleanTime"`

View File

@ -80,6 +80,7 @@ func Init() {
migrations.NewMonitorDB,
migrations.AddNoAuthSetting,
migrations.UpdateXpackHideMenu,
migrations.AddMenuTabsSetting,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)

View File

@ -128,3 +128,13 @@ var UpdateXpackHideMenu = &gormigrate.Migration{
return nil
},
}
var AddMenuTabsSetting = &gormigrate.Migration{
ID: "20240415-add-menu-tabs-setting",
Migrate: func(tx *gorm.DB) error {
if err := tx.Create(&model.Setting{Key: "MenuTabs", Value: "disable"}).Error; err != nil {
return err
}
return nil
},
}

View File

@ -16,6 +16,7 @@ export namespace Setting {
panelName: string;
theme: string;
menuTabs: string;
language: string;
defaultNetwork: string;
lastCleanTime: string;

View File

@ -352,6 +352,9 @@ const message = {
tabs: {
more: 'More',
hide: 'Hide',
close: 'Close',
closeLeft: 'Close left',
closeRight: 'Close right',
closeCurrent: 'Close current',
closeOther: 'Close other',
closeAll: 'Close All',
@ -1228,6 +1231,7 @@ const message = {
portChange: 'Port change',
portChangeHelper: 'Modify the service port and restart the service. Do you want to continue?',
theme: 'Theme',
menuTabs: 'Menu tabs',
dark: 'Dark',
light: 'Light',
auto: 'Follow System',

View File

@ -348,6 +348,9 @@ const message = {
tabs: {
more: '更多',
hide: '收起',
close: '關閉',
closeLeft: '關閉左側',
closeRight: '關閉右側',
closeCurrent: '關閉當前',
closeOther: '關閉其它',
closeAll: '關閉所有',
@ -1165,6 +1168,7 @@ const message = {
portChange: '端口修改',
portChangeHelper: '服務端口修改需要重啟服務是否繼續',
theme: '主題顏色',
menuTabs: '菜單標簽頁',
componentSize: '組件大小',
dark: '暗色',
light: '亮色',

View File

@ -348,6 +348,9 @@ const message = {
tabs: {
more: '更多',
hide: '收起',
close: '关闭',
closeLeft: '关闭左侧',
closeRight: '关闭右侧',
closeCurrent: '关闭当前',
closeOther: '关闭其它',
closeAll: '关闭所有',
@ -1166,6 +1169,7 @@ const message = {
portChange: '端口修改',
portChangeHelper: '服务端口修改需要重启服务是否继续',
theme: '主题颜色',
menuTabs: '菜单标签页',
componentSize: '组件大小',
dark: '暗色',
light: '亮色',

View File

@ -1,7 +1,7 @@
<template>
<router-view v-slot="{ Component, route }" :key="key">
<transition appear name="fade-transform" mode="out-in">
<keep-alive :include="cacheRouter">
<keep-alive :include="include">
<component :is="Component" :key="route.path"></component>
</keep-alive>
</transition>
@ -15,4 +15,13 @@ import { computed } from 'vue';
const key = computed(() => {
return Math.random();
});
const include = computed(() => {
return props.keepAlive || cacheRouter;
});
const props = defineProps({
keepAlive: {
type: Object,
required: false,
},
});
</script>

View File

@ -10,10 +10,11 @@
<el-scrollbar>
<el-menu
:default-active="activeMenu"
:router="true"
:router="menuRouter"
:collapse="isCollapse"
:collapse-transition="false"
:unique-opened="true"
@select="handleMenuClick"
>
<SubItem :menuList="routerMenus" />
<el-menu-item :index="''">
@ -31,7 +32,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, defineEmits } from 'vue';
import { RouteRecordRaw, useRoute } from 'vue-router';
import { loadingSvg } from '@/utils/svg';
import Logo from './components/Logo.vue';
@ -49,6 +50,13 @@ import { getSettingInfo } from '@/api/modules/setting';
const route = useRoute();
const menuStore = MenuStore();
const globalStore = GlobalStore();
defineProps({
menuRouter: {
type: Boolean,
default: true,
required: false,
},
});
const activeMenu = computed(() => {
const { meta, path } = route;
return isString(meta.activeMenu) ? meta.activeMenu : path;
@ -79,7 +87,10 @@ const listeningWindow = () => {
};
};
listeningWindow();
const emit = defineEmits(['menuClick']);
const handleMenuClick = (path) => {
emit('menuClick', path);
};
const logout = () => {
ElMessageBox.confirm(i18n.global.t('commons.msg.sureLogOut'), i18n.global.t('commons.msg.infoTitle'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),

View File

@ -0,0 +1,97 @@
<template>
<el-tab-pane :name="tabItem.path">
<template #label>
<el-dropdown
size="small"
:id="tabItem.path"
ref="dropdownRef"
trigger="contextmenu"
@visible-change="$emit('dropdownVisibleChange', $event, tabItem.path)"
>
<span class="custom-tabs-label">
<el-icon v-if="tabsStore.isShowTabIcon && menuIcon">
<el-icon>
<SvgIcon :iconName="menuIcon" />
</el-icon>
</el-icon>
<span>{{ menuName }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="tabsStore.hasCloseDropdown(tabItem.path, 'close')"
@click="$emit('closeTab', tabItem.path)"
>
<el-icon><Close /></el-icon>
{{ $t('tabs.close') }}
</el-dropdown-item>
<el-dropdown-item
v-if="tabsStore.hasCloseDropdown(tabItem.path, 'left')"
@click="$emit('closeTabs', tabItem.path, 'left')"
>
<el-icon><DArrowLeft /></el-icon>
{{ $t('tabs.closeLeft') }}
</el-dropdown-item>
<el-dropdown-item
v-if="tabsStore.hasCloseDropdown(tabItem.path, 'right')"
@click="$emit('closeTabs', tabItem.path, 'right')"
>
<el-icon><DArrowRight /></el-icon>
{{ $t('tabs.closeRight') }}
</el-dropdown-item>
<el-dropdown-item
v-if="tabsStore.hasCloseDropdown(tabItem.path, 'other')"
@click="$emit('closeOtherTabs', tabItem.path)"
>
<el-icon><More /></el-icon>
{{ $t('tabs.closeOther') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TabsStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { Close, DArrowLeft, DArrowRight, More } from '@element-plus/icons-vue';
import SvgIcon from '@/components/svg-icon/svg-icon.vue';
const i18n = useI18n();
const tabsStore = TabsStore();
const props = defineProps({
tabItem: {
type: Object,
required: true,
},
});
defineEmits(['closeTab', 'closeOtherTabs', 'closeTabs', 'dropdownVisibleChange']);
const menuName = computed(() => {
return i18n.t(props.tabItem.meta.title);
});
const menuIcon = computed(() => {
return props.tabItem.meta.icon;
});
const dropdownRef = ref();
defineExpose({
dropdownRef,
});
</script>
<style scoped>
.common-tabs .custom-tabs-label .el-icon {
vertical-align: middle;
}
.common-tabs .custom-tabs-label span {
vertical-align: middle;
margin-left: 4px;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<el-tabs
v-bind="$attrs"
v-model="tabsStore.activeTabPath"
class="common-tabs"
type="card"
:closable="tabsStore.openedTabs.length > 1"
@tab-change="tabChange"
@tab-remove="closeTab"
>
<tabs-view-item
v-for="item in tabsStore.openedTabs"
ref="tabItems"
:key="item.path"
:tab-item="item"
@close-tab="closeTab"
@close-other-tabs="closeOtherTabs"
@close-tabs="closeTabs"
@dropdown-visible-change="dropdownVisibleChange"
/>
</el-tabs>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { TabsStore } from '@/store';
import { useRoute, useRouter } from 'vue-router';
import TabsViewItem from './components/TabItem.vue';
const router = useRouter();
const route = useRoute();
const tabsStore = TabsStore();
const tabItems = ref();
onMounted(() => {
if (!tabsStore.openedTabs.length) {
tabsStore.addTab(route);
}
tabsStore.activeTabPath = route.path;
});
const tabChange = (tabPath) => {
const tab = tabsStore.findTab(tabPath);
if (tab) {
router.push(tab);
tabsStore.activeTabPath = tab.path;
}
};
const closeTab = (tabPath) => {
const lastTabPath = tabsStore.removeTab(tabPath);
if (lastTabPath) {
tabChange(lastTabPath);
}
};
const closeOtherTabs = (tabPath) => {
tabsStore.removeOtherTabs(tabPath);
tabChange(tabPath);
};
const closeTabs = (tabPath, type) => {
tabsStore.removeTabs(tabPath, type);
tabChange(tabPath);
};
const dropdownVisibleChange = (visible, tabPath) => {
if (visible) {
//
tabItems.value.forEach(({ dropdownRef }) => {
if (dropdownRef.id !== tabPath) {
dropdownRef.handleClose();
}
});
}
};
</script>
<style scoped>
:deep(.el-tabs__header) {
margin: 0;
}
</style>

View File

@ -2,3 +2,4 @@ export { default as Sidebar } from './Sidebar/index.vue';
export { default as Footer } from './AppFooter.vue';
export { default as AppMain } from './AppMain.vue';
export { default as MobileHeader } from './MobileHeader.vue';
export { default as Tabs } from '@/layout/components/Tabs/index.vue';

View File

@ -2,13 +2,13 @@
<div :class="classObj" class="app-wrapper" v-loading="loading" :element-loading-text="loadingText" fullscreen>
<div v-if="classObj.mobile && classObj.openSidebar" class="drawer-bg" @click="handleClickOutside" />
<div class="app-sidebar" v-if="!globalStore.isFullScreen">
<Sidebar />
<Sidebar @menu-click="handleMenuClick" :menu-router="!classObj.openMenuTabs" />
</div>
<div class="main-container">
<mobile-header v-if="classObj.mobile" />
<app-main class="app-main" />
<Tabs v-if="classObj.openMenuTabs" />
<app-main :keep-alive="classObj.openMenuTabs ? tabsStore.cachedTabs : null" class="app-main" />
<Footer class="app-footer" v-if="!globalStore.isFullScreen" />
</div>
</div>
@ -16,17 +16,21 @@
<script setup lang="ts">
import { onMounted, computed, ref, watch, onBeforeUnmount } from 'vue';
import { Sidebar, Footer, AppMain, MobileHeader } from './components';
import { Sidebar, Footer, AppMain, MobileHeader, Tabs } from './components';
import useResize from './hooks/useResize';
import { GlobalStore, MenuStore } from '@/store';
import { GlobalStore, MenuStore, TabsStore } from '@/store';
import { DeviceType } from '@/enums/app';
import { useI18n } from 'vue-i18n';
import { useTheme } from '@/hooks/use-theme';
import { getLicense, getSettingInfo, getSystemAvailable } from '@/api/modules/setting';
import { useRoute, useRouter } from 'vue-router';
useResize();
const router = useRouter();
const route = useRoute();
const menuStore = MenuStore();
const globalStore = GlobalStore();
const tabsStore = TabsStore();
const i18n = useI18n();
const loading = ref(false);
@ -36,12 +40,18 @@ const { switchDark } = useTheme();
let timer: NodeJS.Timer | null = null;
onMounted(() => {
if (!tabsStore.activeTabPath) {
handleMenuClick('/');
}
});
const classObj = computed(() => {
return {
fullScreen: globalStore.isFullScreen,
hideSidebar: menuStore.isCollapse,
openSidebar: !menuStore.isCollapse,
mobile: globalStore.device === DeviceType.Mobile,
openMenuTabs: globalStore.openMenuTabs,
withoutAnimation: menuStore.withoutAnimation,
};
});
@ -59,6 +69,11 @@ watch(
}
},
);
const handleMenuClick = async (path) => {
await router.push({ path: path });
tabsStore.addTab(route);
tabsStore.activeTabPath = route.path;
};
const loadDataFromDB = async () => {
const res = await getSettingInfo();
@ -66,6 +81,7 @@ const loadDataFromDB = async () => {
i18n.locale.value = res.data.language;
i18n.warnHtmlMessage = false;
globalStore.entrance = res.data.securityEntrance;
globalStore.setOpenMenuTabs(res.data.menuTabs === 'enable');
globalStore.updateLanguage(res.data.language);
globalStore.setThemeConfig({ ...themeConfig.value, theme: res.data.theme });
globalStore.setThemeConfig({ ...themeConfig.value, panelName: res.data.panelName });

View File

@ -2,10 +2,11 @@ import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import GlobalStore from './modules/global';
import MenuStore from './modules/menu';
import TabsStore from './modules/tabs';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
export { GlobalStore, MenuStore };
export { GlobalStore, MenuStore, TabsStore };
export default pinia;

View File

@ -20,6 +20,7 @@ export interface GlobalState {
language: string; // zh | en | tw
themeConfig: ThemeConfigProp;
isFullScreen: boolean;
openMenuTabs: boolean;
isOnRestart: boolean;
agreeLicense: boolean;
hasNewVersion: boolean;

View File

@ -23,6 +23,7 @@ const GlobalStore = defineStore({
logoWithText: '',
favicon: '',
},
openMenuTabs: false,
isFullScreen: false,
isOnRestart: false,
agreeLicense: false,
@ -39,6 +40,9 @@ const GlobalStore = defineStore({
}),
getters: {},
actions: {
setOpenMenuTabs(openMenuTabs: boolean) {
this.openMenuTabs = openMenuTabs;
},
setScreenFull() {
this.isFullScreen = !this.isFullScreen;
},

View File

@ -0,0 +1,142 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
const TabsStore = defineStore(
'TabsStore',
() => {
const isShowTabIcon = ref(true);
// 缓存的KEY直接给keepalive使用
const cachedTabs = ref([]);
const openedTabs = ref([]);
const activeTabPath = ref('');
const getActivePath = (path) => {
let firstSlashIndex = path.indexOf('/');
let lastSlashIndex = path.lastIndexOf('/');
if (firstSlashIndex === -1 || firstSlashIndex === lastSlashIndex) {
return path;
}
return path.substring(firstSlashIndex, lastSlashIndex);
};
const getTabIdxByPath = (path) => {
return openedTabs.value.findIndex((v) => v.path === path);
};
const removeAllTabs = () => {
openedTabs.value = [];
cachedTabs.value = [];
};
const removeUnActiveTabs = () => {
if (openedTabs.value.length) {
let idx = getTabIdxByPath(activeTabPath.value);
idx = idx > -1 ? idx : 0;
const tab = openedTabs.value[idx];
removeOtherTabs(tab);
}
};
const findTab = (path) => {
const idx = getTabIdxByPath(path);
if (idx > -1) {
return openedTabs.value[idx];
}
};
const addTab = (tab) => {
const idx = getTabIdxByPath(tab.path);
if (idx < 0) {
openedTabs.value.push(Object.assign({}, tab));
addCachedTab(tab.name);
}
};
const removeTab = (path) => {
if (openedTabs.value.length > 1) {
const idx = getTabIdxByPath(path);
if (idx > -1) {
removeCachedTab(openedTabs.value[idx].name);
openedTabs.value.splice(idx, 1);
}
return openedTabs.value[openedTabs.value.length - 1].path;
}
};
const removeOtherTabs = (path) => {
const idx = getTabIdxByPath(path);
if (idx > -1) {
const tab = openedTabs.value[idx];
openedTabs.value = [tab];
cachedTabs.value = [];
cachedTabs.value = [tab.name];
}
};
const removeTabs = (path, type) => {
if (path) {
const idx = getTabIdxByPath(path);
let removeTabs = [];
if (type === 'right') {
removeTabs = openedTabs.value.splice(idx + 1);
} else if (type === 'left') {
removeTabs = openedTabs.value.splice(0, idx);
}
if (removeTabs.length) {
removeTabs.forEach((e) => removeCachedTab(e.name));
}
}
};
const addCachedTab = (name) => {
if (name && !cachedTabs.value.includes(name)) {
cachedTabs.value.push(name);
}
};
const removeCachedTab = (name) => {
if (name) {
const idx = cachedTabs.value.findIndex((v) => v === name);
if (idx > -1) {
cachedTabs.value.splice(idx, 1);
}
}
};
const hasCloseDropdown = (path, type) => {
const idx = getTabIdxByPath(path);
switch (type) {
case 'close':
case 'other':
return openedTabs.value.length > 1;
case 'left':
return idx !== 0;
case 'right':
return idx !== openedTabs.value.length - 1;
}
};
return {
isShowTabIcon,
activeTabPath,
openedTabs,
cachedTabs,
addTab,
findTab,
addCachedTab,
removeCachedTab,
removeTab,
removeTabs,
removeOtherTabs,
removeAllTabs,
removeUnActiveTabs,
hasCloseDropdown,
getActivePath,
};
},
{
persist: true,
},
);
export default TabsStore;

View File

@ -202,6 +202,9 @@ const loadDetail = (log: string) => {
if (log.indexOf('[Theme]') !== -1) {
return log.replace('[Theme]', '[' + i18n.global.t('setting.theme') + ']');
}
if (log.indexOf('[MenuTabs]') !== -1) {
return log.replace('[MenuTabs]', '[' + i18n.global.t('setting.menuTabs') + ']');
}
if (log.indexOf('[SessionTimeout]') !== -1) {
return log.replace('[SessionTimeout]', '[' + i18n.global.t('setting.sessionTimeout') + ']');
}

View File

@ -153,13 +153,14 @@ import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import type { ElForm } from 'element-plus';
import { loginApi, getCaptcha, mfaLoginApi, checkIsDemo, getLanguage } from '@/api/modules/auth';
import { GlobalStore, MenuStore } from '@/store';
import { GlobalStore, MenuStore, TabsStore } from '@/store';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { useI18n } from 'vue-i18n';
const globalStore = GlobalStore();
const menuStore = MenuStore();
const tabsStore = TabsStore();
const usei18n = useI18n();
const errAuthInfo = ref(false);
@ -270,6 +271,7 @@ const login = (formEl: FormInstance | undefined) => {
globalStore.setLogStatus(true);
globalStore.setAgreeLicense(true);
menuStore.setMenuList([]);
tabsStore.removeAllTabs();
MsgSuccess(i18n.global.t('commons.msg.loginSuccess'));
router.push({ name: 'home' });
} catch (error) {
@ -295,6 +297,7 @@ const mfaLogin = async (auto: boolean) => {
}
globalStore.setLogStatus(true);
menuStore.setMenuList([]);
tabsStore.removeAllTabs();
MsgSuccess(i18n.global.t('commons.msg.loginSuccess'));
router.push({ name: 'home' });
}

View File

@ -40,6 +40,17 @@
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('setting.menuTabs')" prop="menuTabs">
<el-radio-group @change="onSave('MenuTabs', form.menuTabs)" v-model="form.menuTabs">
<el-radio-button value="enable">
<span>{{ $t('commons.button.enable') }}</span>
</el-radio-button>
<el-radio-button value="disable">
<span>{{ $t('commons.button.disable') }}</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="$t('setting.title')" prop="panelName">
<el-input disabled v-model="form.panelName">
<template #append>
@ -160,6 +171,7 @@ const form = reactive({
panelName: '',
systemIP: '',
theme: '',
menuTabs: '',
language: '',
complexityVerification: '',
defaultNetwork: '',
@ -200,6 +212,7 @@ const search = async () => {
form.panelName = res.data.panelName;
form.systemIP = res.data.systemIP;
form.theme = res.data.theme;
form.menuTabs = res.data.menuTabs;
form.language = res.data.language;
form.complexityVerification = res.data.complexityVerification;
form.defaultNetwork = res.data.defaultNetwork;
@ -270,6 +283,9 @@ const onSave = async (key: string, val: any) => {
globalStore.setThemeConfig({ ...themeConfig.value, theme: val });
switchDark();
}
if (key === 'MenuTabs') {
globalStore.setOpenMenuTabs(val === 'enable');
}
let param = {
key: key,
value: val + '',