All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m38s
Co-authored-by: Cursor <cursoragent@cursor.com>
258 lines
8.1 KiB
TypeScript
258 lines
8.1 KiB
TypeScript
import type { RouteLocationNormalized } from 'vue-router';
|
||
import { defineStore } from 'pinia';
|
||
import {
|
||
DEFAULT_ROUTE,
|
||
DEFAULT_ROUTE_NAME,
|
||
REDIRECT_ROUTE_NAME,
|
||
} from '@/router/constants';
|
||
import { isString } from '@/utils/is';
|
||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||
import { buildMenuTitleMaps, resolveMenuTitleByPath, resolveMenuTitleFromTree } from '@/utils/menu-display';
|
||
import { translateMenuTitleByPath } from '@/utils/menu-i18n';
|
||
import { isLoginRouteName } from '@/utils/login-portal';
|
||
import i18n from '@/locale';
|
||
import type { MenuTreeDto } from '@/api/rbac';
|
||
import { TabBarState, TagProps } from './types';
|
||
|
||
/** keep-alive 同时缓存的页面上限(LRU),避免页签开太多越切越顿 */
|
||
export const MAX_KEEP_ALIVE_TABS = 10;
|
||
|
||
function resolveRouteTabTitle(route: RouteLocationNormalized): string {
|
||
const rbacUserStore = useRbacUserStore();
|
||
const { pathToTitle } = buildMenuTitleMaps(rbacUserStore.menus as MenuTreeDto[]);
|
||
const menuTitle = resolveMenuTitleByPath(route.path, pathToTitle);
|
||
if (menuTitle) {
|
||
return menuTitle;
|
||
}
|
||
|
||
const metaLocale = route.meta?.locale;
|
||
if (typeof metaLocale === 'string' && metaLocale.trim()) {
|
||
const translated = String(i18n.global.t(metaLocale.trim()));
|
||
if (translated && translated !== metaLocale.trim()) {
|
||
return translated;
|
||
}
|
||
}
|
||
|
||
const byPath = translateMenuTitleByPath(route.path);
|
||
if (byPath) {
|
||
return byPath;
|
||
}
|
||
|
||
const metaTitle = route.meta?.title;
|
||
if (typeof metaTitle === 'string' && metaTitle.trim()) {
|
||
return metaTitle.trim();
|
||
}
|
||
|
||
return String(route.name || route.path || '');
|
||
}
|
||
|
||
const formatTag = (route: RouteLocationNormalized): TagProps => {
|
||
const { name, meta, fullPath, query } = route;
|
||
return {
|
||
title: resolveRouteTabTitle(route),
|
||
name: String(name),
|
||
fullPath,
|
||
query,
|
||
ignoreCache: meta.ignoreCache,
|
||
};
|
||
};
|
||
|
||
const BAN_LIST = [REDIRECT_ROUTE_NAME, 'H5Login', 'H5AdminLogin', 'siteRoot'];
|
||
|
||
function shouldSkipTabRoute(route: RouteLocationNormalized): boolean {
|
||
const name = route.name != null ? String(route.name) : '';
|
||
if (!name) return true;
|
||
if (BAN_LIST.includes(name)) return true;
|
||
if (isLoginRouteName(name)) return true;
|
||
if (route.meta?.noAffix) return true;
|
||
if (route.meta?.requiresAuth === false && (route.path === '/login' || route.path === '/admin')) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const useTabBarStore = defineStore('tabBar', {
|
||
state: (): TabBarState => ({
|
||
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
|
||
tagList: [DEFAULT_ROUTE],
|
||
previewingPath: '',
|
||
previewingName: '',
|
||
routeRefreshTicks: {},
|
||
}),
|
||
|
||
getters: {
|
||
getTabList(): TagProps[] {
|
||
return this.tagList;
|
||
},
|
||
getCacheList(): string[] {
|
||
return Array.from(this.cacheTabList);
|
||
},
|
||
},
|
||
|
||
actions: {
|
||
/** 超出上限时淘汰最久未访问的缓存(保留首页) */
|
||
pruneCache(exceptName?: string) {
|
||
while (this.cacheTabList.size > MAX_KEEP_ALIVE_TABS) {
|
||
let removed = false;
|
||
for (const name of this.cacheTabList) {
|
||
if (name === DEFAULT_ROUTE_NAME || name === exceptName) {
|
||
continue;
|
||
}
|
||
this.cacheTabList.delete(name);
|
||
removed = true;
|
||
break;
|
||
}
|
||
if (!removed) break;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* LRU:访问时挪到末尾,并裁剪超量缓存。
|
||
* 必须一次性重赋值 Set,禁止先 delete 再 add:
|
||
* keep-alive 会监听 include,名字从列表里消失会立刻 prune 掉缓存实例,
|
||
* 表现为「切换标签就整页刷新」。
|
||
*/
|
||
touchCache(name: string) {
|
||
if (!isString(name) || name === '') return;
|
||
const next = new Set(
|
||
[...this.cacheTabList].filter((item) => item !== name),
|
||
);
|
||
next.add(name);
|
||
this.cacheTabList = next;
|
||
this.pruneCache(name);
|
||
},
|
||
|
||
updateTabList(route: RouteLocationNormalized) {
|
||
if (shouldSkipTabRoute(route)) return;
|
||
// 登录页曾被误写入时清掉,避免登录成功后仍挂着「login」页签
|
||
this.tagList = this.tagList.filter(
|
||
(tag) => !isLoginRouteName(tag.name) && tag.name !== 'H5Login' && tag.name !== 'H5AdminLogin',
|
||
);
|
||
const next = formatTag(route);
|
||
const idx = this.tagList.findIndex(
|
||
(tag) =>
|
||
tag.fullPath === next.fullPath ||
|
||
(!!next.name && tag.name === next.name),
|
||
);
|
||
// 同路由仅 query 变化(如公告 tab 的 noticeType)时更新已有页签,避免顶栏重复开签
|
||
if (idx !== -1) {
|
||
const prev = this.tagList[idx];
|
||
this.tagList[idx] = {
|
||
...prev,
|
||
fullPath: next.fullPath,
|
||
query: next.query,
|
||
title: next.title || prev.title,
|
||
ignoreCache: next.ignoreCache,
|
||
};
|
||
} else {
|
||
this.tagList.push(next);
|
||
}
|
||
if (!route.meta.ignoreCache) {
|
||
this.touchCache(String(route.name));
|
||
}
|
||
},
|
||
/** 菜单/标签点击立刻高亮,不等路由 afterEach */
|
||
previewNavigate(tag: TagProps) {
|
||
if (!tag?.fullPath && !tag?.name) return;
|
||
if (
|
||
isLoginRouteName(tag.name) ||
|
||
tag.name === 'H5Login' ||
|
||
tag.name === 'H5AdminLogin'
|
||
) {
|
||
return;
|
||
}
|
||
this.previewingPath = tag.fullPath || '';
|
||
this.previewingName = tag.name || '';
|
||
const exists = this.tagList.some(
|
||
(t) =>
|
||
(tag.fullPath && t.fullPath === tag.fullPath) ||
|
||
(tag.name && t.name === tag.name),
|
||
);
|
||
if (!exists && tag.name) {
|
||
this.tagList.push(tag);
|
||
if (!tag.ignoreCache) {
|
||
this.touchCache(tag.name);
|
||
}
|
||
}
|
||
},
|
||
clearPreview() {
|
||
if (!this.previewingPath && !this.previewingName) return;
|
||
this.previewingPath = '';
|
||
this.previewingName = '';
|
||
},
|
||
deleteTag(idx: number, tag: TagProps) {
|
||
this.tagList.splice(idx, 1);
|
||
this.cacheTabList.delete(tag.name);
|
||
},
|
||
addCache(name: string) {
|
||
if (isString(name) && name !== '') {
|
||
this.touchCache(name);
|
||
}
|
||
},
|
||
deleteCache(tag: TagProps) {
|
||
this.cacheTabList.delete(tag.name);
|
||
},
|
||
/** 顶栏刷新:仅 bump 一次 key,避免 router.replace 加/去 _r 导致页面挂载两次 */
|
||
bumpRouteRefresh(routeName: string) {
|
||
const key = String(routeName || '').trim();
|
||
if (!key) return;
|
||
const prev = this.routeRefreshTicks[key] || 0;
|
||
this.routeRefreshTicks[key] = prev + 1;
|
||
},
|
||
freshTabList(tags: TagProps[]) {
|
||
this.tagList = tags;
|
||
this.cacheTabList.clear();
|
||
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
|
||
this.tagList
|
||
.filter((el) => !el.ignoreCache)
|
||
.map((el) => el.name)
|
||
.forEach((x) => {
|
||
if (x && x !== DEFAULT_ROUTE_NAME) {
|
||
this.cacheTabList.add(x);
|
||
}
|
||
});
|
||
this.pruneCache();
|
||
},
|
||
resetTabList() {
|
||
this.tagList = [DEFAULT_ROUTE];
|
||
this.cacheTabList.clear();
|
||
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
|
||
this.previewingPath = '';
|
||
this.previewingName = '';
|
||
},
|
||
delVisitedRoute(path: string) {
|
||
const idx = this.tagList.findIndex(tag => tag.fullPath === path);
|
||
if (idx !== -1) {
|
||
const tag = this.tagList[idx];
|
||
this.deleteTag(idx, tag);
|
||
}
|
||
},
|
||
/** 拖动排序标签:将 fromIndex 位置的标签移动到 toIndex */
|
||
reorderTabs(fromIndex: number, toIndex: number) {
|
||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
|
||
const list = [...this.tagList];
|
||
const [item] = list.splice(fromIndex, 1);
|
||
list.splice(toIndex, 0, item);
|
||
this.freshTabList(list);
|
||
},
|
||
/** 菜单加载或切换语言后刷新标签标题 */
|
||
refreshTagTitles() {
|
||
const rbacUserStore = useRbacUserStore();
|
||
const menus = rbacUserStore.menus as MenuTreeDto[];
|
||
this.tagList = this.tagList.map((tag) => {
|
||
const fromTree = resolveMenuTitleFromTree(menus, tag.fullPath, '');
|
||
if (fromTree) {
|
||
return { ...tag, title: fromTree };
|
||
}
|
||
const byPath = translateMenuTitleByPath(tag.fullPath);
|
||
if (byPath) {
|
||
return { ...tag, title: byPath };
|
||
}
|
||
return tag;
|
||
});
|
||
},
|
||
},
|
||
});
|
||
|
||
export default useTabBarStore;
|