mirror of https://github.com/halo-dev/halo
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
parent
8755c24b11
commit
eb141e4966
|
@ -14,7 +14,7 @@ import {
|
|||
import MenuItemEditingModal from "./components/MenuItemEditingModal.vue";
|
||||
import MenuItemListItem from "./components/MenuItemListItem.vue";
|
||||
import MenuList from "./components/MenuList.vue";
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import type { Menu, MenuItem } from "@halo-dev/api-client";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
|
@ -27,68 +27,49 @@ import {
|
|||
resetMenuItemsTreePriority,
|
||||
} from "./utils";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const menuItems = ref<MenuItem[]>([] as MenuItem[]);
|
||||
const menuTreeItems = ref<MenuTreeItem[]>([] as MenuTreeItem[]);
|
||||
const selectedMenu = ref<Menu>();
|
||||
const selectedMenuItem = ref<MenuItem>();
|
||||
const selectedParentMenuItem = ref<MenuItem>();
|
||||
const loading = ref(false);
|
||||
const menuListRef = 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) {
|
||||
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({
|
||||
page: 0,
|
||||
size: 0,
|
||||
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
|
||||
);
|
||||
|
||||
if (deletedMenuItems.length) {
|
||||
refreshInterval.value = setInterval(() => {
|
||||
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);
|
||||
return deletingMenuItems?.length ? 3000 : false;
|
||||
},
|
||||
enabled: computed(() => !!selectedMenu.value),
|
||||
});
|
||||
|
||||
const handleOpenEditingModal = (menuItem: MenuTreeItem) => {
|
||||
|
@ -120,11 +101,10 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
|
|||
menuToUpdate &&
|
||||
!menuToUpdate.spec.menuItems?.includes(menuItem.metadata.name)
|
||||
) {
|
||||
if (menuToUpdate.spec.menuItems) {
|
||||
menuToUpdate.spec.menuItems.push(menuItem.metadata.name);
|
||||
} else {
|
||||
menuToUpdate.spec.menuItems = [menuItem.metadata.name];
|
||||
}
|
||||
menuToUpdate.spec.menuItems = [
|
||||
...(menuToUpdate.spec.menuItems || []),
|
||||
menuItem.metadata.name,
|
||||
];
|
||||
|
||||
await apiClient.extension.menu.updatev1alpha1Menu({
|
||||
name: menuToUpdate.metadata.name,
|
||||
|
@ -132,8 +112,8 @@ const onMenuItemSaved = async (menuItem: MenuItem) => {
|
|||
});
|
||||
}
|
||||
|
||||
await menuListRef.value.handleFetchMenus();
|
||||
await handleFetchMenuItems({ mute: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["menus"] });
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleUpdateInBatch = useDebounceFn(async () => {
|
||||
|
@ -150,8 +130,8 @@ const handleUpdateInBatch = useDebounceFn(async () => {
|
|||
} catch (e) {
|
||||
console.error("Failed to update menu items", e);
|
||||
} finally {
|
||||
await menuListRef.value.handleFetchMenus();
|
||||
await handleFetchMenuItems({ mute: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ["menus"] });
|
||||
await refetch();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
|
@ -178,33 +158,26 @@ const handleDelete = async (menuItem: MenuTreeItem) => {
|
|||
await Promise.all(deleteChildrenRequests);
|
||||
}
|
||||
|
||||
await handleFetchMenuItems();
|
||||
await refetch();
|
||||
|
||||
// update items under menu
|
||||
const menuToUpdate = cloneDeep(selectedMenu.value);
|
||||
if (menuToUpdate) {
|
||||
menuToUpdate.spec.menuItems = menuToUpdate.spec.menuItems?.filter(
|
||||
(name) => ![menuItem.metadata.name, ...childrenNames].includes(name)
|
||||
);
|
||||
await apiClient.extension.menu.updatev1alpha1Menu({
|
||||
name: menuToUpdate.metadata.name,
|
||||
menu: menuToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["menus"] });
|
||||
|
||||
Toast.success(t("core.common.toast.delete_success"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetMenuItems = async () => {
|
||||
if (!selectedMenu.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuToUpdate = cloneDeep(selectedMenu.value);
|
||||
|
||||
const menuItemNames = menuItems.value.map((menuItem) => {
|
||||
return menuItem.metadata.name;
|
||||
});
|
||||
|
||||
menuToUpdate.spec.menuItems = menuItemNames;
|
||||
|
||||
await apiClient.extension.menu.updatev1alpha1Menu({
|
||||
name: menuToUpdate.metadata.name,
|
||||
menu: menuToUpdate,
|
||||
});
|
||||
|
||||
await menuListRef.value.handleFetchMenus({ mute: true });
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<MenuItemEditingModal
|
||||
|
@ -223,11 +196,7 @@ const handleResetMenuItems = async () => {
|
|||
<div class="m-0 md:m-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<div class="w-96">
|
||||
<MenuList
|
||||
ref="menuListRef"
|
||||
v-model:selected-menu="selectedMenu"
|
||||
@select="handleFetchMenuItems()"
|
||||
/>
|
||||
<MenuList v-model:selected-menu="selectedMenu" @select="refetch()" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<VCard :body-class="['!p-0']">
|
||||
|
@ -256,15 +225,15 @@ const handleResetMenuItems = async () => {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<VLoading v-if="loading" />
|
||||
<Transition v-else-if="!menuItems.length" appear name="fade">
|
||||
<VLoading v-if="isLoading" />
|
||||
<Transition v-else-if="!menuItems?.length" appear name="fade">
|
||||
<VEmpty
|
||||
:message="$t('core.menu.menu_item_empty.message')"
|
||||
:title="$t('core.menu.menu_item_empty.title')"
|
||||
>
|
||||
<template #actions>
|
||||
<VSpace>
|
||||
<VButton @click="handleFetchMenuItems()">
|
||||
<VButton @click="refetch()">
|
||||
{{ $t("core.common.buttons.refresh") }}
|
||||
</VButton>
|
||||
<VButton
|
||||
|
|
|
@ -14,23 +14,23 @@ import {
|
|||
VDropdownItem,
|
||||
} from "@halo-dev/components";
|
||||
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 { apiClient } from "@/utils/api-client";
|
||||
import { useRouteQuery } from "@vueuse/router";
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
selectedMenu: Menu | null;
|
||||
selectedMenu?: Menu;
|
||||
}>(),
|
||||
{
|
||||
selectedMenu: null,
|
||||
selectedMenu: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -39,56 +39,39 @@ const emit = defineEmits<{
|
|||
(event: "update:selectedMenu", menu: Menu): void;
|
||||
}>();
|
||||
|
||||
const menus = ref<Menu[]>([] as Menu[]);
|
||||
const loading = ref(false);
|
||||
const selectedMenuToUpdate = ref<Menu>();
|
||||
const menuEditingModal = ref<boolean>(false);
|
||||
const refreshInterval = ref();
|
||||
|
||||
const handleFetchMenus = async (options?: { mute?: boolean }) => {
|
||||
selectedMenuToUpdate.value = undefined;
|
||||
try {
|
||||
clearInterval(refreshInterval.value);
|
||||
|
||||
if (!options?.mute) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.extension.menu.listv1alpha1Menu();
|
||||
menus.value = data.items;
|
||||
|
||||
// update selected menu
|
||||
const {
|
||||
data: menus,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery<Menu[]>({
|
||||
queryKey: ["menus"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.extension.menu.listv1alpha1Menu({
|
||||
page: 0,
|
||||
size: 0,
|
||||
});
|
||||
return data.items;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
onSuccess(data) {
|
||||
if (props.selectedMenu) {
|
||||
const updatedMenu = menus.value.find(
|
||||
const updatedMenu = data?.find(
|
||||
(menu) => menu.metadata.name === props.selectedMenu?.metadata.name
|
||||
);
|
||||
if (updatedMenu) {
|
||||
emit("update:selectedMenu", updatedMenu);
|
||||
}
|
||||
}
|
||||
|
||||
const deletedMenus = menus.value.filter(
|
||||
},
|
||||
refetchInterval(data) {
|
||||
const deletingMenus = data?.filter(
|
||||
(menu) => !!menu.metadata.deletionTimestamp
|
||||
);
|
||||
|
||||
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);
|
||||
return deletingMenus?.length ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const menuQuery = useRouteQuery("menu");
|
||||
|
@ -111,20 +94,23 @@ const handleDeleteMenu = async (menu: Menu) => {
|
|||
name: menu.metadata.name,
|
||||
});
|
||||
|
||||
const deleteItemsPromises = Array.from(menu.spec.menuItems || []).map(
|
||||
(item) =>
|
||||
apiClient.extension.menuItem.deletev1alpha1MenuItem({
|
||||
name: item,
|
||||
})
|
||||
const deleteItemsPromises = menu.spec.menuItems?.map((item) =>
|
||||
apiClient.extension.menuItem.deletev1alpha1MenuItem({
|
||||
name: item,
|
||||
})
|
||||
);
|
||||
|
||||
if (!deleteItemsPromises) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(deleteItemsPromises);
|
||||
|
||||
Toast.success(t("core.common.toast.delete_success"));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete menu", e);
|
||||
} finally {
|
||||
await handleFetchMenus();
|
||||
await refetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -136,41 +122,39 @@ const handleOpenEditingModal = (menu?: Menu) => {
|
|||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await handleFetchMenus();
|
||||
await refetch();
|
||||
|
||||
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) {
|
||||
handleSelect(menu);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (menus.value.length > 0) {
|
||||
if (menus.value?.length) {
|
||||
handleSelect(menus.value[0]);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
handleFetchMenus,
|
||||
});
|
||||
|
||||
// primary menu
|
||||
const primaryMenuName = ref<string>();
|
||||
const { data: primaryMenuName, refetch: refetchPrimaryMenuName } = useQuery({
|
||||
queryKey: ["primary-menu-name"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({
|
||||
name: "system",
|
||||
});
|
||||
|
||||
const handleFetchPrimaryMenuName = async () => {
|
||||
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap({
|
||||
name: "system",
|
||||
});
|
||||
if (!data.data?.menu) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!data.data?.menu) {
|
||||
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 { data: systemConfigMap } =
|
||||
|
@ -188,30 +172,28 @@ const handleSetPrimaryMenu = async (menu: Menu) => {
|
|||
configMap: systemConfigMap,
|
||||
});
|
||||
}
|
||||
await handleFetchPrimaryMenuName();
|
||||
await refetchPrimaryMenuName();
|
||||
|
||||
Toast.success(t("core.menu.operations.set_primary.toast_success"));
|
||||
};
|
||||
|
||||
onMounted(handleFetchPrimaryMenuName);
|
||||
</script>
|
||||
<template>
|
||||
<MenuEditingModal
|
||||
v-model:visible="menuEditingModal"
|
||||
:menu="selectedMenuToUpdate"
|
||||
@close="handleFetchMenus()"
|
||||
@close="refetch()"
|
||||
@created="handleSelect"
|
||||
/>
|
||||
<VCard :body-class="['!p-0']" :title="$t('core.menu.title')">
|
||||
<VLoading v-if="loading" />
|
||||
<Transition v-else-if="!menus.length" appear name="fade">
|
||||
<VLoading v-if="isLoading" />
|
||||
<Transition v-else-if="!menus?.length" appear name="fade">
|
||||
<VEmpty
|
||||
:message="$t('core.menu.empty.message')"
|
||||
:title="$t('core.menu.empty.title')"
|
||||
>
|
||||
<template #actions>
|
||||
<VSpace>
|
||||
<VButton size="sm" @click="handleFetchMenus()">
|
||||
<VButton size="sm" @click="refetch()">
|
||||
{{ $t("core.common.buttons.refresh") }}
|
||||
</VButton>
|
||||
</VSpace>
|
||||
|
|
Loading…
Reference in New Issue