重构 Mask

This commit is contained in:
陈华春
2023-09-06 16:17:51 +08:00
parent ce68ecde7f
commit e58b99ceb8
14 changed files with 277 additions and 196 deletions

View File

@@ -53,16 +53,29 @@
}
]
},
{
"id": "home",
"title": "首页工作台",
"type": "dialog",
"url": "/ui/mask"
},
{
"id": "test",
"title": "测试无图标",
"title": "测试",
"children": [
{
"id": "test_1",
"title": "测试无图标",
"title": "测试弹窗打开",
"disabled": false,
"type": "route",
"type": "dialog",
"url": "/ui/mask/page?id=test_1"
},
{
"id": "test_2",
"title": "测试新开窗口",
"disabled": false,
"type": "window",
"url": "https://www.baidu.com/"
}
]
},

View File

@@ -2,10 +2,9 @@
<XMask
:menus="menus"
:favorites="favorites"
manual
@select="onMenuSelect"
:menuAdapter="menuAdapter"
:defaultActiveMenu="defaultActiveMenu"
homepage="/ui/mask"
home="/ui/mask"
:tabs="20"
:actions="actions"
theme
@@ -40,11 +39,14 @@
}).then((res) => res.data);
};
const onMenuSelect = (menu: MenuDataItem) => {
router.push({
path: '/ui/mask/page',
query: { id: menu.id }
});
const menuAdapter = (menu: MenuDataItem) => {
return {
...menu,
url:
!menu.type || menu.type === 'route'
? `/ui/mask/page?id=${menu.id}`
: menu.url
};
};
const defaultActiveMenu = (

View File

@@ -11,25 +11,26 @@
import { ref } from 'vue';
import { ElInput, ElButton } from 'element-plus';
import { useRoute, useRouter } from 'vue-router';
import { useMask } from '@vtj/ui';
import { useMask, MaskTab } from '@vtj/ui';
const route = useRoute();
const router = useRouter();
const inputValue = ref('');
const mask = useMask();
const onClick = () => {
router.push('/ui/mask/subpage');
mask.exposed?.addTab({
menu: {
id: 'aaaaa',
title: '测试'
},
closable: true
});
};
defineOptions({
tabAdapter() {
console.log('component tabAdapter');
}
name: 'InnerPage'
});
const defineTab = async () => {
return {
// title: '自定义标签'
};
};
defineExpose({
defineTab
});
</script>

View File

@@ -2,10 +2,16 @@
<div>Subpage</div>
</template>
<script type="ts" setup>
import {useMask} from '@vtj/ui'
<script lang="ts" setup>
import { useMask } from '@vtj/ui';
const defineTab = () => {
return {
title: '二级页面'
};
};
// const mask = useMask();
// mask.exposed.openTab('/ui/mask/subpage', {title:'测试跳转'});
// console.log('mask', mask)
defineExpose({
defineTab
});
</script>

View File

@@ -10,13 +10,13 @@ const packagesPath = resolve('../../packages');
const alias = {
// '@vtj/utils': join(packagesPath, 'utils/src/index.ts'),
// '@vtj/ui/lib/style.css': join(packagesPath, 'ui/src/style/index.scss'),
'@vtj/ui/lib/style.css': join(packagesPath, 'ui/src/style/index.scss'),
// '@vtj/engine/lib/style.css': join(
// packagesPath,
// 'engine/src/style/index.scss'
// ),
// '@vtj/icons/lib/style.css': join(packagesPath, 'icons/src/style.scss'),
// '@vtj/ui': join(packagesPath, 'ui/src'),
'@vtj/ui': join(packagesPath, 'ui/src')
// '@vtj/icons': join(packagesPath, 'icons/src'),
// '@vtj/engine': join(packagesPath, 'engine/src'),
// '@vtj/runtime': join(packagesPath, 'runtime/src')

View File

@@ -6,7 +6,7 @@
<Brand
:logo="props.logo"
:title="props.title"
:url="props.homepage"
:url="homeTab.url"
:collapsed="collapsed"></Brand>
</template>
<SwitchBar
@@ -21,7 +21,7 @@
:flatMenus="flatMenus"
:menus="menus"
:active="active"
@select="selectMenu"></Menu>
@select="select"></Menu>
</Sidebar>
<XContainer class="x-mask__main" grow flex direction="column">
<XContainer
@@ -32,14 +32,14 @@
ref="tabRef"
:favorites="favorites"
:tabs="showTabs"
:isActiveTab="isActiveTab"
:home="homeTab"
:value="active?.id"
@click="activeTab"
@home="activeHome"
:value="currentTabValue"
:isActiveTab="isActiveTab"
@click="onTabClick"
@remove="removeTab"
@refresh="onRefresh"
@toggleFavorite="toggleFavorite"></Tabs>
@toggleFavorite="toggleFavorite"
@dialog="onTabDialog"></Tabs>
<Toolbar
:tabs="dropdownTabs"
:actions="props.actions"
@@ -80,11 +80,7 @@
nextTick,
ref
} from 'vue';
import {
useRoute,
useRouter,
RouteLocationNormalizedLoaded
} from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { XContainer, MenuDataItem, ActionMenuItem, ActionProps } from '../';
import Sidebar from './components/Sidebar.vue';
import SwitchBar from './components/SwitchBar.vue';
@@ -94,7 +90,13 @@
import Toolbar from './components/Toolbar.vue';
import Avatar from './components/Avatar.vue';
import Content from './components/Content.vue';
import { maskProps, MASK_INSTANCE_KEY, MaskEmits } from './types';
import {
maskProps,
MASK_INSTANCE_KEY,
MaskEmits,
MaskTab,
MaskDefineTab
} from './types';
import { useMenus, useTabs, useFavorites } from './use';
defineOptions({
@@ -116,8 +118,8 @@
search,
select,
active,
homeMenu
} = useMenus(props, emit, router);
getMenuByUrl
} = useMenus(props, emit);
const { favorites, toggleFavorite } = useFavorites(props);
@@ -128,56 +130,47 @@
dropdownTabs,
isActiveTab,
removeTab,
activeTab,
activeHome,
addTab,
removeAllTabs,
removeOtherTabs,
moveToShow
} = useTabs(props, emit, router, active, select, homeMenu);
moveToShow,
currentTabValue,
isHomeTab
} = useTabs(props, emit);
const selectMenu = async (menu: MenuDataItem) => {
select(menu);
const { type = 'route' } = menu;
if (type === 'route') {
const init = async (menus?: MenuDataItem[]) => {
if (!menus || !menus.length) return;
const fullPath = route.fullPath;
const menu = getMenuByUrl(fullPath);
if (isHomeTab(fullPath)) {
currentTabValue.value = fullPath;
} else {
await nextTick();
addTab({
menu,
closable: false
});
const { url = fullPath, icon, title = '新建标签页' } = menu || {};
const view = contentRef.value.getCacheComponent(fullPath);
const defineTab = view?.exposed?.defineTab as MaskDefineTab;
const tab: MaskTab = Object.assign(
{ url, icon, title, menu },
defineTab ? await defineTab() : {}
);
addTab(tab);
}
if (menu) {
await nextTick();
active.value = menu;
}
};
const defaultActiveMenu =
props.defaultActiveMenu ??
((to: RouteLocationNormalizedLoaded) => {
return flatMenus.value.find((n) => n.url === to.fullPath);
});
watch(
flatMenus,
() => {
if (!flatMenus.value.length) return;
const current = defaultActiveMenu(route, flatMenus.value);
if (current) {
selectMenu(current);
} else {
active.value = null;
}
},
{ immediate: true }
);
watch(flatMenus, init, { immediate: true });
watch(
() => route.fullPath,
() => {
console.log('route change', route.fullPath);
},
{
immediate: true
}
() => init(flatMenus.value)
);
const onTabClick = (tab: MaskTab) => {
router.push(tab.url).catch((e) => e);
};
const onActionClick = (action: ActionProps) => {
emit('actionClick', action);
};
@@ -190,6 +183,10 @@
contentRef.value.refresh();
};
const onTabDialog = (tab: MaskTab) => {
console.log('open dialog', tab);
};
provide(MASK_INSTANCE_KEY, instance as ComponentInternalInstance);
defineExpose({
@@ -198,7 +195,7 @@
flatMenus,
favorites,
active,
selectMenu,
select,
addTab
});
</script>

View File

@@ -1,28 +1,24 @@
<template>
<XContainer class="x-mask__content" :flex="false" grow padding>
<slot></slot>
<KeepAlive :key="aliveKey">
<Suspense>
<RouterView ref="viewRef" :key="viewKey"></RouterView>
</Suspense>
</KeepAlive>
<RouterView v-slot="{ Component, route }">
<KeepAlive ref="aliveRef">
<component v-if="aliveKey" :is="Component" :key="route.fullPath" />
</KeepAlive>
</RouterView>
</XContainer>
</template>
<script lang="ts" setup>
import { KeepAlive, Suspense, computed, ref } from 'vue';
import { KeepAlive } from 'vue';
import { RouterView } from 'vue-router';
import { XContainer } from '../../';
import { RouterView, useRoute } from 'vue-router';
const route = useRoute();
const viewKey = computed(() => route.fullPath);
import { useViewCache } from '../use';
const aliveKey = ref(Symbol());
const viewRef = ref();
const refresh = () => {
aliveKey.value = Symbol();
};
const { aliveKey, aliveRef, refresh, getCacheComponent } = useViewCache();
defineExpose({
viewRef,
getCacheComponent,
refresh
});
</script>

View File

@@ -12,23 +12,21 @@
:model-value="props.value"
@tab-remove="onTabRemove"
@tab-click="onTabClick">
<ElTabPane v-if="props.home" :name="props.home.menu.id">
<ElTabPane v-if="props.home" :name="props.home.url">
<template #label>
<div class="x-mask-tabs__trigger">
<component
v-if="props.home.menu.icon"
:is="(useIcon(props.home.menu.icon) as any)"></component>
v-if="props.home.icon"
:is="(useIcon(props.home.icon) as any)"></component>
<span v-if="props.home.menu.title">{{
props.home.menu.title
}}</span>
<span v-if="props.home.title">{{ props.home.title }}</span>
</div>
</template>
</ElTabPane>
<ElTabPane
v-for="tab in props.tabs"
:key="`tab_${tab.menu.id}`"
:name="tab.menu.id"
:key="tab.id || tab.url"
:name="tab.url"
lazy
closable>
<template #label>
@@ -40,10 +38,10 @@
<template #reference>
<div class="x-mask-tabs__trigger">
<component
v-if="tab.menu.icon"
:is="(useIcon(tab.menu.icon) as any)"></component>
v-if="tab.icon"
:is="(useIcon(tab.icon) as any)"></component>
<span v-if="tab.menu.title">{{ tab.menu.title }}</span>
<span v-if="tab.title">{{ tab.title }}</span>
</div>
</template>
<XActionBar
@@ -89,14 +87,14 @@
const emit = defineEmits<{
click: [tab: MaskTab];
remove: [tab: MaskTab];
home: [];
refresh: [];
toggleFavorite: [item: MenuDataItem];
dialog: [tab: MaskTab];
}>();
const getActions = (tab: MaskTab) => {
const isFav = !!props.favorites.find(
(n) => n === tab.menu || n.id === tab.menu.id
(n) => n === tab.menu || n.id === tab.menu?.id
);
return [
{
@@ -109,31 +107,33 @@
icon: isFav ? StarFilled : Star,
label: '收藏',
name: 'favorite',
value: tab.menu
value: tab.menu,
disabled: !tab.menu
},
'|',
{
icon: CopyDocument,
label: '弹窗',
name: 'dialog'
name: 'dialog',
value: tab
}
] as ActionBarItems;
};
const onTabClick = (pane: TabsPaneContext) => {
const name = pane.paneName;
if (name === props.home.menu.id) {
emit('home');
if (name === props.home.url) {
emit('click', props.home);
return;
}
const tab = props.tabs.find((n) => n.menu.id === name);
const tab = props.tabs.find((n) => n.url === name);
if (tab) {
emit('click', tab);
}
};
const onTabRemove = (name: string | number) => {
const tab = props.tabs.find((n) => n.menu.id === name);
const tab = props.tabs.find((n) => n.url === name);
if (tab) {
emit('remove', tab);
}
@@ -148,6 +148,7 @@
emit('toggleFavorite', item.value as MenuDataItem);
break;
case 'dialog':
emit('dialog', item.value as MaskTab);
break;
}

View File

@@ -72,7 +72,7 @@
const menus = props.tabs.map((n, i) => {
return {
divided: i === 0,
label: n.menu.title,
label: n.title,
command: n
};
});

View File

@@ -4,7 +4,8 @@ import {
InjectionKey,
ShallowReactive,
ComputedRef,
ComponentInternalInstance
ComponentInternalInstance,
DefineComponent
} from 'vue';
import { RouteLocationNormalizedLoaded } from 'vue-router';
@@ -61,11 +62,12 @@ export const maskProps = {
}
},
/**
* 手动处理菜单打开设置true需要自行侦听 menu-select 事件实现菜单打开页面
* 菜单项数据适配函数,用作转换菜单项数据
*/
manual: {
type: Boolean
menuAdapter: {
type: Function as PropType<(menu: MenuDataItem) => MenuDataItem>
},
/**
* 设置初始化选中菜单函数
*/
@@ -78,10 +80,10 @@ export const maskProps = {
>
},
/**
* 主页路由路径
* 主页Tab配置
*/
homepage: {
type: String,
home: {
type: [String, Object] as PropType<string | MaskTab>,
default: '/'
},
@@ -141,7 +143,19 @@ export type MaskSlots = {
};
export interface MaskTab {
menu: MenuDataItem;
id?: symbol;
// 页面路由
url: string;
// 标题文本
title?: string;
// 图标
icon?: string | Record<string, any> | DefineComponent<any, any, any, any>;
// 能否关闭
closable?: boolean;
// 弹窗模式
dialog?: boolean;
// 关联菜单项
menu?: MenuDataItem;
}
export type MaskDefineTab = () => Partial<MaskTab> | Promise<Partial<MaskTab>>;

View File

@@ -1,3 +1,4 @@
export * from './useMenus';
export * from './useTabs';
export * from './useFavorites';
export * from './useViewCache';

View File

@@ -1,40 +1,38 @@
import { shallowRef, computed, ref } from 'vue';
import { Router } from 'vue-router';
import { useRouter } from 'vue-router';
import { MaskProps, MaskEmits } from '../types';
import { MenuDataItem, Emits, createDialog } from '../../';
import { HomeFilled } from '@element-plus/icons-vue';
import { arrayToMap } from '@vtj/utils';
const HOME_ID = '__vtj__home__';
export function useMenus(
props: MaskProps,
emit: Emits<MaskEmits>,
router: Router
function toFlat(
array: MenuDataItem[],
menuAdapter?: (menu: MenuDataItem) => MenuDataItem
) {
const homeMenu: MenuDataItem = {
id: HOME_ID,
icon: HomeFilled,
url: props.homepage
};
let result: MenuDataItem[] = [];
array.forEach((n) => {
n = menuAdapter ? menuAdapter(n) : n;
if (!n.children) {
result.push(n);
} else {
result = result.concat(toFlat(n.children, menuAdapter));
}
});
return result;
}
export function useMenus(props: MaskProps, emit: Emits<MaskEmits>) {
const router = useRouter();
const collapsed = ref(false);
const favorite = ref(false);
const keyword = ref('');
const menus = shallowRef<MenuDataItem[]>([]);
const active = ref<MenuDataItem | null>(homeMenu);
const toFlat = (array: MenuDataItem[]) => {
let result: MenuDataItem[] = [];
array.forEach((n) => {
if (!n.children) {
result.push(n);
} else {
result = result.concat(toFlat(n.children));
}
});
return result;
};
const active = ref<MenuDataItem | null>(null);
const flatMenus = computed(() => toFlat(menus.value, props.menuAdapter));
const menusMap = computed(() => arrayToMap(flatMenus.value, 'id'));
const flatMenus = computed(() => toFlat(menus.value));
const getMenuByUrl = (url: string) => {
return flatMenus.value.find((n) => n.url === url);
};
(async () => {
menus.value =
@@ -48,12 +46,14 @@ export function useMenus(
};
const select = (menu: MenuDataItem) => {
const { type = 'route', url, title, icon } = menu;
const activeMenu = menusMap.value.get(menu.id);
if (!activeMenu) return;
const { type = 'route', url, title, icon } = activeMenu;
emit('select', menu);
if (type === 'route') {
active.value = menu;
active.value = activeMenu;
}
if (props.manual || !url) return;
if (!url) return;
if (type === 'route' && router) {
if (
url.startsWith('https:') ||
@@ -85,9 +85,10 @@ export function useMenus(
keyword,
menus,
flatMenus,
menusMap,
search,
select,
active,
homeMenu
getMenuByUrl
};
}

View File

@@ -1,31 +1,35 @@
import { shallowRef, watch, computed, ref, Ref } from 'vue';
import { shallowRef, computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MaskProps, MaskEmits, TAB_ITEM_WIDTH, MaskTab } from '../types';
import { MenuDataItem, Emits } from '../../';
import { Emits } from '../../';
import { useElementSize } from '@vueuse/core';
import { ElMessageBox } from 'element-plus';
import type { Router, RouteLocationRaw } from 'vue-router';
import { HomeFilled } from '@element-plus/icons-vue';
export function useTabs(
props: MaskProps,
emit: Emits<MaskEmits>,
router: Router,
active: Ref<MenuDataItem | null | undefined>,
select: (menu: MenuDataItem) => void,
homeMenu: MenuDataItem
) {
export function useTabs(props: MaskProps, emit: Emits<MaskEmits>) {
const route = useRoute();
const router = useRouter();
const homeTab = computed<MaskTab>(() => {
return {
menu: homeMenu,
closable: false
};
return typeof props.home === 'string'
? {
url: props.home,
icon: HomeFilled,
closable: false
}
: ({
...props.home,
closable: false
} as MaskTab);
});
// tabs 组件引用
const tabRef = ref();
const { width } = useElementSize(tabRef);
// tabs数据项
const tabs = shallowRef<MaskTab[]>([]);
// 当前激活得Tab菜单id
const tabsValue = computed(() => active.value?.id || homeMenu.id);
// 当前激活的Tab name
const currentTabValue = ref<string | undefined>();
// baner上可以展示的tab数量
const showCount = computed(() => Math.floor(width.value / TAB_ITEM_WIDTH));
// banner 上的tabs项
@@ -35,28 +39,32 @@ export function useTabs(
// 判断是否激活的tab
const isActiveTab = (tab: MaskTab) => {
return tabsValue.value === tab.menu.id;
return route.fullPath === tab.url;
};
// 判断两个tab项是否相等
const isEqual = (a: MaskTab, b: MaskTab) => {
return a === b || a.menu.id === b.menu.id;
return a === b || a.url === b.url;
};
const isExistTab = (url: string) => {
return !!tabs.value.find((n) => n.url === url);
};
const isHomeTab = (url: string) => {
return homeTab.value.url === url;
};
// 切换tab
const activeTab = (tab: MaskTab) => {
if (!isActiveTab(tab)) {
select(tab.menu);
}
currentTabValue.value = tab.url;
router.push(currentTabValue.value).catch((e) => e);
};
// 切换到首页
const activeHome = () => {
active.value = homeTab.value.menu;
const url = homeTab.value.menu.url;
if (url) {
router.push(url).catch((e) => e);
}
currentTabValue.value = homeTab.value.url;
router.push(currentTabValue.value).catch((e) => e);
};
// 新增tab
@@ -70,6 +78,19 @@ export function useTabs(
}
};
const updateTab = (tab: MaskTab) => {
const index = tabs.value.findIndex((n) => isEqual(n, tab));
if (index >= 0) {
const match = tabs.value[index];
tabs.value = [...tabs.value].splice(
index,
1,
Object.assign(match, tab, { id: Symbol() })
);
}
};
// 下拉菜单的tab 移动到 banner 并激活
const moveToShow = (tab: MaskTab) => {
const items = tabs.value.filter((n) => !isEqual(n, tab));
@@ -85,7 +106,8 @@ export function useTabs(
if (!ret) return;
tabs.value = tabs.value.filter((n) => !isEqual(n, tab));
// 删除的是激活tab
if (active.value?.id === tab.menu.id) {
if (currentTabValue.value === tab.url) {
const first = tabs.value[0];
first ? activeTab(first) : activeHome();
}
@@ -107,25 +129,16 @@ export function useTabs(
type: 'warning'
}).catch((e) => false);
if (!ret) return;
tabs.value = tabs.value.filter((n) => n.menu.id === active.value?.id);
tabs.value = tabs.value.filter((n) => n.url === currentTabValue.value);
};
// const openTab = (to: RouteLocationRaw, menu: MenuDataItem) => {
// const tab: MaskTab = {
// menu: { ...menu, id: Date.now() },
// closable: true
// };
// router.push(to);
// // addTab(tab);
// };
return {
tabRef,
homeTab,
tabs,
showTabs,
dropdownTabs,
tabsValue,
currentTabValue,
isActiveTab,
addTab,
removeTab,
@@ -133,7 +146,10 @@ export function useTabs(
activeHome,
activeTab,
removeAllTabs,
removeOtherTabs
removeOtherTabs,
updateTab,
isExistTab,
isHomeTab
// openTab
};
}

View File

@@ -0,0 +1,33 @@
import { ref, nextTick, ComponentInternalInstance, onMounted } from 'vue';
import { useRoute } from 'vue-router';
export function useViewCache() {
const aliveRef = ref();
const aliveKey = ref<any>(Symbol());
const viewInstance = ref<ComponentInternalInstance | null>(null);
const route = useRoute();
const refresh = async () => {
aliveKey.value = undefined;
const caches = aliveRef.value._.__v_cache as Map<string, any>;
caches.delete(route.fullPath);
await nextTick();
aliveKey.value = Symbol();
};
const getCacheComponent = (key: string) => {
const caches = aliveRef.value._.__v_cache as Map<string, any>;
const match = caches.get(key);
if (match) {
return match.component as ComponentInternalInstance;
}
return null;
};
return {
aliveKey,
aliveRef,
refresh,
getCacheComponent,
viewInstance
};
}