refactor: use tanstack query to refactor menu related data fetching (#3714)

#### What type of PR is this?

/kind improvement

#### What this PR does / why we need it:

使用 [TanStack Query](https://github.com/TanStack/query) 重构菜单和菜单项的逻辑,修复菜单项和菜单关联可能发现混乱的问题。

#### Which issue(s) this PR fixes:

Ref https://github.com/halo-dev/halo/issues/3360
Fixed https://github.com/halo-dev/halo/issues/3467

#### Special notes for your reviewer:

测试方式:

1. 创建若干菜单和菜单项,检查功能是否正常。

#### Does this PR introduce a user-facing change?

```release-note
修复 Console 端菜单项和菜单关联可能发现混乱的问题。
```
pull/3756/head^2
Ryan Wang 2023-04-16 23:12:13 +08:00 committed by GitHub
parent 8755c24b11
commit eb141e4966
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 107 additions and 156 deletions

View File

@ -14,7 +14,7 @@ import {
import MenuItemEditingModal from "./components/MenuItemEditingModal.vue"; import MenuItemEditingModal from "./components/MenuItemEditingModal.vue";
import MenuItemListItem from "./components/MenuItemListItem.vue"; import MenuItemListItem from "./components/MenuItemListItem.vue";
import MenuList from "./components/MenuList.vue"; import MenuList from "./components/MenuList.vue";
import { onUnmounted, ref } from "vue"; import { computed, ref } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { Menu, MenuItem } from "@halo-dev/api-client"; import type { Menu, MenuItem } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
@ -27,68 +27,49 @@ import {
resetMenuItemsTreePriority, resetMenuItemsTreePriority,
} from "./utils"; } from "./utils";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient();
const menuItems = ref<MenuItem[]>([] as MenuItem[]);
const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]); const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]);
const selectedMenu = ref<Menu>(); const selectedMenu = ref<Menu>();
const selectedMenuItem = ref<MenuItem>(); const selectedMenuItem = ref<MenuItem>();
const selectedParentMenuItem = ref<MenuItem>(); const selectedParentMenuItem = ref<MenuItem>();
const loading = ref(false);
const menuListRef = ref();
const menuItemEditingModal = ref(); const menuItemEditingModal = ref();
const refreshInterval = ref();
const handleFetchMenuItems = async (options?: { mute?: boolean }) => {
try {
clearInterval(refreshInterval.value);
if (!options?.mute) {
loading.value = true;
}
const {
data: menuItems,
isLoading,
refetch,
} = useQuery<MenuItem[]>({
queryKey: ["menu-items", selectedMenu],
queryFn: async () => {
if (!selectedMenu.value?.spec.menuItems) { if (!selectedMenu.value?.spec.menuItems) {
return; return [];
} }
const menuItemNames = Array.from(selectedMenu.value.spec.menuItems)?.map(
(item) => item const menuItemNames = selectedMenu.value.spec.menuItems.filter(Boolean);
);
const { data } = await apiClient.extension.menuItem.listv1alpha1MenuItem({ const { data } = await apiClient.extension.menuItem.listv1alpha1MenuItem({
page: 0, page: 0,
size: 0, size: 0,
fieldSelector: [`name=(${menuItemNames.join(",")})`], fieldSelector: [`name=(${menuItemNames.join(",")})`],
}); });
menuItems.value = data.items;
// Build the menu tree
menuTreeItems.value = buildMenuItemsTree(data.items);
const deletedMenuItems = menuItems.value.filter( return data.items;
},
onSuccess(data) {
menuTreeItems.value = buildMenuItemsTree(data);
},
refetchOnWindowFocus: false,
refetchInterval(data) {
const deletingMenuItems = data?.filter(
(menuItem) => !!menuItem.metadata.deletionTimestamp (menuItem) => !!menuItem.metadata.deletionTimestamp
); );
return deletingMenuItems?.length ? 3000 : false;
if (deletedMenuItems.length) { },
refreshInterval.value = setInterval(() => { enabled: computed(() => !!selectedMenu.value),
handleFetchMenuItems({ mute: true });
}, 3000);
}
await handleResetMenuItems();
} catch (e) {
console.error("Failed to fetch menu items", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
}); });
const handleOpenEditingModal = (menuItem: MenuTreeItem) => { const handleOpenEditingModal = (menuItem: MenuTreeItem) => {
@ -120,11 +101,10 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
menuToUpdate && menuToUpdate &&
!menuToUpdate.spec.menuItems?.includes(menuItem.metadata.name) !menuToUpdate.spec.menuItems?.includes(menuItem.metadata.name)
) { ) {
if (menuToUpdate.spec.menuItems) { menuToUpdate.spec.menuItems = [
menuToUpdate.spec.menuItems.push(menuItem.metadata.name); ...(menuToUpdate.spec.menuItems || []),
} else { menuItem.metadata.name,
menuToUpdate.spec.menuItems = [menuItem.metadata.name]; ];
}
await apiClient.extension.menu.updatev1alpha1Menu({ await apiClient.extension.menu.updatev1alpha1Menu({
name: menuToUpdate.metadata.name, name: menuToUpdate.metadata.name,
@ -132,8 +112,8 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
}); });
} }
await menuListRef.value.handleFetchMenus(); await queryClient.invalidateQueries({ queryKey: ["menus"] });
await handleFetchMenuItems({ mute: true }); await refetch();
}; };
const handleUpdateInBatch = useDebounceFn(async () => { const handleUpdateInBatch = useDebounceFn(async () => {
@ -150,8 +130,8 @@ const handleUpdateInBatch = useDebounceFn(async () => {
} catch (e) { } catch (e) {
console.error("Failed to update menu items", e); console.error("Failed to update menu items", e);
} finally { } finally {
await menuListRef.value.handleFetchMenus(); await queryClient.invalidateQueries({ queryKey: ["menus"] });
await handleFetchMenuItems({ mute: true }); await refetch();
} }
}, 300); }, 300);
@ -178,32 +158,25 @@ const handleDelete = async (menuItem: MenuTreeItem) => {
await Promise.all(deleteChildrenRequests); await Promise.all(deleteChildrenRequests);
} }
await handleFetchMenuItems(); await refetch();
Toast.success(t("core.common.toast.delete_success"));
},
});
};
const handleResetMenuItems = async () => {
if (!selectedMenu.value) {
return;
}
// update items under menu
const menuToUpdate = cloneDeep(selectedMenu.value); const menuToUpdate = cloneDeep(selectedMenu.value);
if (menuToUpdate) {
const menuItemNames = menuItems.value.map((menuItem) => { menuToUpdate.spec.menuItems = menuToUpdate.spec.menuItems?.filter(
return menuItem.metadata.name; (name) => ![menuItem.metadata.name, ...childrenNames].includes(name)
}); );
menuToUpdate.spec.menuItems = menuItemNames;
await apiClient.extension.menu.updatev1alpha1Menu({ await apiClient.extension.menu.updatev1alpha1Menu({
name: menuToUpdate.metadata.name, name: menuToUpdate.metadata.name,
menu: menuToUpdate, menu: menuToUpdate,
}); });
}
await menuListRef.value.handleFetchMenus({ mute: true }); await queryClient.invalidateQueries({ queryKey: ["menus"] });
Toast.success(t("core.common.toast.delete_success"));
},
});
}; };
</script> </script>
<template> <template>
@ -223,11 +196,7 @@ const handleResetMenuItems = async () => {
<div class="m-0 md:m-4"> <div class="m-0 md:m-4">
<div class="flex flex-col gap-4 sm:flex-row"> <div class="flex flex-col gap-4 sm:flex-row">
<div class="w-96"> <div class="w-96">
<MenuList <MenuList v-model:selected-menu="selectedMenu" @select="refetch()" />
ref="menuListRef"
v-model:selected-menu="selectedMenu"
@select="handleFetchMenuItems()"
/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<VCard :body-class="['!p-0']"> <VCard :body-class="['!p-0']">
@ -256,15 +225,15 @@ const handleResetMenuItems = async () => {
</div> </div>
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!menuItems.length" appear name="fade"> <Transition v-else-if="!menuItems?.length" appear name="fade">
<VEmpty <VEmpty
:message="$t('core.menu.menu_item_empty.message')" :message="$t('core.menu.menu_item_empty.message')"
:title="$t('core.menu.menu_item_empty.title')" :title="$t('core.menu.menu_item_empty.title')"
> >
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchMenuItems()"> <VButton @click="refetch()">
{{ $t("core.common.buttons.refresh") }} {{ $t("core.common.buttons.refresh") }}
</VButton> </VButton>
<VButton <VButton

View File

@ -14,23 +14,23 @@ import {
VDropdownItem, VDropdownItem,
} from "@halo-dev/components"; } from "@halo-dev/components";
import MenuEditingModal from "./MenuEditingModal.vue"; import MenuEditingModal from "./MenuEditingModal.vue";
import { onMounted, onUnmounted, ref } from "vue"; import { onMounted, ref } from "vue";
import type { Menu } from "@halo-dev/api-client"; import type { Menu } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
selectedMenu: Menu | null; selectedMenu?: Menu;
}>(), }>(),
{ {
selectedMenu: null, selectedMenu: undefined,
} }
); );
@ -39,56 +39,39 @@ const emit = defineEmits<{
(event: "update:selectedMenu", menu: Menu): void; (event: "update:selectedMenu", menu: Menu): void;
}>(); }>();
const menus = ref<Menu[]>([] as Menu[]);
const loading = ref(false);
const selectedMenuToUpdate = ref<Menu>(); const selectedMenuToUpdate = ref<Menu>();
const menuEditingModal = ref<boolean>(false); const menuEditingModal = ref<boolean>(false);
const refreshInterval = ref();
const handleFetchMenus = async (options?: { mute?: boolean }) => { const {
selectedMenuToUpdate.value = undefined; data: menus,
try { isLoading,
clearInterval(refreshInterval.value); refetch,
} = useQuery<Menu[]>({
if (!options?.mute) { queryKey: ["menus"],
loading.value = true; queryFn: async () => {
} const { data } = await apiClient.extension.menu.listv1alpha1Menu({
page: 0,
const { data } = await apiClient.extension.menu.listv1alpha1Menu(); size: 0,
menus.value = data.items; });
return data.items;
// update selected menu },
refetchOnWindowFocus: false,
onSuccess(data) {
if (props.selectedMenu) { if (props.selectedMenu) {
const updatedMenu = menus.value.find( const updatedMenu = data?.find(
(menu) => menu.metadata.name === props.selectedMenu?.metadata.name (menu) => menu.metadata.name === props.selectedMenu?.metadata.name
); );
if (updatedMenu) { if (updatedMenu) {
emit("update:selectedMenu", updatedMenu); emit("update:selectedMenu", updatedMenu);
} }
} }
},
const deletedMenus = menus.value.filter( refetchInterval(data) {
const deletingMenus = data?.filter(
(menu) => !!menu.metadata.deletionTimestamp (menu) => !!menu.metadata.deletionTimestamp
); );
return deletingMenus?.length ? 3000 : false;
if (deletedMenus.length) { },
refreshInterval.value = setInterval(() => {
handleFetchMenus({ mute: true });
}, 3000);
}
} catch (e) {
console.error("Failed to fetch menus", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
}); });
const menuQuery = useRouteQuery("menu"); const menuQuery = useRouteQuery("menu");
@ -111,20 +94,23 @@ const handleDeleteMenu = async (menu: Menu) => {
name: menu.metadata.name, name: menu.metadata.name,
}); });
const deleteItemsPromises = Array.from(menu.spec.menuItems || []).map( const deleteItemsPromises = menu.spec.menuItems?.map((item) =>
(item) =>
apiClient.extension.menuItem.deletev1alpha1MenuItem({ apiClient.extension.menuItem.deletev1alpha1MenuItem({
name: item, name: item,
}) })
); );
if (!deleteItemsPromises) {
return;
}
await Promise.all(deleteItemsPromises); await Promise.all(deleteItemsPromises);
Toast.success(t("core.common.toast.delete_success")); Toast.success(t("core.common.toast.delete_success"));
} catch (e) { } catch (e) {
console.error("Failed to delete menu", e); console.error("Failed to delete menu", e);
} finally { } finally {
await handleFetchMenus(); await refetch();
} }
}, },
}); });
@ -136,41 +122,39 @@ const handleOpenEditingModal = (menu?: Menu) => {
}; };
onMounted(async () => { onMounted(async () => {
await handleFetchMenus(); await refetch();
if (menuQuery.value) { if (menuQuery.value) {
const menu = menus.value.find((m) => m.metadata.name === menuQuery.value); const menu = menus.value?.find((m) => m.metadata.name === menuQuery.value);
if (menu) { if (menu) {
handleSelect(menu); handleSelect(menu);
} }
return; return;
} }
if (menus.value.length > 0) { if (menus.value?.length) {
handleSelect(menus.value[0]); handleSelect(menus.value[0]);
} }
}); });
defineExpose({
handleFetchMenus,
});
// primary menu // primary menu
const primaryMenuName = ref<string>(); const { data: primaryMenuName, refetch: refetchPrimaryMenuName } = useQuery({
queryKey: ["primary-menu-name"],
const handleFetchPrimaryMenuName = async () => { queryFn: async () => {
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({ const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({
name: "system", name: "system",
}); });
if (!data.data?.menu) { if (!data.data?.menu) {
return; return "";
} }
const menuConfig = JSON.parse(data.data.menu); const menuConfig = JSON.parse(data.data.menu);
primaryMenuName.value = menuConfig.primary; return menuConfig.primary;
}; },
refetchOnWindowFocus: false,
});
const handleSetPrimaryMenu = async (menu: Menu) => { const handleSetPrimaryMenu = async (menu: Menu) => {
const { data: systemConfigMap } = const { data: systemConfigMap } =
@ -188,30 +172,28 @@ const handleSetPrimaryMenu = async (menu: Menu) => {
configMap: systemConfigMap, configMap: systemConfigMap,
}); });
} }
await handleFetchPrimaryMenuName(); await refetchPrimaryMenuName();
Toast.success(t("core.menu.operations.set_primary.toast_success")); Toast.success(t("core.menu.operations.set_primary.toast_success"));
}; };
onMounted(handleFetchPrimaryMenuName);
</script> </script>
<template> <template>
<MenuEditingModal <MenuEditingModal
v-model:visible="menuEditingModal" v-model:visible="menuEditingModal"
:menu="selectedMenuToUpdate" :menu="selectedMenuToUpdate"
@close="handleFetchMenus()" @close="refetch()"
@created="handleSelect" @created="handleSelect"
/> />
<VCard :body-class="['!p-0']" :title="$t('core.menu.title')"> <VCard :body-class="['!p-0']" :title="$t('core.menu.title')">
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!menus.length" appear name="fade"> <Transition v-else-if="!menus?.length" appear name="fade">
<VEmpty <VEmpty
:message="$t('core.menu.empty.message')" :message="$t('core.menu.empty.message')"
:title="$t('core.menu.empty.title')" :title="$t('core.menu.empty.title')"
> >
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton size="sm" @click="handleFetchMenus()"> <VButton size="sm" @click="refetch()">
{{ $t("core.common.buttons.refresh") }} {{ $t("core.common.buttons.refresh") }}
</VButton> </VButton>
</VSpace> </VSpace>