
* feat(tabs): 支持动态函数作为标签标题 修改 `setTabTitle` 和 `tabsView` 逻辑,允许传入函数作为标签标题,以便动态生成标题内容 * feat(tabbar): 添加动态设置标签页标题功能 允许设置静态字符串或动态函数作为标签标题,支持根据状态或语言变化动态更新标题 * refactor(tabs): 移除冗余的newTabTitle2变量并优化标题设置逻辑 移除tabs组件中冗余的newTabTitle2变量,直接使用newTabTitle作为标题来源。同时,优化use-tabs和tabbar模块的标题设置逻辑,支持ComputedRef作为动态标题,提升代码简洁性和可维护性。 --------- Co-authored-by: yuanwj <ywj6792341@qq.com>
622 lines
16 KiB
TypeScript
622 lines
16 KiB
TypeScript
import type { ComputedRef } from 'vue';
|
||
import type { Router, RouteRecordNormalized } from 'vue-router';
|
||
|
||
import type { TabDefinition } from '@vben-core/typings';
|
||
|
||
import { toRaw } from 'vue';
|
||
|
||
import { preferences } from '@vben-core/preferences';
|
||
import {
|
||
openRouteInNewWindow,
|
||
startProgress,
|
||
stopProgress,
|
||
} from '@vben-core/shared/utils';
|
||
|
||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||
|
||
interface TabbarState {
|
||
/**
|
||
* @zh_CN 当前打开的标签页列表缓存
|
||
*/
|
||
cachedTabs: Set<string>;
|
||
/**
|
||
* @zh_CN 拖拽结束的索引
|
||
*/
|
||
dragEndIndex: number;
|
||
/**
|
||
* @zh_CN 需要排除缓存的标签页
|
||
*/
|
||
excludeCachedTabs: Set<string>;
|
||
/**
|
||
* @zh_CN 标签右键菜单列表
|
||
*/
|
||
menuList: string[];
|
||
/**
|
||
* @zh_CN 是否刷新
|
||
*/
|
||
renderRouteView?: boolean;
|
||
/**
|
||
* @zh_CN 当前打开的标签页列表
|
||
*/
|
||
tabs: TabDefinition[];
|
||
/**
|
||
* @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能
|
||
*/
|
||
updateTime?: number;
|
||
}
|
||
|
||
/**
|
||
* @zh_CN 访问权限相关
|
||
*/
|
||
export const useTabbarStore = defineStore('core-tabbar', {
|
||
actions: {
|
||
/**
|
||
* Close tabs in bulk
|
||
*/
|
||
async _bulkCloseByPaths(paths: string[]) {
|
||
this.tabs = this.tabs.filter((item) => {
|
||
return !paths.includes(getTabPath(item));
|
||
});
|
||
|
||
this.updateCacheTabs();
|
||
},
|
||
/**
|
||
* @zh_CN 关闭标签页
|
||
* @param tab
|
||
*/
|
||
_close(tab: TabDefinition) {
|
||
const { fullPath } = tab;
|
||
if (isAffixTab(tab)) {
|
||
return;
|
||
}
|
||
const index = this.tabs.findIndex((item) => item.fullPath === fullPath);
|
||
index !== -1 && this.tabs.splice(index, 1);
|
||
},
|
||
/**
|
||
* @zh_CN 跳转到默认标签页
|
||
*/
|
||
async _goToDefaultTab(router: Router) {
|
||
if (this.getTabs.length <= 0) {
|
||
return;
|
||
}
|
||
const firstTab = this.getTabs[0];
|
||
if (firstTab) {
|
||
await this._goToTab(firstTab, router);
|
||
}
|
||
},
|
||
/**
|
||
* @zh_CN 跳转到标签页
|
||
* @param tab
|
||
* @param router
|
||
*/
|
||
async _goToTab(tab: TabDefinition, router: Router) {
|
||
const { params, path, query } = tab;
|
||
const toParams = {
|
||
params: params || {},
|
||
path,
|
||
query: query || {},
|
||
};
|
||
await router.replace(toParams);
|
||
},
|
||
/**
|
||
* @zh_CN 添加标签页
|
||
* @param routeTab
|
||
*/
|
||
addTab(routeTab: TabDefinition) {
|
||
const tab = cloneTab(routeTab);
|
||
if (!isTabShown(tab)) {
|
||
return;
|
||
}
|
||
|
||
const tabIndex = this.tabs.findIndex((tab) => {
|
||
return getTabPath(tab) === getTabPath(routeTab);
|
||
});
|
||
|
||
if (tabIndex === -1) {
|
||
const maxCount = preferences.tabbar.maxCount;
|
||
// 获取动态路由打开数,超过 0 即代表需要控制打开数
|
||
const maxNumOfOpenTab = (routeTab?.meta?.maxNumOfOpenTab ??
|
||
-1) as number;
|
||
// 如果动态路由层级大于 0 了,那么就要限制该路由的打开数限制了
|
||
// 获取到已经打开的动态路由数, 判断是否大于某一个值
|
||
if (
|
||
maxNumOfOpenTab > 0 &&
|
||
this.tabs.filter((tab) => tab.name === routeTab.name).length >=
|
||
maxNumOfOpenTab
|
||
) {
|
||
// 关闭第一个
|
||
const index = this.tabs.findIndex(
|
||
(item) => item.name === routeTab.name,
|
||
);
|
||
index !== -1 && this.tabs.splice(index, 1);
|
||
} else if (maxCount > 0 && this.tabs.length >= maxCount) {
|
||
// 关闭第一个
|
||
const index = this.tabs.findIndex(
|
||
(item) =>
|
||
!Reflect.has(item.meta, 'affixTab') || !item.meta.affixTab,
|
||
);
|
||
index !== -1 && this.tabs.splice(index, 1);
|
||
}
|
||
this.tabs.push(tab);
|
||
} else {
|
||
// 页面已经存在,不重复添加选项卡,只更新选项卡参数
|
||
const currentTab = toRaw(this.tabs)[tabIndex];
|
||
const mergedTab = {
|
||
...currentTab,
|
||
...tab,
|
||
meta: { ...currentTab?.meta, ...tab.meta },
|
||
};
|
||
if (currentTab) {
|
||
const curMeta = currentTab.meta;
|
||
if (Reflect.has(curMeta, 'affixTab')) {
|
||
mergedTab.meta.affixTab = curMeta.affixTab;
|
||
}
|
||
if (Reflect.has(curMeta, 'newTabTitle')) {
|
||
mergedTab.meta.newTabTitle = curMeta.newTabTitle;
|
||
}
|
||
}
|
||
|
||
this.tabs.splice(tabIndex, 1, mergedTab);
|
||
}
|
||
this.updateCacheTabs();
|
||
},
|
||
/**
|
||
* @zh_CN 关闭所有标签页
|
||
*/
|
||
async closeAllTabs(router: Router) {
|
||
const newTabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||
this.tabs = newTabs.length > 0 ? newTabs : [...this.tabs].splice(0, 1);
|
||
await this._goToDefaultTab(router);
|
||
this.updateCacheTabs();
|
||
},
|
||
/**
|
||
* @zh_CN 关闭左侧标签页
|
||
* @param tab
|
||
*/
|
||
async closeLeftTabs(tab: TabDefinition) {
|
||
const index = this.tabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
|
||
if (index < 1) {
|
||
return;
|
||
}
|
||
|
||
const leftTabs = this.tabs.slice(0, index);
|
||
const paths: string[] = [];
|
||
|
||
for (const item of leftTabs) {
|
||
if (!isAffixTab(item)) {
|
||
paths.push(getTabPath(item));
|
||
}
|
||
}
|
||
await this._bulkCloseByPaths(paths);
|
||
},
|
||
/**
|
||
* @zh_CN 关闭其他标签页
|
||
* @param tab
|
||
*/
|
||
async closeOtherTabs(tab: TabDefinition) {
|
||
const closePaths = this.tabs.map((item) => getTabPath(item));
|
||
|
||
const paths: string[] = [];
|
||
|
||
for (const path of closePaths) {
|
||
if (path !== tab.fullPath) {
|
||
const closeTab = this.tabs.find((item) => getTabPath(item) === path);
|
||
if (!closeTab) {
|
||
continue;
|
||
}
|
||
if (!isAffixTab(closeTab)) {
|
||
paths.push(getTabPath(closeTab));
|
||
}
|
||
}
|
||
}
|
||
await this._bulkCloseByPaths(paths);
|
||
},
|
||
/**
|
||
* @zh_CN 关闭右侧标签页
|
||
* @param tab
|
||
*/
|
||
async closeRightTabs(tab: TabDefinition) {
|
||
const index = this.tabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
|
||
if (index !== -1 && index < this.tabs.length - 1) {
|
||
const rightTabs = this.tabs.slice(index + 1);
|
||
|
||
const paths: string[] = [];
|
||
for (const item of rightTabs) {
|
||
if (!isAffixTab(item)) {
|
||
paths.push(getTabPath(item));
|
||
}
|
||
}
|
||
await this._bulkCloseByPaths(paths);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 关闭标签页
|
||
* @param tab
|
||
* @param router
|
||
*/
|
||
async closeTab(tab: TabDefinition, router: Router) {
|
||
const { currentRoute } = router;
|
||
|
||
// 关闭不是激活选项卡
|
||
if (getTabPath(currentRoute.value) !== getTabPath(tab)) {
|
||
this._close(tab);
|
||
this.updateCacheTabs();
|
||
return;
|
||
}
|
||
const index = this.getTabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(currentRoute.value),
|
||
);
|
||
|
||
const before = this.getTabs[index - 1];
|
||
const after = this.getTabs[index + 1];
|
||
|
||
// 下一个tab存在,跳转到下一个
|
||
if (after) {
|
||
this._close(tab);
|
||
await this._goToTab(after, router);
|
||
// 上一个tab存在,跳转到上一个
|
||
} else if (before) {
|
||
this._close(tab);
|
||
await this._goToTab(before, router);
|
||
} else {
|
||
console.error('Failed to close the tab; only one tab remains open.');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 通过key关闭标签页
|
||
* @param key
|
||
* @param router
|
||
*/
|
||
async closeTabByKey(key: string, router: Router) {
|
||
const originKey = decodeURIComponent(key);
|
||
const index = this.tabs.findIndex(
|
||
(item) => getTabPath(item) === originKey,
|
||
);
|
||
if (index === -1) {
|
||
return;
|
||
}
|
||
|
||
const tab = this.tabs[index];
|
||
if (tab) {
|
||
await this.closeTab(tab, router);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 根据路径获取标签页
|
||
* @param path
|
||
*/
|
||
getTabByPath(path: string) {
|
||
return this.getTabs.find(
|
||
(item) => getTabPath(item) === path,
|
||
) as TabDefinition;
|
||
},
|
||
/**
|
||
* @zh_CN 新窗口打开标签页
|
||
* @param tab
|
||
*/
|
||
async openTabInNewWindow(tab: TabDefinition) {
|
||
openRouteInNewWindow(tab.fullPath || tab.path);
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 固定标签页
|
||
* @param tab
|
||
*/
|
||
async pinTab(tab: TabDefinition) {
|
||
const index = this.tabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
if (index !== -1) {
|
||
const oldTab = this.tabs[index];
|
||
tab.meta.affixTab = true;
|
||
tab.meta.title = oldTab?.meta?.title as string;
|
||
// this.addTab(tab);
|
||
this.tabs.splice(index, 1, tab);
|
||
}
|
||
// 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
|
||
const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||
// 获得固定tabs的index
|
||
const newIndex = affixTabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
// 交换位置重新排序
|
||
await this.sortTabs(index, newIndex);
|
||
},
|
||
|
||
/**
|
||
* 刷新标签页
|
||
*/
|
||
async refresh(router: Router | string) {
|
||
// 如果是Router路由,那么就根据当前路由刷新
|
||
// 如果是string字符串,为路由名称,则定向刷新指定标签页,不能是当前路由名称,否则不会刷新
|
||
if (typeof router === 'string') {
|
||
return await this.refreshByName(router);
|
||
}
|
||
|
||
const { currentRoute } = router;
|
||
const { name } = currentRoute.value;
|
||
|
||
this.excludeCachedTabs.add(name as string);
|
||
this.renderRouteView = false;
|
||
startProgress();
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
|
||
this.excludeCachedTabs.delete(name as string);
|
||
this.renderRouteView = true;
|
||
stopProgress();
|
||
},
|
||
|
||
/**
|
||
* 根据路由名称刷新指定标签页
|
||
*/
|
||
async refreshByName(name: string) {
|
||
this.excludeCachedTabs.add(name);
|
||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||
this.excludeCachedTabs.delete(name);
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 重置标签页标题
|
||
*/
|
||
async resetTabTitle(tab: TabDefinition) {
|
||
if (tab?.meta?.newTabTitle) {
|
||
return;
|
||
}
|
||
const findTab = this.tabs.find(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
if (findTab) {
|
||
findTab.meta.newTabTitle = undefined;
|
||
await this.updateCacheTabs();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 设置固定标签页
|
||
* @param tabs
|
||
*/
|
||
setAffixTabs(tabs: RouteRecordNormalized[]) {
|
||
for (const tab of tabs) {
|
||
tab.meta.affixTab = true;
|
||
this.addTab(routeToTab(tab));
|
||
}
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 更新菜单列表
|
||
* @param list
|
||
*/
|
||
setMenuList(list: string[]) {
|
||
this.menuList = list;
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 设置标签页标题
|
||
*
|
||
* @zh_CN 支持设置静态标题字符串或计算属性作为动态标题
|
||
* @zh_CN 当标题为计算属性时,标题会随计算属性值变化而自动更新
|
||
* @zh_CN 适用于需要根据状态或多语言动态更新标题的场景
|
||
*
|
||
* @param {TabDefinition} tab - 标签页对象
|
||
* @param {ComputedRef<string> | string} title - 标题内容,支持静态字符串或计算属性
|
||
*
|
||
* @example
|
||
* // 设置静态标题
|
||
* setTabTitle(tab, '新标签页');
|
||
*
|
||
* @example
|
||
* // 设置动态标题
|
||
* setTabTitle(tab, computed(() => t('common.dashboard')));
|
||
*/
|
||
async setTabTitle(tab: TabDefinition, title: ComputedRef<string> | string) {
|
||
const findTab = this.tabs.find(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
|
||
if (findTab) {
|
||
findTab.meta.newTabTitle = title;
|
||
|
||
await this.updateCacheTabs();
|
||
}
|
||
},
|
||
setUpdateTime() {
|
||
this.updateTime = Date.now();
|
||
},
|
||
/**
|
||
* @zh_CN 设置标签页顺序
|
||
* @param oldIndex
|
||
* @param newIndex
|
||
*/
|
||
async sortTabs(oldIndex: number, newIndex: number) {
|
||
const currentTab = this.tabs[oldIndex];
|
||
if (!currentTab) {
|
||
return;
|
||
}
|
||
this.tabs.splice(oldIndex, 1);
|
||
this.tabs.splice(newIndex, 0, currentTab);
|
||
this.dragEndIndex = this.dragEndIndex + 1;
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 切换固定标签页
|
||
* @param tab
|
||
*/
|
||
async toggleTabPin(tab: TabDefinition) {
|
||
const affixTab = tab?.meta?.affixTab ?? false;
|
||
|
||
await (affixTab ? this.unpinTab(tab) : this.pinTab(tab));
|
||
},
|
||
|
||
/**
|
||
* @zh_CN 取消固定标签页
|
||
* @param tab
|
||
*/
|
||
async unpinTab(tab: TabDefinition) {
|
||
const index = this.tabs.findIndex(
|
||
(item) => getTabPath(item) === getTabPath(tab),
|
||
);
|
||
|
||
if (index !== -1) {
|
||
const oldTab = this.tabs[index];
|
||
tab.meta.affixTab = false;
|
||
tab.meta.title = oldTab?.meta?.title as string;
|
||
// this.addTab(tab);
|
||
this.tabs.splice(index, 1, tab);
|
||
}
|
||
// 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
|
||
const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||
// 获得固定tabs的index,使用固定tabs的下一个位置也就是活动tabs的第一个位置
|
||
const newIndex = affixTabs.length;
|
||
// 交换位置重新排序
|
||
await this.sortTabs(index, newIndex);
|
||
},
|
||
/**
|
||
* 根据当前打开的选项卡更新缓存
|
||
*/
|
||
async updateCacheTabs() {
|
||
const cacheMap = new Set<string>();
|
||
|
||
for (const tab of this.tabs) {
|
||
// 跳过不需要持久化的标签页
|
||
const keepAlive = tab.meta?.keepAlive;
|
||
if (!keepAlive) {
|
||
continue;
|
||
}
|
||
(tab.matched || []).forEach((t, i) => {
|
||
if (i > 0) {
|
||
cacheMap.add(t.name as string);
|
||
}
|
||
});
|
||
|
||
const name = tab.name as string;
|
||
cacheMap.add(name);
|
||
}
|
||
this.cachedTabs = cacheMap;
|
||
},
|
||
},
|
||
getters: {
|
||
affixTabs(): TabDefinition[] {
|
||
const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||
|
||
return affixTabs.sort((a, b) => {
|
||
const orderA = (a.meta?.affixTabOrder ?? 0) as number;
|
||
const orderB = (b.meta?.affixTabOrder ?? 0) as number;
|
||
return orderA - orderB;
|
||
});
|
||
},
|
||
getCachedTabs(): string[] {
|
||
return [...this.cachedTabs];
|
||
},
|
||
getExcludeCachedTabs(): string[] {
|
||
return [...this.excludeCachedTabs];
|
||
},
|
||
getMenuList(): string[] {
|
||
return this.menuList;
|
||
},
|
||
getTabs(): TabDefinition[] {
|
||
const normalTabs = this.tabs.filter((tab) => !isAffixTab(tab));
|
||
return [...this.affixTabs, ...normalTabs].filter(Boolean);
|
||
},
|
||
},
|
||
persist: [
|
||
// tabs不需要保存在localStorage
|
||
{
|
||
pick: ['tabs'],
|
||
storage: sessionStorage,
|
||
},
|
||
],
|
||
state: (): TabbarState => ({
|
||
cachedTabs: new Set(),
|
||
dragEndIndex: 0,
|
||
excludeCachedTabs: new Set(),
|
||
menuList: [
|
||
'close',
|
||
'affix',
|
||
'maximize',
|
||
'reload',
|
||
'open-in-new-window',
|
||
'close-left',
|
||
'close-right',
|
||
'close-other',
|
||
'close-all',
|
||
],
|
||
renderRouteView: true,
|
||
tabs: [],
|
||
updateTime: Date.now(),
|
||
}),
|
||
});
|
||
|
||
// 解决热更新问题
|
||
const hot = import.meta.hot;
|
||
if (hot) {
|
||
hot.accept(acceptHMRUpdate(useTabbarStore, hot));
|
||
}
|
||
|
||
/**
|
||
* @zh_CN 克隆路由,防止路由被修改
|
||
* @param route
|
||
*/
|
||
function cloneTab(route: TabDefinition): TabDefinition {
|
||
if (!route) {
|
||
return route;
|
||
}
|
||
const { matched, meta, ...opt } = route;
|
||
return {
|
||
...opt,
|
||
matched: (matched
|
||
? matched.map((item) => ({
|
||
meta: item.meta,
|
||
name: item.name,
|
||
path: item.path,
|
||
}))
|
||
: undefined) as RouteRecordNormalized[],
|
||
meta: {
|
||
...meta,
|
||
newTabTitle: meta.newTabTitle,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @zh_CN 是否是固定标签页
|
||
* @param tab
|
||
*/
|
||
function isAffixTab(tab: TabDefinition) {
|
||
return tab?.meta?.affixTab ?? false;
|
||
}
|
||
|
||
/**
|
||
* @zh_CN 是否显示标签
|
||
* @param tab
|
||
*/
|
||
function isTabShown(tab: TabDefinition) {
|
||
const matched = tab?.matched ?? [];
|
||
return !tab.meta.hideInTab && matched.every((item) => !item.meta.hideInTab);
|
||
}
|
||
|
||
/**
|
||
* @zh_CN 获取标签页路径
|
||
* @param tab
|
||
*/
|
||
function getTabPath(tab: RouteRecordNormalized | TabDefinition) {
|
||
return decodeURIComponent((tab as TabDefinition).fullPath || tab.path);
|
||
}
|
||
|
||
function routeToTab(route: RouteRecordNormalized) {
|
||
return {
|
||
meta: route.meta,
|
||
name: route.name,
|
||
path: route.path,
|
||
} as TabDefinition;
|
||
}
|