update 优化 页签区增加左右滚动、右侧下拉、单独“刷新”按钮

add 新增 TagsView 全屏功能
add 新增 设置面板增加了“持久化标签页”开关 支持标签页持久化
This commit is contained in:
疯狂的狮子Li
2026-03-24 17:58:07 +08:00
parent 4fb60440f2
commit a86557cc2a
8 changed files with 681 additions and 210 deletions
+8
View File
@@ -52,6 +52,14 @@ function addIframe() {
position: relative;
overflow: hidden;
padding: 12px;
&:fullscreen,
&:-webkit-full-screen,
&:-moz-full-screen,
&:-ms-fullscreen {
background: var(--el-bg-color);
overflow-y: auto;
}
}
.app-main.with-fixed-header {
+20 -8
View File
@@ -42,7 +42,7 @@
<img src="@/assets/images/dark.svg" alt="dark" />
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false">
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/>
@@ -54,7 +54,7 @@
<img src="@/assets/images/light.svg" alt="light" />
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block">
<i aria-label="图标: check" class="anticon anticon-check">
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class>
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false">
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/>
@@ -87,6 +87,13 @@
</span>
</div>
<div class="drawer-item">
<span>持久化标签页</span>
<span class="comp-style">
<el-switch v-model="settingsStore.tagsViewPersist" :disabled="!settingsStore.tagsView" class="drawer-switch" />
</span>
</div>
<div class="drawer-item">
<span>显示页签图标</span>
<span class="comp-style">
@@ -161,19 +168,19 @@ const toggleDark = () => useToggle(isDark);
/** 菜单导航设置 */
watch(
() => navType,
(val: string) => {
if (val.value === NavTypeEnum.TOP) {
navType,
(val) => {
if (val === NavTypeEnum.TOP) {
appStore.toggleSideBarHide(true);
permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any);
} else if (val.value === NavTypeEnum.LEFT) {
} else if (val === NavTypeEnum.LEFT) {
appStore.toggleSideBarHide(false);
permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any);
} else if (val.value === NavTypeEnum.MIX) {
} else if (val === NavTypeEnum.MIX) {
appStore.toggleSideBarHide(false);
}
},
{ immediate: true, deep: true }
{ immediate: true }
);
const handleNavType = (val: NavTypeEnum) => {
@@ -202,7 +209,11 @@ const handleTheme = (val: string) => {
const saveSetting = () => {
proxy?.$modal.loading('正在保存到本地,请稍候...');
const settings = useStorage<LayoutSetting>('layout-setting', defaultSettings);
if (!storeSettings.value.tagsViewPersist) {
localStorage.removeItem('tags-view-visited');
}
settings.value.tagsView = storeSettings.value.tagsView;
settings.value.tagsViewPersist = storeSettings.value.tagsViewPersist;
settings.value.tagsIcon = storeSettings.value.tagsIcon;
settings.value.fixedHeader = storeSettings.value.fixedHeader;
settings.value.sidebarLogo = storeSettings.value.sidebarLogo;
@@ -216,6 +227,7 @@ const saveSetting = () => {
};
const resetSetting = () => {
proxy?.$modal.loading('正在清除设置缓存并刷新,请稍候...');
localStorage.removeItem('tags-view-visited');
useStorage<any>('layout-setting', null).value = null;
setTimeout('window.location.reload()', 1000);
};
+103 -56
View File
@@ -5,84 +5,127 @@
</template>
<script setup lang="ts">
import { RouteLocationNormalized } from 'vue-router';
import { useTagsViewStore } from '@/store/modules/tagsView';
import type { RouteLocationNormalized } from 'vue-router';
const tagAndTagSpacing = ref(4);
const tagAndTagSpacing = 4;
const emits = defineEmits(['scroll', 'updateArrows']);
const scrollContainerRef = ref<ElScrollbarInstance>();
const scrollWrapper = computed(() => scrollContainerRef.value?.$refs.wrapRef);
const getScrollWrapper = (): HTMLElement | null => {
const scrollbar = scrollContainerRef.value as any;
return scrollbar?.wrapRef || scrollbar?.$refs?.wrapRef || null;
};
const emitScroll = () => {
emits('scroll');
emits('updateArrows');
};
onMounted(() => {
scrollWrapper.value?.addEventListener('scroll', emitScroll, true);
getScrollWrapper()?.addEventListener('scroll', emitScroll, true);
});
onBeforeUnmount(() => {
scrollWrapper.value?.removeEventListener('scroll', emitScroll);
getScrollWrapper()?.removeEventListener('scroll', emitScroll);
});
const smoothScrollTo = (target: number) => {
const scrollWrapper = getScrollWrapper();
if (!scrollWrapper) {
return;
}
scrollWrapper.scrollTo({ left: target, behavior: 'smooth' });
setTimeout(() => {
emits('updateArrows');
}, 350);
};
const handleScroll = (e: WheelEvent) => {
const eventDelta = (e as any).wheelDelta || -e.deltaY * 40;
const $scrollWrapper = scrollWrapper.value;
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4;
};
const emits = defineEmits(['scroll']);
const emitScroll = () => {
emits('scroll');
};
const scrollWrapper = getScrollWrapper();
if (!scrollWrapper) {
return;
}
const tagsViewStore = useTagsViewStore();
const visitedViews = computed(() => tagsViewStore.visitedViews);
scrollWrapper.scrollLeft += eventDelta / 4;
emits('updateArrows');
};
const moveToTarget = (currentTag: RouteLocationNormalized) => {
const $container = scrollContainerRef.value?.$el;
const $containerWidth = $container.offsetWidth;
const $scrollWrapper = scrollWrapper.value;
let firstTag = null;
let lastTag = null;
// find first tag and last tag
if (visitedViews.value.length > 0) {
firstTag = visitedViews.value[0];
lastTag = visitedViews.value[visitedViews.value.length - 1];
const container = scrollContainerRef.value?.$el as HTMLElement | undefined;
const scrollWrapper = getScrollWrapper();
if (!container || !scrollWrapper) {
return;
}
if (firstTag === currentTag) {
$scrollWrapper.scrollLeft = 0;
} else if (lastTag === currentTag) {
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth;
} else {
const tagListDom: any = document.getElementsByClassName('tags-view-item');
const currentIndex = visitedViews.value.findIndex((item) => item === currentTag);
let prevTag = null;
let nextTag = null;
for (const k in tagListDom) {
if (k !== 'length' && Object.hasOwnProperty.call(tagListDom, k)) {
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex - 1].path) {
prevTag = tagListDom[k];
}
if (tagListDom[k].dataset.path === visitedViews.value[currentIndex + 1].path) {
nextTag = tagListDom[k];
}
}
const containerWidth = container.offsetWidth;
const tagKey = currentTag.fullPath || currentTag.path;
const tagListDom = Array.from(document.querySelectorAll('.tags-view-item')) as HTMLElement[];
const currentIndex = tagListDom.findIndex((item) => item.dataset.tagKey === tagKey);
if (currentIndex === -1) {
return;
}
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value;
const currentElement = tagListDom[currentIndex];
const firstTag = tagListDom[0];
const lastTag = tagListDom[tagListDom.length - 1];
// the tag's offsetLeft before of prevTag
const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value;
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth;
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft;
if (currentElement === firstTag) {
smoothScrollTo(0);
return;
}
if (currentElement === lastTag) {
smoothScrollTo(scrollWrapper.scrollWidth - containerWidth);
return;
}
const prevTag = tagListDom[currentIndex - 1];
const nextTag = tagListDom[currentIndex + 1];
if (!prevTag || !nextTag) {
return;
}
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing;
const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing;
if (afterNextTagOffsetLeft > scrollWrapper.scrollLeft + containerWidth) {
smoothScrollTo(afterNextTagOffsetLeft - containerWidth);
} else if (beforePrevTagOffsetLeft < scrollWrapper.scrollLeft) {
smoothScrollTo(beforePrevTagOffsetLeft);
}
};
const scrollToStart = () => {
smoothScrollTo(0);
};
const scrollToEnd = () => {
const scrollWrapper = getScrollWrapper();
if (!scrollWrapper) {
return;
}
smoothScrollTo(scrollWrapper.scrollWidth - scrollWrapper.clientWidth);
};
const getScrollState = () => {
const scrollWrapper = getScrollWrapper();
if (!scrollWrapper) {
return { canLeft: false, canRight: false };
}
return {
canLeft: scrollWrapper.scrollLeft > 0,
canRight: scrollWrapper.scrollLeft < scrollWrapper.scrollWidth - scrollWrapper.clientWidth - 1
};
};
defineExpose({
moveToTarget
moveToTarget,
scrollToStart,
scrollToEnd,
getScrollState
});
</script>
@@ -92,11 +135,15 @@ defineExpose({
position: relative;
overflow: hidden;
width: 100%;
:deep(.el-scrollbar__bar) {
bottom: 0px;
bottom: 0;
}
:deep(.el-scrollbar__wrap) {
height: 49px;
height: 34px;
display: flex;
align-items: center;
}
}
</style>
+397 -110
View File
@@ -1,63 +1,115 @@
<template>
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
<span class="tags-nav-btn tags-nav-btn--left" :class="{ disabled: !canScrollLeft }" @click="scrollLeft">
<el-icon><ArrowLeft /></el-icon>
</span>
<scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll" @update-arrows="updateArrowState">
<router-link
v-for="tag in visitedViews"
:key="tag.path"
:data-path="tag.path"
:class="{ 'active': isActive(tag), 'has-icon': tagsIcon }"
:to="{ path: tag.path ? tag.path : '', query: tag.query, fullPath: tag.fullPath ? tag.fullPath : '' }"
:key="tag.fullPath || tag.path"
:data-tag-key="tag.fullPath || tag.path"
:class="{ active: isActive(tag), 'has-icon': tagsIcon }"
:to="tag.fullPath || tag.path || '/'"
class="tags-view-item"
:style="activeStyle(tag)"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : undefined"
@contextmenu.prevent="openMenu(tag, $event)"
>
<svg-icon v-if="tagsIcon && tag.meta && tag.meta.icon && tag.meta.icon !== '#'" :icon-class="tag.meta.icon" />
<span class="tags-view-item-title">{{ tag.title }}</span>
<span class="tags-view-item-title">{{ tag.title || tag.meta?.title }}</span>
<span v-if="!isAffix(tag)" @click.prevent.stop="closeSelectedTag(tag)">
<close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle" />
<Close class="el-icon-close" style="width: 1em; height: 1em; vertical-align: middle" />
</span>
</router-link>
</scroll-pane>
<span class="tags-nav-btn tags-nav-btn--right" :class="{ disabled: !canScrollRight }" @click="scrollRight">
<el-icon><ArrowRight /></el-icon>
</span>
<el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
<span class="tags-action-btn">
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu class="tags-dropdown-menu">
<el-dropdown-item v-if="!isAffix(selectedDropdownTag)" command="close">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeLeft" :disabled="isFirstView()">关闭左侧</el-dropdown-item>
<el-dropdown-item command="closeRight" :disabled="isLastView()">关闭右侧</el-dropdown-item>
<el-dropdown-item command="closeAll">全部关闭</el-dropdown-item>
<el-dropdown-item command="fullscreen" divided>全屏显示</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="tags-action-btn tags-refresh-btn" title="刷新页面" @click="refreshSelectedTag(selectedDropdownTag)">
<el-icon><RefreshRight /></el-icon>
<span>刷新</span>
</span>
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)"><refresh-right style="width: 1em; height: 1em" /> 刷新页面</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><close style="width: 1em; height: 1em" /> 关闭当前</li>
<li @click="closeOthersTags"><circle-close style="width: 1em; height: 1em" /> 关闭其他</li>
<li v-if="!isFirstView()" @click="closeLeftTags"><back style="width: 1em; height: 1em" /> 关闭左侧</li>
<li v-if="!isLastView()" @click="closeRightTags"><right style="width: 1em; height: 1em" /> 关闭右侧</li>
<li @click="closeAllTags(selectedTag)"><circle-close style="width: 1em; height: 1em" /> 全部关闭</li>
<li @click="refreshSelectedTag(selectedTag)">
<RefreshRight style="width: 1em; height: 1em" /> 刷新页面
</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<Close style="width: 1em; height: 1em" /> 关闭当前
</li>
<li @click="closeOthersTags">
<CircleClose style="width: 1em; height: 1em" /> 关闭其他
</li>
<li v-if="!isFirstView()" @click="closeLeftTags">
<Back style="width: 1em; height: 1em" /> 关闭左侧
</li>
<li v-if="!isLastView()" @click="closeRightTags">
<Right style="width: 1em; height: 1em" /> 关闭右侧
</li>
<li @click="closeAllTags(selectedTag)">
<CircleClose style="width: 1em; height: 1em" /> 全部关闭
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ArrowDown, ArrowLeft, ArrowRight, Back, CircleClose, Close, RefreshRight, Right } from '@element-plus/icons-vue';
import ScrollPane from './ScrollPane.vue';
import { getNormalPath } from '@/utils/ruoyi';
import { useSettingsStore } from '@/store/modules/settings';
import { usePermissionStore } from '@/store/modules/permission';
import { useTagsViewStore } from '@/store/modules/tagsView';
import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
const visible = ref(false);
const top = ref(0);
const left = ref(0);
const selectedTag = ref<RouteLocationNormalized>();
const affixTags = ref<RouteLocationNormalized[]>([]);
const canScrollLeft = ref(false);
const canScrollRight = ref(false);
const isFullscreen = ref(false);
const scrollPaneRef = ref<InstanceType<typeof ScrollPane>>();
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();
const router = useRouter();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const tagsViewStore = useTagsViewStore();
const visitedViews = computed(() => useTagsViewStore().getVisitedViews());
const routes = computed(() => usePermissionStore().getRoutes());
const theme = computed(() => useSettingsStore().theme);
const tagsIcon = computed(() => useSettingsStore().tagsIcon)
const visitedViews = computed(() => tagsViewStore.getVisitedViews());
const routes = computed(() => permissionStore.getRoutes());
const tagsIcon = computed(() => settingsStore.tagsIcon);
const selectedDropdownTag = computed<RouteLocationNormalized | undefined>(() => {
return visitedViews.value.find((tag) => isActive(tag)) || selectedTag.value;
});
watch(route, () => {
addTags();
moveToCurrentTag();
});
watch(visible, (value) => {
if (value) {
document.body.addEventListener('click', closeMenu);
@@ -66,53 +118,80 @@ watch(visible, (value) => {
}
});
const isActive = (r: RouteLocationNormalized): boolean => {
return r.path === route.path;
watch(
visitedViews,
() => {
nextTick(() => {
updateArrowState();
});
},
{ deep: true }
);
const isActive = (currentRoute: RouteLocationNormalized): boolean => {
return currentRoute.path === route.path;
};
const activeStyle = (tag: RouteLocationNormalized) => {
if (!isActive(tag)) return {};
return {
'background-color': 'var(--tags-view-active-bg)',
'border-color': 'var(--tags-view-active-border-color)'
backgroundColor: 'var(--tags-view-active-bg)',
borderColor: 'var(--tags-view-active-border-color)'
};
};
const isAffix = (tag: RouteLocationNormalized) => {
return tag?.meta && tag?.meta?.affix;
};
const isFirstView = () => {
try {
return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath;
} catch (err) {
return false;
}
};
const isLastView = () => {
try {
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath;
} catch (err) {
return false;
}
};
const filterAffixTags = (routes: RouteRecordRaw[], basePath = '') => {
let tags: RouteLocationNormalized[] = [];
routes.forEach((route) => {
if (route.meta && route.meta.affix) {
const tagPath = getNormalPath(basePath + '/' + route.path);
const isAffix = (tag?: RouteLocationNormalized) => {
return !!tag?.meta?.affix;
};
const getOperateTag = () => {
return selectedTag.value?.fullPath ? selectedTag.value : selectedDropdownTag.value;
};
const isFirstView = () => {
const tag = getOperateTag();
if (!tag) {
return false;
}
try {
return tag.fullPath === '/index' || tag.fullPath === visitedViews.value[1]?.fullPath;
} catch {
return false;
}
};
const isLastView = () => {
const tag = getOperateTag();
if (!tag) {
return false;
}
try {
return tag.fullPath === visitedViews.value[visitedViews.value.length - 1]?.fullPath;
} catch {
return false;
}
};
const filterAffixTags = (routeList: RouteRecordRaw[], basePath = '') => {
let tags: RouteLocationNormalized[] = [];
routeList.forEach((item) => {
if (item.meta?.affix) {
const tagPath = getNormalPath(basePath + '/' + item.path);
tags.push({
hash: '',
matched: [],
params: undefined,
query: undefined,
params: {},
query: {},
redirectedFrom: undefined,
fullPath: tagPath,
path: tagPath,
name: route.name as string,
meta: { ...route.meta }
});
name: item.name as string,
meta: { ...item.meta },
title: item.meta?.title || 'no-name'
} as RouteLocationNormalized);
}
if (route.children) {
const tempTags = filterAffixTags(route.children, route.path);
if (item.children) {
const tempTags = filterAffixTags(item.children, item.path);
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags];
}
@@ -120,135 +199,283 @@ const filterAffixTags = (routes: RouteRecordRaw[], basePath = '') => {
});
return tags;
};
const initTags = () => {
const res = filterAffixTags(routes.value);
affixTags.value = res;
for (const tag of res) {
// Must have tag name
if (settingsStore.tagsViewPersist) {
tagsViewStore.loadPersistedViews();
}
const tags = filterAffixTags(routes.value);
affixTags.value = tags;
for (const tag of tags) {
if (tag.name) {
useTagsViewStore().addVisitedView(tag);
tagsViewStore.addAffixView(tag);
}
}
};
const addTags = () => {
const { name } = route;
if (route.query.title) {
route.meta.title = route.query.title as string;
if (typeof route.query.title === 'string') {
route.meta.title = route.query.title;
}
if (name) {
useTagsViewStore().addView(route as any);
if (route.name) {
tagsViewStore.addView(route as RouteLocationNormalized);
}
};
const moveToCurrentTag = () => {
nextTick(() => {
for (const r of visitedViews.value) {
if (r.path === route.path) {
scrollPaneRef.value?.moveToTarget(r);
// when query is different then update
if (r.fullPath !== route.fullPath) {
useTagsViewStore().updateVisitedView(route);
for (const item of visitedViews.value) {
if (item.path === route.path) {
scrollPaneRef.value?.moveToTarget(item);
if (item.fullPath !== route.fullPath) {
tagsViewStore.updateVisitedView(route as RouteLocationNormalized);
}
break;
}
}
});
};
const refreshSelectedTag = (view: RouteLocationNormalized) => {
const refreshSelectedTag = (view?: RouteLocationNormalized) => {
if (!view) {
return;
}
proxy?.$tab.refreshPage(view);
if (route.meta.link) {
useTagsViewStore().delIframeView(route);
tagsViewStore.delIframeView(route as RouteLocationNormalized);
}
};
const closeSelectedTag = (view: RouteLocationNormalized) => {
proxy?.$tab.closePage(view).then(({ visitedViews }: any) => {
const closeSelectedTag = (view?: RouteLocationNormalized) => {
if (!view) {
return;
}
proxy?.$tab.closePage(view).then(({ visitedViews }: { visitedViews: RouteLocationNormalized[] }) => {
if (isActive(view)) {
toLastView(visitedViews, view);
}
});
};
const closeRightTags = () => {
proxy?.$tab.closeRightPage(selectedTag.value).then((visitedViews: RouteLocationNormalized[]) => {
if (!visitedViews.find((i: RouteLocationNormalized) => i.fullPath === route.fullPath)) {
toLastView(visitedViews);
const tag = getOperateTag();
if (!tag) {
return;
}
proxy?.$tab.closeRightPage(tag).then((views: RouteLocationNormalized[]) => {
if (!views.find((item) => item.fullPath === route.fullPath)) {
toLastView(views);
}
});
};
const closeLeftTags = () => {
proxy?.$tab.closeLeftPage(selectedTag.value).then((visitedViews: RouteLocationNormalized[]) => {
if (!visitedViews.find((i: RouteLocationNormalized) => i.fullPath === route.fullPath)) {
toLastView(visitedViews);
const tag = getOperateTag();
if (!tag) {
return;
}
proxy?.$tab.closeLeftPage(tag).then((views: RouteLocationNormalized[]) => {
if (!views.find((item) => item.fullPath === route.fullPath)) {
toLastView(views);
}
});
};
const closeOthersTags = () => {
router.push(selectedTag.value).catch(() => {});
proxy?.$tab.closeOtherPage(selectedTag.value).then(() => {
const tag = getOperateTag();
if (!tag) {
return;
}
router.push(tag.fullPath || tag.path || '/').catch(() => {});
proxy?.$tab.closeOtherPage(tag).then(() => {
moveToCurrentTag();
});
};
const closeAllTags = (view: RouteLocationNormalized) => {
proxy?.$tab.closeAllPage().then(({ visitedViews }) => {
const closeAllTags = (view?: RouteLocationNormalized) => {
proxy?.$tab.closeAllPage().then(({ visitedViews: views }: { visitedViews: RouteLocationNormalized[] }) => {
if (affixTags.value.some((tag) => tag.path === route.path)) {
return;
}
toLastView(visitedViews, view);
toLastView(views, view);
});
};
const toLastView = (visitedViews: RouteLocationNormalized[], view?: RouteLocationNormalized) => {
const latestView = visitedViews.slice(-1)[0];
if (latestView) {
router.push(latestView.fullPath as string);
} else {
// now the default is to redirect to the home page if there is no tags-view,
// you can adjust it according to your needs.
if (view?.name === 'Dashboard') {
// to reload home page
router.replace({ path: '/redirect' + view?.fullPath });
const toLastView = (views: RouteLocationNormalized[], view?: RouteLocationNormalized) => {
const latestView = views.slice(-1)[0];
if (latestView?.fullPath) {
router.push(latestView.fullPath);
} else if (view?.name === 'Dashboard' && view.fullPath) {
router.replace({ path: '/redirect' + view.fullPath });
} else {
router.push('/');
}
};
const scrollLeft = () => {
if (!canScrollLeft.value) {
return;
}
scrollPaneRef.value?.scrollToStart();
};
const scrollRight = () => {
if (!canScrollRight.value) {
return;
}
scrollPaneRef.value?.scrollToEnd();
};
const updateArrowState = () => {
nextTick(() => {
const state = scrollPaneRef.value?.getScrollState();
canScrollLeft.value = !!state?.canLeft;
canScrollRight.value = !!state?.canRight;
});
};
const toggleFullscreen = async () => {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!document.fullscreenElement) {
await appMain?.requestFullscreen?.();
} else {
await document.exitFullscreen();
}
};
const openMenu = (tag: RouteLocationNormalized, e: MouseEvent) => {
const menuMinWidth = 105;
const offsetLeft = proxy?.$el.getBoundingClientRect().left; // container margin left
const offsetWidth = proxy?.$el.offsetWidth; // container width
const maxLeft = offsetWidth - menuMinWidth; // left boundary
const l = e.clientX - offsetLeft + 15; // 15: margin right
if (l > maxLeft) {
left.value = maxLeft;
} else {
left.value = l;
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
};
const handleDropdownCommand = (command: string) => {
const tag = selectedDropdownTag.value;
if (!tag) {
return;
}
selectedTag.value = tag;
switch (command) {
case 'fullscreen':
toggleFullscreen();
break;
case 'close':
closeSelectedTag(tag);
break;
case 'closeOthers':
closeOthersTags();
break;
case 'closeLeft':
closeLeftTags();
break;
case 'closeRight':
closeRightTags();
break;
case 'closeAll':
closeAllTags(tag);
break;
default:
break;
}
};
const openMenu = (tag: RouteLocationNormalized, e: MouseEvent) => {
left.value = e.clientX;
top.value = e.clientY;
visible.value = true;
selectedTag.value = tag;
};
const closeMenu = () => {
visible.value = false;
};
const handleScroll = () => {
closeMenu();
updateArrowState();
};
onMounted(() => {
initTags();
addTags();
updateArrowState();
window.addEventListener('resize', updateArrowState);
document.addEventListener('fullscreenchange', onFullscreenChange);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateArrowState);
document.removeEventListener('fullscreenchange', onFullscreenChange);
document.body.removeEventListener('click', closeMenu);
});
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
display: flex;
align-items: flex-start;
height: 38px;
width: 100%;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--app-radius-md);
box-shadow: var(--app-shadow-sm);
$btn-width: 26px;
$btn-color: #71717a;
$btn-hover-bg: var(--el-fill-color-light);
$btn-hover-color: var(--el-text-color-primary);
$btn-disabled-color: #c0c4cc;
.tags-nav-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: $btn-width;
height: 26px;
margin-top: 4px;
cursor: pointer;
color: $btn-color;
user-select: none;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--app-radius-md);
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
&:hover:not(.disabled) {
background: $btn-hover-bg;
color: $btn-hover-color;
border-color: var(--el-color-primary-light-5);
box-shadow: var(--app-shadow-sm);
transform: translateY(-1px);
}
&.disabled {
color: $btn-disabled-color;
cursor: not-allowed;
}
}
.tags-nav-btn--left {
margin-left: 8px;
margin-right: 4px;
}
.tags-nav-btn--right {
margin-left: 4px;
margin-right: 4px;
}
.tags-view-wrapper {
flex: 1;
min-width: 0;
.tags-view-item {
display: inline-block;
display: inline-flex;
align-items: center;
position: relative;
cursor: pointer;
height: 26px;
@@ -261,23 +488,32 @@ onMounted(() => {
margin-left: 5px;
margin-top: 4px;
border-radius: var(--app-radius-md);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease, color 0.2s ease;
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
&:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
box-shadow: var(--app-shadow-sm);
transform: translateY(-1px);
}
&:first-of-type {
margin-left: 15px;
margin-left: 10px;
}
&:last-of-type {
margin-right: 15px;
margin-right: 10px;
}
&.active {
background-color: #42b983;
color: #fff;
border-color: #42b983;
&::before {
content: '';
background: #fff;
@@ -291,28 +527,78 @@ onMounted(() => {
}
}
}
.tags-view-item.active.has-icon::before {
content: none !important;
}
.tags-view-item-title {
margin-left: 4px;
margin-right: 3px;
}
.tags-action-dropdown {
flex-shrink: 0;
display: flex;
align-items: center;
margin-top: 4px;
margin-left: 4px;
}
.tags-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: $btn-width;
height: 26px;
padding: 0 8px;
cursor: pointer;
color: $btn-color;
user-select: none;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: var(--app-radius-md);
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
&:hover {
background: $btn-hover-bg;
color: $btn-hover-color;
border-color: var(--el-color-primary-light-5);
box-shadow: var(--app-shadow-sm);
transform: translateY(-1px);
}
}
.tags-refresh-btn {
width: auto;
font-size: 12px;
margin-top: 4px;
margin-left: 8px;
margin-right: 8px;
}
.contextmenu {
margin: 0;
background: var(--el-bg-color);
z-index: 3000;
position: absolute;
position: fixed;
list-style-type: none;
padding: 5px 0;
border-radius: var(--app-radius-md);
font-size: 12px;
font-weight: 400;
box-shadow: var(--app-shadow-md);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: var(--el-fill-color-light);
}
@@ -322,7 +608,6 @@ onMounted(() => {
</style>
<style lang="scss">
//reset element css of el-icon-close
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
@@ -333,11 +618,13 @@ onMounted(() => {
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
+5
View File
@@ -28,6 +28,11 @@ const setting: DefaultSettings = {
*/
tagsView: true,
/**
* 持久化标签页
*/
tagsViewPersist: false,
/**
* 显示页签图标
*/
+3 -1
View File
@@ -7,8 +7,8 @@ import { NavTypeEnum } from '@/enums/NavTypeEnum';
export const useSettingsStore = defineStore('setting', () => {
const storageSetting = useStorage<LayoutSetting>('layout-setting', {
topNav: defaultSettings.topNav,
tagsView: defaultSettings.tagsView,
tagsViewPersist: defaultSettings.tagsViewPersist,
tagsIcon: defaultSettings.tagsIcon,
fixedHeader: defaultSettings.fixedHeader,
sidebarLogo: defaultSettings.sidebarLogo,
@@ -22,6 +22,7 @@ export const useSettingsStore = defineStore('setting', () => {
const sideTheme = ref<string>(storageSetting.value.sideTheme);
const showSettings = ref<boolean>(defaultSettings.showSettings);
const tagsView = ref<boolean>(storageSetting.value.tagsView);
const tagsViewPersist = ref<boolean>(storageSetting.value.tagsViewPersist);
const tagsIcon = ref<boolean>(storageSetting.value.tagsIcon);
const fixedHeader = ref<boolean>(storageSetting.value.fixedHeader);
const sidebarLogo = ref<boolean>(storageSetting.value.sidebarLogo);
@@ -40,6 +41,7 @@ export const useSettingsStore = defineStore('setting', () => {
sideTheme,
showSettings,
tagsView,
tagsViewPersist,
tagsIcon,
fixedHeader,
sidebarLogo,
+134 -28
View File
@@ -1,17 +1,80 @@
import { RouteLocationNormalized } from 'vue-router';
import type { LocationQuery, RouteLocationNormalized, RouteMeta } from 'vue-router';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useSettingsStore } from './settings';
const PERSIST_KEY = 'tags-view-visited';
type PersistedTagView = {
path: string;
fullPath?: string;
name?: string | symbol | null;
title?: string;
query?: LocationQuery;
meta?: RouteMeta;
};
type TagView = RouteLocationNormalized & {
title?: string;
};
const isPersistEnabled = () => {
return useSettingsStore().tagsViewPersist;
};
const normalizeVisitedView = (view: RouteLocationNormalized): TagView => {
return Object.assign({}, view, {
title: view.meta?.title || 'no-name'
});
};
const saveVisitedViews = (views: any[]) => {
if (!isPersistEnabled()) {
return;
}
const payload: PersistedTagView[] = (views as TagView[])
.filter((view) => !view.meta?.affix)
.map((view) => ({
path: view.path,
fullPath: view.fullPath,
name: view.name,
title: (view as any).title || view.meta?.title || 'no-name',
query: view.query,
meta: view.meta
}));
localStorage.setItem(PERSIST_KEY, JSON.stringify(payload));
};
const loadVisitedViews = (): PersistedTagView[] => {
const cache = localStorage.getItem(PERSIST_KEY);
if (!cache) {
return [];
}
try {
return JSON.parse(cache) as PersistedTagView[];
} catch {
localStorage.removeItem(PERSIST_KEY);
return [];
}
};
const clearVisitedViews = () => {
localStorage.removeItem(PERSIST_KEY);
};
export const useTagsViewStore = defineStore('tagsView', () => {
const visitedViews = ref<RouteLocationNormalized[]>([]);
const visitedViews = ref<TagView[]>([]);
const cachedViews = ref<string[]>([]);
const iframeViews = ref<RouteLocationNormalized[]>([]);
const iframeViews = ref<TagView[]>([]);
const getVisitedViews = (): RouteLocationNormalized[] => {
return visitedViews.value as RouteLocationNormalized[];
const getVisitedViews = (): TagView[] => {
return visitedViews.value as TagView[];
};
const getIframeViews = (): RouteLocationNormalized[] => {
return iframeViews.value as RouteLocationNormalized[];
const getIframeViews = (): TagView[] => {
return iframeViews.value as TagView[];
};
const getCachedViews = (): string[] => {
return cachedViews.value;
@@ -24,26 +87,54 @@ export const useTagsViewStore = defineStore('tagsView', () => {
const addIframeView = (view: RouteLocationNormalized): void => {
if (iframeViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
iframeViews.value.push(
Object.assign({}, view, {
title: view.meta?.title || 'no-name'
})
);
iframeViews.value.push(normalizeVisitedView(view) as RouteLocationNormalized);
};
const delIframeView = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
iframeViews.value = iframeViews.value.filter((item: RouteLocationNormalized) => item.path !== view.path);
resolve([...(iframeViews.value as RouteLocationNormalized[])]);
resolve(iframeViews.value.slice() as RouteLocationNormalized[]);
});
};
const addVisitedView = (view: RouteLocationNormalized): void => {
if (visitedViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
visitedViews.value.push(
Object.assign({}, view, {
title: view.meta?.title || 'no-name'
})
);
visitedViews.value.push(normalizeVisitedView(view));
saveVisitedViews(visitedViews.value);
};
const addAffixView = (view: RouteLocationNormalized): void => {
if (visitedViews.value.some((v: RouteLocationNormalized) => v.path === view.path)) return;
const insertIndex = visitedViews.value.findIndex((item) => !item.meta?.affix);
const normalizedView = normalizeVisitedView(view);
if (insertIndex === -1) {
visitedViews.value.push(normalizedView);
} else {
visitedViews.value.splice(insertIndex, 0, normalizedView);
}
};
const loadPersistedViews = (): void => {
loadVisitedViews().forEach((view) => {
if (visitedViews.value.some((item) => item.path === view.path)) {
return;
}
visitedViews.value.push({
hash: '',
matched: [],
params: {},
redirectedFrom: undefined,
path: view.path,
fullPath: view.fullPath || view.path,
query: view.query || {},
name: view.name || undefined,
meta: view.meta || {},
title: view.title || view.meta?.title || 'no-name'
} as TagView);
});
};
const delView = (
view: RouteLocationNormalized
): Promise<{
@@ -56,7 +147,7 @@ export const useTagsViewStore = defineStore('tagsView', () => {
delCachedView(view);
}
resolve({
visitedViews: [...(visitedViews.value as RouteLocationNormalized[])],
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value]
});
});
@@ -70,9 +161,11 @@ export const useTagsViewStore = defineStore('tagsView', () => {
break;
}
}
resolve([...(visitedViews.value as RouteLocationNormalized[])]);
saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
});
};
const delCachedView = (view?: RouteLocationNormalized): Promise<string[]> => {
let viewName = '';
if (view) {
@@ -84,6 +177,7 @@ export const useTagsViewStore = defineStore('tagsView', () => {
resolve([...cachedViews.value]);
});
};
const delOthersViews = (
view: RouteLocationNormalized
): Promise<{
@@ -94,7 +188,7 @@ export const useTagsViewStore = defineStore('tagsView', () => {
delOthersVisitedViews(view);
delOthersCachedViews(view);
resolve({
visitedViews: [...(visitedViews.value as RouteLocationNormalized[])],
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value]
});
});
@@ -105,9 +199,11 @@ export const useTagsViewStore = defineStore('tagsView', () => {
visitedViews.value = visitedViews.value.filter((v: RouteLocationNormalized) => {
return v.meta?.affix || v.path === view.path;
});
resolve([...(visitedViews.value as RouteLocationNormalized[])]);
saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
});
};
const delOthersCachedViews = (view: RouteLocationNormalized): Promise<string[]> => {
const viewName = view.name as string;
return new Promise((resolve) => {
@@ -126,15 +222,17 @@ export const useTagsViewStore = defineStore('tagsView', () => {
delAllVisitedViews();
delAllCachedViews();
resolve({
visitedViews: [...(visitedViews.value as RouteLocationNormalized[])],
visitedViews: visitedViews.value.slice() as RouteLocationNormalized[],
cachedViews: [...cachedViews.value]
});
});
};
const delAllVisitedViews = (): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
visitedViews.value = visitedViews.value.filter((tag: RouteLocationNormalized) => tag.meta?.affix);
resolve([...(visitedViews.value as RouteLocationNormalized[])]);
clearVisitedViews();
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
});
};
@@ -152,11 +250,14 @@ export const useTagsViewStore = defineStore('tagsView', () => {
break;
}
}
saveVisitedViews(visitedViews.value);
};
const delRightTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path);
if (index === -1) {
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
return;
}
visitedViews.value = visitedViews.value.filter((item: RouteLocationNormalized, idx: number) => {
@@ -169,13 +270,16 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}
return false;
});
resolve([...(visitedViews.value as RouteLocationNormalized[])]);
saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
});
};
const delLeftTags = (view: RouteLocationNormalized): Promise<RouteLocationNormalized[]> => {
return new Promise((resolve) => {
const index = visitedViews.value.findIndex((v: RouteLocationNormalized) => v.path === view.path);
if (index === -1) {
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
return;
}
visitedViews.value = visitedViews.value.filter((item: RouteLocationNormalized, idx: number) => {
@@ -188,7 +292,8 @@ export const useTagsViewStore = defineStore('tagsView', () => {
}
return false;
});
resolve([...(visitedViews.value as RouteLocationNormalized[])]);
saveVisitedViews(visitedViews.value);
resolve(visitedViews.value.slice() as RouteLocationNormalized[]);
});
};
@@ -202,7 +307,6 @@ export const useTagsViewStore = defineStore('tagsView', () => {
};
const isDynamicRoute = (view: RouteLocationNormalized): boolean => {
// 检查匹配的路由记录中是否有动态段
return view.matched.some((m) => m.path.includes(':'));
};
@@ -216,6 +320,7 @@ export const useTagsViewStore = defineStore('tagsView', () => {
getCachedViews,
addVisitedView,
addAffixView,
addCachedView,
delVisitedView,
delCachedView,
@@ -229,6 +334,7 @@ export const useTagsViewStore = defineStore('tagsView', () => {
delRightTags,
delLeftTags,
addIframeView,
delIframeView
delIframeView,
loadPersistedViews
};
});
+4
View File
@@ -99,6 +99,10 @@ declare global {
* 是否显示多标签导航
*/
tagsView: boolean;
/**
* 是否持久化标签页
*/
tagsViewPersist: boolean;
/**
* 显示页签图标
*/