refactor: use tanstack query to refactor post-related fetching (#879)

#### 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

#### Special notes for your reviewer:

测试方式:

1. 测试文章相关联的页面,包括文章管理、分类、标签。

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

```release-note
None
```
pull/883/head
Ryan Wang 2023-02-24 12:10:13 +08:00 committed by GitHub
parent 5a27f56b89
commit 66a626c916
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 406 additions and 521 deletions

View File

@ -20,9 +20,7 @@ const emit = defineEmits<{
(event: "select", category?: Category): void; (event: "select", category?: Category): void;
}>(); }>();
const { categories, handleFetchCategories } = usePostCategory({ const { categories } = usePostCategory();
fetchOnMounted: false,
});
const handleSelect = (category: Category) => { const handleSelect = (category: Category) => {
if ( if (
@ -39,7 +37,6 @@ const handleSelect = (category: Category) => {
}; };
function onDropdownShow() { function onDropdownShow() {
handleFetchCategories();
setTimeout(() => { setTimeout(() => {
setFocus("categoryDropdownSelectorInput"); setFocus("categoryDropdownSelectorInput");
}, 200); }, 200);
@ -53,11 +50,14 @@ let fuse: Fuse<Category> | undefined = undefined;
watch( watch(
() => categories.value, () => categories.value,
() => { () => {
fuse = new Fuse(categories.value, { fuse = new Fuse(categories.value || [], {
keys: ["spec.displayName", "metadata.name"], keys: ["spec.displayName", "metadata.name"],
useExtendedSearch: true, useExtendedSearch: true,
threshold: 0.2, threshold: 0.2,
}); });
},
{
immediate: true,
} }
); );

View File

@ -21,7 +21,7 @@ const emit = defineEmits<{
(event: "select", tag?: Tag): void; (event: "select", tag?: Tag): void;
}>(); }>();
const { tags, handleFetchTags } = usePostTag({ fetchOnMounted: false }); const { tags } = usePostTag();
const handleSelect = (tag: Tag) => { const handleSelect = (tag: Tag) => {
if (props.selected && tag.metadata.name === props.selected.metadata.name) { if (props.selected && tag.metadata.name === props.selected.metadata.name) {
@ -35,7 +35,6 @@ const handleSelect = (tag: Tag) => {
}; };
function onDropdownShow() { function onDropdownShow() {
handleFetchTags();
setTimeout(() => { setTimeout(() => {
setFocus("tagDropdownSelectorInput"); setFocus("tagDropdownSelectorInput");
}, 200); }, 200);
@ -49,11 +48,14 @@ let fuse: Fuse<Tag> | undefined = undefined;
watch( watch(
() => tags.value, () => tags.value,
() => { () => {
fuse = new Fuse(tags.value, { fuse = new Fuse(tags.value || [], {
keys: ["spec.displayName", "metadata.name", "spec.email"], keys: ["spec.displayName", "metadata.name", "spec.email"],
useExtendedSearch: true, useExtendedSearch: true,
threshold: 0.2, threshold: 0.2,
}); });
},
{
immediate: true,
} }
); );

View File

@ -35,9 +35,7 @@ const multiple = computed(() => {
return multiple === "true"; return multiple === "true";
}); });
const { categories, categoriesTree, handleFetchCategories } = usePostCategory({ const { categories, categoriesTree, handleFetchCategories } = usePostCategory();
fetchOnMounted: true,
});
provide<Ref<CategoryTree[]>>("categoriesTree", categoriesTree); provide<Ref<CategoryTree[]>>("categoriesTree", categoriesTree);
@ -69,7 +67,7 @@ const searchResults = computed(() => {
watch( watch(
() => searchResults.value, () => searchResults.value,
(value) => { (value) => {
if (value?.length > 0 && text.value) { if (value?.length && text.value) {
selectedCategory.value = value[0]; selectedCategory.value = value[0];
scrollToSelected(); scrollToSelected();
} else { } else {
@ -81,11 +79,14 @@ watch(
watch( watch(
() => categories.value, () => categories.value,
() => { () => {
fuse = new Fuse(categories.value, { fuse = new Fuse(categories.value || [], {
keys: ["spec.displayName", "spec.slug"], keys: ["spec.displayName", "spec.slug"],
useExtendedSearch: true, useExtendedSearch: true,
threshold: 0.2, threshold: 0.2,
}); });
},
{
immediate: true,
} }
); );
@ -94,14 +95,14 @@ const selectedCategories = computed(() => {
const currentValue = props.context._value || []; const currentValue = props.context._value || [];
return currentValue return currentValue
.map((categoryName): Category | undefined => { .map((categoryName): Category | undefined => {
return categories.value.find( return categories.value?.find(
(category) => category.metadata.name === categoryName (category) => category.metadata.name === categoryName
); );
}) })
.filter(Boolean) as Category[]; .filter(Boolean) as Category[];
} }
const category = categories.value.find( const category = categories.value?.find(
(category) => category.metadata.name === props.context._value (category) => category.metadata.name === props.context._value
); );
return [category].filter(Boolean) as Category[]; return [category].filter(Boolean) as Category[];
@ -140,6 +141,8 @@ const handleSelect = (category: CategoryTree | Category) => {
}; };
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (!searchResults.value) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@ -217,7 +220,7 @@ const handleCreateCategory = async () => {
description: "", description: "",
cover: "", cover: "",
template: "", template: "",
priority: categories.value.length + 1, priority: categories.value?.length || 0 + 1,
children: [], children: [],
}, },
apiVersion: "content.halo.run/v1alpha1", apiVersion: "content.halo.run/v1alpha1",
@ -286,7 +289,7 @@ const handleDelete = () => {
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']"> <div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
<ul class="p-1"> <ul class="p-1">
<li <li
v-if="text.trim() && searchResults.length <= 0" v-if="text.trim() && !searchResults?.length"
v-permission="['system:posts:manage']" v-permission="['system:posts:manage']"
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2" class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
> >

View File

@ -24,7 +24,7 @@ const label = computed(() => {
props.category.metadata.name props.category.metadata.name
); );
return categories return categories
.map((category: CategoryTree) => category.spec.displayName) ?.map((category: CategoryTree) => category.spec.displayName)
.join(" / "); .join(" / ");
}); });
</script> </script>

View File

@ -33,7 +33,7 @@ const label = computed(() => {
props.category.metadata.name props.category.metadata.name
); );
return categories return categories
.map((category: CategoryTree) => category.spec.displayName) ?.map((category: CategoryTree) => category.spec.displayName)
.join(" / "); .join(" / ");
}); });
</script> </script>

View File

@ -2,7 +2,7 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { FormKitFrameworkContext } from "@formkit/core"; import type { FormKitFrameworkContext } from "@formkit/core";
import type { Tag } from "@halo-dev/api-client"; import type { Tag } from "@halo-dev/api-client";
import { computed, onMounted, ref, watch, type PropType } from "vue"; import { computed, ref, watch, type PropType } from "vue";
import PostTag from "@/modules/contents/posts/tags/components/PostTag.vue"; import PostTag from "@/modules/contents/posts/tags/components/PostTag.vue";
import { import {
IconCheckboxCircle, IconCheckboxCircle,
@ -13,6 +13,7 @@ import { onClickOutside } from "@vueuse/core";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { slugify } from "transliteration"; import { slugify } from "transliteration";
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -36,7 +37,6 @@ const multiple = computed(() => {
return multiple === "true"; return multiple === "true";
}); });
const postTags = ref<Tag[]>([] as Tag[]);
const selectedTag = ref<Tag>(); const selectedTag = ref<Tag>();
const dropdownVisible = ref(false); const dropdownVisible = ref(false);
const text = ref(""); const text = ref("");
@ -46,8 +46,9 @@ onClickOutside(wrapperRef, () => {
dropdownVisible.value = false; dropdownVisible.value = false;
}); });
// search const { tags: postTags, handleFetchTags } = usePostTag();
// search
let fuse: Fuse<Tag> | undefined = undefined; let fuse: Fuse<Tag> | undefined = undefined;
const searchResults = computed(() => { const searchResults = computed(() => {
@ -61,7 +62,7 @@ const searchResults = computed(() => {
watch( watch(
() => searchResults.value, () => searchResults.value,
(value) => { (value) => {
if (value?.length > 0 && text.value) { if (value?.length && text.value) {
selectedTag.value = value[0]; selectedTag.value = value[0];
scrollToSelected(); scrollToSelected();
} else { } else {
@ -70,32 +71,31 @@ watch(
} }
); );
const handleFetchTags = async () => { watch(
const { data } = await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({ () => postTags.value,
page: 0, () => {
size: 0, fuse = new Fuse(postTags.value || [], {
}); keys: ["spec.displayName", "metadata.name", "spec.email"],
useExtendedSearch: true,
postTags.value = data.items; threshold: 0.2,
});
fuse = new Fuse(data.items, { },
keys: ["spec.displayName", "spec.slug"], {
useExtendedSearch: true, immediate: true,
threshold: 0.2, }
}); );
};
const selectedTags = computed(() => { const selectedTags = computed(() => {
if (multiple.value) { if (multiple.value) {
const selectedTagNames = (props.context._value as string[]) || []; const selectedTagNames = (props.context._value as string[]) || [];
return selectedTagNames return selectedTagNames
.map((tagName): Tag | undefined => { .map((tagName): Tag | undefined => {
return postTags.value.find((tag) => tag.metadata.name === tagName); return postTags.value?.find((tag) => tag.metadata.name === tagName);
}) })
.filter(Boolean) as Tag[]; .filter(Boolean) as Tag[];
} }
const tag = postTags.value.find( const tag = postTags.value?.find(
(tag) => tag.metadata.name === props.context._value (tag) => tag.metadata.name === props.context._value
); );
@ -129,6 +129,8 @@ const handleSelect = (tag: Tag) => {
}; };
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (!searchResults.value) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@ -234,8 +236,6 @@ const handleDelete = () => {
props.context.node.input(""); props.context.node.input("");
} }
}; };
onMounted(handleFetchTags);
</script> </script>
<template> <template>
@ -279,7 +279,7 @@ onMounted(handleFetchTags);
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']"> <div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
<ul class="p-1"> <ul class="p-1">
<li <li
v-if="text.trim() && searchResults.length <= 0" v-if="text.trim() && !searchResults?.length"
v-permission="['system:posts:manage']" v-permission="['system:posts:manage']"
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2" class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
@click="handleCreateTag" @click="handleCreateTag"

View File

@ -18,86 +18,55 @@ import {
Toast, Toast,
} from "@halo-dev/components"; } from "@halo-dev/components";
import PostTag from "./tags/components/PostTag.vue"; import PostTag from "./tags/components/PostTag.vue";
import { onMounted, ref, watch } from "vue"; import { ref, watch } from "vue";
import type { ListedPostList, Post } from "@halo-dev/api-client"; import type { ListedPost, Post } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { getNode } from "@formkit/core"; import { getNode } from "@formkit/core";
import FilterTag from "@/components/filter/FilterTag.vue"; import FilterTag from "@/components/filter/FilterTag.vue";
import { useQuery } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const posts = ref<ListedPostList>({
page: 1,
size: 50,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
totalPages: 0,
});
const loading = ref(false);
const checkedAll = ref(false); const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]); const selectedPostNames = ref<string[]>([]);
const refreshInterval = ref();
const keyword = ref(""); const keyword = ref("");
const handleFetchPosts = async (page?: number) => { const page = ref(1);
try { const size = ref(20);
clearInterval(refreshInterval.value); const total = ref(0);
loading.value = true;
if (page) {
posts.value.page = page;
}
const {
data: posts,
isLoading,
isFetching,
refetch,
} = useQuery<ListedPost[]>({
queryKey: ["deleted-posts", page, size, keyword],
queryFn: async () => {
const { data } = await apiClient.post.listPosts({ const { data } = await apiClient.post.listPosts({
labelSelector: [`content.halo.run/deleted=true`], labelSelector: [`content.halo.run/deleted=true`],
page: posts.value.page, page: page.value,
size: posts.value.size, size: size.value,
keyword: keyword.value, keyword: keyword.value,
}); });
posts.value = data;
const deletedPosts = posts.value.items.filter( total.value = data.total;
return data.items;
},
refetchOnWindowFocus: false,
refetchInterval: (data) => {
const deletingPosts = data?.filter(
(post) => (post) =>
!!post.post.metadata.deletionTimestamp || !post.post.spec.deleted !!post.post.metadata.deletionTimestamp || !post.post.spec.deleted
); );
return deletingPosts?.length ? 3000 : false;
if (deletedPosts.length) { },
refreshInterval.value = setInterval(() => {
handleFetchPosts();
}, 3000);
}
} catch (e) {
console.error("Failed to fetch deleted posts", e);
} finally {
loading.value = false;
}
};
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
}); });
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
posts.value.page = page;
posts.value.size = size;
handleFetchPosts();
};
const checkSelection = (post: Post) => { const checkSelection = (post: Post) => {
return selectedPostNames.value.includes(post.metadata.name); return selectedPostNames.value.includes(post.metadata.name);
}; };
@ -107,7 +76,7 @@ const handleCheckAllChange = (e: Event) => {
if (checked) { if (checked) {
selectedPostNames.value = selectedPostNames.value =
posts.value.items.map((post) => { posts.value?.map((post) => {
return post.post.metadata.name; return post.post.metadata.name;
}) || []; }) || [];
} else { } else {
@ -124,7 +93,7 @@ const handleDeletePermanently = async (post: Post) => {
await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({ await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name: post.metadata.name, name: post.metadata.name,
}); });
await handleFetchPosts(); await refetch();
Toast.success("删除成功"); Toast.success("删除成功");
}, },
@ -144,7 +113,7 @@ const handleDeletePermanentlyInBatch = async () => {
}); });
}) })
); );
await handleFetchPosts(); await refetch();
selectedPostNames.value = []; selectedPostNames.value = [];
Toast.success("删除成功"); Toast.success("删除成功");
@ -163,7 +132,8 @@ const handleRecovery = async (post: Post) => {
name: postToUpdate.metadata.name, name: postToUpdate.metadata.name,
post: postToUpdate, post: postToUpdate,
}); });
await handleFetchPosts();
await refetch();
Toast.success("恢复成功"); Toast.success("恢复成功");
}, },
@ -177,7 +147,7 @@ const handleRecoveryInBatch = async () => {
onConfirm: async () => { onConfirm: async () => {
await Promise.all( await Promise.all(
selectedPostNames.value.map((name) => { selectedPostNames.value.map((name) => {
const post = posts.value.items.find( const post = posts.value?.find(
(item) => item.post.metadata.name === name (item) => item.post.metadata.name === name
)?.post; )?.post;
@ -185,14 +155,19 @@ const handleRecoveryInBatch = async () => {
return Promise.resolve(); return Promise.resolve();
} }
post.spec.deleted = false;
return apiClient.extension.post.updatecontentHaloRunV1alpha1Post({ return apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: post.metadata.name, name: post.metadata.name,
post: post, post: {
...post,
spec: {
...post.spec,
deleted: false,
},
},
}); });
}) })
); );
await handleFetchPosts(); await refetch();
selectedPostNames.value = []; selectedPostNames.value = [];
Toast.success("恢复成功"); Toast.success("恢复成功");
@ -201,11 +176,7 @@ const handleRecoveryInBatch = async () => {
}; };
watch(selectedPostNames, (newValue) => { watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value.items?.length; checkedAll.value = newValue.length === posts.value?.length;
});
onMounted(() => {
handleFetchPosts();
}); });
function handleKeywordChange() { function handleKeywordChange() {
@ -213,12 +184,12 @@ function handleKeywordChange() {
if (keywordNode) { if (keywordNode) {
keyword.value = keywordNode._value as string; keyword.value = keywordNode._value as string;
} }
handleFetchPosts(1); page.value = 1;
} }
function handleClearKeyword() { function handleClearKeyword() {
keyword.value = ""; keyword.value = "";
handleFetchPosts(1); page.value = 1;
} }
</script> </script>
<template> <template>
@ -294,10 +265,10 @@ function handleClearKeyword() {
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<div <div
class="group cursor-pointer rounded p-1 hover:bg-gray-200" class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchPosts()" @click="refetch()"
> >
<IconRefreshLine <IconRefreshLine
:class="{ 'animate-spin text-gray-900': loading }" :class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900" class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/> />
</div> </div>
@ -308,16 +279,16 @@ function handleClearKeyword() {
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!posts.items.length" appear name="fade"> <Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty <VEmpty
message="你可以尝试刷新或者返回文章管理" message="你可以尝试刷新或者返回文章管理"
title="没有文章被放入回收站" title="没有文章被放入回收站"
> >
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchPosts"></VButton> <VButton @click="refetch"></VButton>
<VButton :route="{ name: 'Posts' }" type="primary"> <VButton :route="{ name: 'Posts' }" type="primary">
返回 返回
</VButton> </VButton>
@ -331,7 +302,7 @@ function handleClearKeyword() {
class="box-border h-full w-full divide-y divide-gray-100" class="box-border h-full w-full divide-y divide-gray-100"
role="list" role="list"
> >
<li v-for="(post, index) in posts.items" :key="index"> <li v-for="(post, index) in posts" :key="index">
<VEntity :is-selected="checkSelection(post.post)"> <VEntity :is-selected="checkSelection(post.post)">
<template <template
v-if="currentUserHasPermission(['system:posts:manage'])" v-if="currentUserHasPermission(['system:posts:manage'])"
@ -452,11 +423,10 @@ function handleClearKeyword() {
<template #footer> <template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end"> <div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination <VPagination
:page="posts.page" v-model:page="page"
:size="posts.size" v-model:size="size"
:total="posts.total" :total="total"
:size-options="[20, 30, 50, 100]" :size-options="[20, 30, 50, 100]"
@change="handlePaginationChange"
/> />
</div> </div>
</template> </template>

View File

@ -28,282 +28,32 @@ import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSel
import CategoryDropdownSelector from "@/components/dropdown-selector/CategoryDropdownSelector.vue"; import CategoryDropdownSelector from "@/components/dropdown-selector/CategoryDropdownSelector.vue";
import PostSettingModal from "./components/PostSettingModal.vue"; import PostSettingModal from "./components/PostSettingModal.vue";
import PostTag from "../posts/tags/components/PostTag.vue"; import PostTag from "../posts/tags/components/PostTag.vue";
import { computed, onMounted, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import type { import type {
User, User,
Category, Category,
ListedPostList,
Post, Post,
Tag, Tag,
ListedPost,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import { postLabels } from "@/constants/labels"; import { postLabels } from "@/constants/labels";
import FilterTag from "@/components/filter/FilterTag.vue"; import FilterTag from "@/components/filter/FilterTag.vue";
import FilteCleanButton from "@/components/filter/FilterCleanButton.vue"; import FilteCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core"; import { getNode } from "@formkit/core";
import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue"; import TagDropdownSelector from "@/components/dropdown-selector/TagDropdownSelector.vue";
import { useQuery } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const posts = ref<ListedPostList>({
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
totalPages: 0,
});
const loading = ref(false);
const settingModal = ref(false); const settingModal = ref(false);
const selectedPost = ref<Post>(); const selectedPost = ref<Post>();
const checkedAll = ref(false); const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]); const selectedPostNames = ref<string[]>([]);
const refreshInterval = ref();
const handleFetchPosts = async (options?: {
mute?: boolean;
page?: number;
}) => {
try {
clearInterval(refreshInterval.value);
if (!options?.mute) {
loading.value = true;
}
let categories: string[] | undefined;
let tags: string[] | undefined;
let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
if (selectedCategory.value) {
categories = [
selectedCategory.value.metadata.name,
selectedCategory.value.metadata.name,
];
}
if (selectedTag.value) {
tags = [selectedTag.value.metadata.name];
}
if (selectedContributor.value) {
contributors = [selectedContributor.value.metadata.name];
}
if (selectedPublishStatusItem.value.value !== undefined) {
labelSelector.push(
`${postLabels.PUBLISHED}=${selectedPublishStatusItem.value.value}`
);
}
if (options?.page) {
posts.value.page = options.page;
}
const { data } = await apiClient.post.listPosts({
labelSelector,
page: posts.value.page,
size: posts.value.size,
visible: selectedVisibleItem.value?.value,
sort: selectedSortItem.value?.sort,
sortOrder: selectedSortItem.value?.sortOrder,
keyword: keyword.value,
category: categories,
tag: tags,
contributor: contributors,
});
posts.value = data;
// When an post is in the process of deleting or publishing, the list needs to be refreshed regularly
const abnormalPosts = posts.value.items.filter((post) => {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
if (abnormalPosts.length) {
refreshInterval.value = setInterval(() => {
handleFetchPosts({ mute: true });
}, 3000);
}
} catch (e) {
console.error("Failed to fetch posts", e);
} finally {
loading.value = false;
}
};
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
});
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
posts.value.page = page;
posts.value.size = size;
handleFetchPosts();
};
const handleOpenSettingModal = async (post: Post) => {
const { data } = await apiClient.extension.post.getcontentHaloRunV1alpha1Post(
{
name: post.metadata.name,
}
);
selectedPost.value = data;
settingModal.value = true;
};
const onSettingModalClose = () => {
selectedPost.value = undefined;
handleFetchPosts({ mute: true });
};
const handleSelectPrevious = async () => {
const { items, hasPrevious } = posts.value;
const index = items.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index > 0) {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: items[index - 1].post.metadata.name,
});
selectedPost.value = data;
return;
}
if (index === 0 && hasPrevious) {
posts.value.page--;
await handleFetchPosts();
selectedPost.value = posts.value.items[posts.value.items.length - 1].post;
}
};
const handleSelectNext = async () => {
const { items, hasNext } = posts.value;
const index = items.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index < items.length - 1) {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: items[index + 1].post.metadata.name,
});
selectedPost.value = data;
return;
}
if (index === items.length - 1 && hasNext) {
posts.value.page++;
await handleFetchPosts();
selectedPost.value = posts.value.items[0].post;
}
};
const checkSelection = (post: Post) => {
return (
post.metadata.name === selectedPost.value?.metadata.name ||
selectedPostNames.value.includes(post.metadata.name)
);
};
const getPublishStatus = (post: Post) => {
const { labels } = post.metadata;
return labels?.[postLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
};
const isPublishing = (post: Post) => {
const { spec, status, metadata } = post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value.items.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value = [];
}
};
const handleDelete = async (post: Post) => {
Dialog.warning({
title: "确定要删除该文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.post.recyclePost({
name: post.metadata.name,
});
await handleFetchPosts();
Toast.success("删除成功");
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除选中的文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
const post = posts.value.items.find(
(item) => item.post.metadata.name === name
)?.post;
if (!post) {
return Promise.resolve();
}
post.spec.deleted = true;
return apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: post.metadata.name,
post: post,
});
})
);
await handleFetchPosts();
selectedPostNames.value = [];
Toast.success("删除成功");
},
});
};
watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value.items?.length;
});
onMounted(() => {
handleFetchPosts();
});
// Filters // Filters
interface VisibleItem { interface VisibleItem {
label: string; label: string;
value?: "PUBLIC" | "INTERNAL" | "PRIVATE"; value?: "PUBLIC" | "INTERNAL" | "PRIVATE";
@ -388,32 +138,32 @@ const keyword = ref("");
function handleVisibleItemChange(visibleItem: VisibleItem) { function handleVisibleItemChange(visibleItem: VisibleItem) {
selectedVisibleItem.value = visibleItem; selectedVisibleItem.value = visibleItem;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handlePublishStatusItemChange(publishStatusItem: PublishStatuItem) { function handlePublishStatusItemChange(publishStatusItem: PublishStatuItem) {
selectedPublishStatusItem.value = publishStatusItem; selectedPublishStatusItem.value = publishStatusItem;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleSortItemChange(sortItem?: SortItem) { function handleSortItemChange(sortItem?: SortItem) {
selectedSortItem.value = sortItem; selectedSortItem.value = sortItem;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleCategoryChange(category?: Category) { function handleCategoryChange(category?: Category) {
selectedCategory.value = category; selectedCategory.value = category;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleTagChange(tag?: Tag) { function handleTagChange(tag?: Tag) {
selectedTag.value = tag; selectedTag.value = tag;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleContributorChange(user?: User) { function handleContributorChange(user?: User) {
selectedContributor.value = user; selectedContributor.value = user;
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleKeywordChange() { function handleKeywordChange() {
@ -421,12 +171,12 @@ function handleKeywordChange() {
if (keywordNode) { if (keywordNode) {
keyword.value = keywordNode._value as string; keyword.value = keywordNode._value as string;
} }
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleClearKeyword() { function handleClearKeyword() {
keyword.value = ""; keyword.value = "";
handleFetchPosts({ page: 1 }); page.value = 1;
} }
function handleClearFilters() { function handleClearFilters() {
@ -437,7 +187,7 @@ function handleClearFilters() {
selectedTag.value = undefined; selectedTag.value = undefined;
selectedContributor.value = undefined; selectedContributor.value = undefined;
keyword.value = ""; keyword.value = "";
handleFetchPosts({ page: 1 }); page.value = 1;
} }
const hasFilters = computed(() => { const hasFilters = computed(() => {
@ -451,6 +201,220 @@ const hasFilters = computed(() => {
keyword.value keyword.value
); );
}); });
const page = ref(1);
const size = ref(20);
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
const {
data: posts,
isLoading,
isFetching,
refetch,
} = useQuery<ListedPost[]>({
queryKey: [
"posts",
page,
size,
selectedCategory,
selectedTag,
selectedContributor,
selectedPublishStatusItem,
selectedVisibleItem,
selectedSortItem,
keyword,
],
queryFn: async () => {
let categories: string[] | undefined;
let tags: string[] | undefined;
let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
if (selectedCategory.value) {
categories = [selectedCategory.value.metadata.name];
}
if (selectedTag.value) {
tags = [selectedTag.value.metadata.name];
}
if (selectedContributor.value) {
contributors = [selectedContributor.value.metadata.name];
}
if (selectedPublishStatusItem.value.value !== undefined) {
labelSelector.push(
`${postLabels.PUBLISHED}=${selectedPublishStatusItem.value.value}`
);
}
const { data } = await apiClient.post.listPosts({
labelSelector,
page: page.value,
size: size.value,
visible: selectedVisibleItem.value?.value,
sort: selectedSortItem.value?.sort,
sortOrder: selectedSortItem.value?.sortOrder,
keyword: keyword.value,
category: categories,
tag: tags,
contributor: contributors,
});
total.value = data.total;
hasNext.value = data.hasNext;
hasPrevious.value = data.hasPrevious;
return data.items;
},
refetchInterval: (data) => {
const abnormalPosts = data?.filter((post) => {
const { spec, metadata, status } = post.post;
return (
spec.deleted ||
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
return abnormalPosts?.length ? 3000 : false;
},
refetchOnWindowFocus: false,
});
const handleOpenSettingModal = async (post: Post) => {
const { data } = await apiClient.extension.post.getcontentHaloRunV1alpha1Post(
{
name: post.metadata.name,
}
);
selectedPost.value = data;
settingModal.value = true;
};
const onSettingModalClose = () => {
selectedPost.value = undefined;
refetch();
};
const handleSelectPrevious = async () => {
if (!posts.value) return;
const index = posts.value.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index > 0) {
const { data: previousPost } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: posts.value[index - 1].post.metadata.name,
});
selectedPost.value = previousPost;
return;
}
if (index === 0 && hasPrevious) {
page.value--;
await refetch();
selectedPost.value = posts.value[posts.value.length - 1].post;
}
};
const handleSelectNext = async () => {
if (!posts.value) return;
const index = posts.value.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index < posts.value.length - 1) {
const { data: nextPost } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: posts.value[index + 1].post.metadata.name,
});
selectedPost.value = nextPost;
return;
}
if (index === posts.value.length - 1 && hasNext) {
page.value++;
await refetch();
selectedPost.value = posts.value[0].post;
}
};
const checkSelection = (post: Post) => {
return (
post.metadata.name === selectedPost.value?.metadata.name ||
selectedPostNames.value.includes(post.metadata.name)
);
};
const getPublishStatus = (post: Post) => {
const { labels } = post.metadata;
return labels?.[postLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
};
const isPublishing = (post: Post) => {
const { spec, status, metadata } = post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value?.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value = [];
}
};
const handleDelete = async (post: Post) => {
Dialog.warning({
title: "确定要删除该文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.post.recyclePost({
name: post.metadata.name,
});
await refetch();
Toast.success("删除成功");
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除选中的文章吗?",
description: "该操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
return apiClient.post.recyclePost({
name,
});
})
);
await refetch();
selectedPostNames.value = [];
Toast.success("删除成功");
},
});
};
watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value?.length;
});
</script> </script>
<template> <template>
<PostSettingModal <PostSettingModal
@ -708,11 +672,11 @@ const hasFilters = computed(() => {
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<div <div
class="group cursor-pointer rounded p-1 hover:bg-gray-200" class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchPosts()" @click="refetch()"
> >
<IconRefreshLine <IconRefreshLine
v-tooltip="`刷新`" v-tooltip="`刷新`"
:class="{ 'animate-spin text-gray-900': loading }" :class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900" class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/> />
</div> </div>
@ -722,12 +686,12 @@ const hasFilters = computed(() => {
</div> </div>
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!posts.items.length" appear name="fade"> <Transition v-else-if="!posts?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建文章" title="当前没有文章"> <VEmpty message="你可以尝试刷新或者新建文章" title="当前没有文章">
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchPosts"></VButton> <VButton @click="refetch"></VButton>
<VButton <VButton
v-permission="['system:posts:manage']" v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }" :route="{ name: 'PostEditor' }"
@ -747,7 +711,7 @@ const hasFilters = computed(() => {
class="box-border h-full w-full divide-y divide-gray-100" class="box-border h-full w-full divide-y divide-gray-100"
role="list" role="list"
> >
<li v-for="(post, index) in posts.items" :key="index"> <li v-for="(post, index) in posts" :key="index">
<VEntity :is-selected="checkSelection(post.post)"> <VEntity :is-selected="checkSelection(post.post)">
<template <template
v-if="currentUserHasPermission(['system:posts:manage'])" v-if="currentUserHasPermission(['system:posts:manage'])"
@ -928,11 +892,10 @@ const hasFilters = computed(() => {
<template #footer> <template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end"> <div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination <VPagination
:page="posts.page" v-model:page="page"
:size="posts.size" v-model:size="size"
:total="posts.total" :total="total"
:size-options="[20, 30, 50, 100]" :size-options="[20, 30, 50, 100]"
@change="handlePaginationChange"
/> />
</div> </div>
</template> </template>

View File

@ -38,10 +38,10 @@ const selectedCategory = ref<Category | null>(null);
const { const {
categories, categories,
categoriesTree, categoriesTree,
loading, isLoading,
handleFetchCategories, handleFetchCategories,
handleDelete, handleDelete,
} = usePostCategory({ fetchOnMounted: true }); } = usePostCategory();
const handleUpdateInBatch = useDebounceFn(async () => { const handleUpdateInBatch = useDebounceFn(async () => {
const categoriesTreeToUpdate = resetCategoriesTreePriority( const categoriesTreeToUpdate = resetCategoriesTreePriority(
@ -106,14 +106,14 @@ const onEditingModalClose = () => {
> >
<div class="flex w-full flex-1 sm:w-auto"> <div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium"> <span class="text-base font-medium">
{{ categories.length }} 个分类 {{ categories?.length || 0 }} 个分类
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!categories.length" appear name="fade"> <Transition v-else-if="!categories?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建分类" title="当前没有分类"> <VEmpty message="你可以尝试刷新或者新建分类" title="当前没有分类">
<template #actions> <template #actions>
<VSpace> <VSpace>

View File

@ -1,67 +1,48 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { Category } from "@halo-dev/api-client"; import type { Category } from "@halo-dev/api-client";
import { onUnmounted, type Ref } from "vue"; import type { Ref } from "vue";
import { onMounted, ref } from "vue"; import { ref } from "vue";
import type { CategoryTree } from "@/modules/contents/posts/categories/utils"; import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
import { buildCategoriesTree } from "@/modules/contents/posts/categories/utils"; import { buildCategoriesTree } from "@/modules/contents/posts/categories/utils";
import { Dialog, Toast } from "@halo-dev/components"; import { Dialog, Toast } from "@halo-dev/components";
import { onBeforeRouteLeave } from "vue-router"; import { useQuery } from "@tanstack/vue-query";
interface usePostCategoryReturn { interface usePostCategoryReturn {
categories: Ref<Category[]>; categories: Ref<Category[] | undefined>;
categoriesTree: Ref<CategoryTree[]>; categoriesTree: Ref<CategoryTree[]>;
loading: Ref<boolean>; isLoading: Ref<boolean>;
handleFetchCategories: (fetchOptions?: { mute?: boolean }) => void; handleFetchCategories: () => void;
handleDelete: (category: CategoryTree) => void; handleDelete: (category: CategoryTree) => void;
} }
export function usePostCategory(options?: { export function usePostCategory(): usePostCategoryReturn {
fetchOnMounted: boolean;
}): usePostCategoryReturn {
const { fetchOnMounted } = options || {};
const categories = ref<Category[]>([] as Category[]);
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]); const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
const loading = ref(false);
const refreshInterval = ref();
const handleFetchCategories = async (fetchOptions?: { mute?: boolean }) => { const {
try { data: categories,
clearInterval(refreshInterval.value); isLoading,
refetch,
if (!fetchOptions?.mute) { } = useQuery({
loading.value = true; queryKey: ["post-categories"],
} queryFn: async () => {
const { data } = const { data } =
await apiClient.extension.category.listcontentHaloRunV1alpha1Category({ await apiClient.extension.category.listcontentHaloRunV1alpha1Category({
page: 0, page: 0,
size: 0, size: 0,
}); });
categories.value = data.items;
categoriesTree.value = buildCategoriesTree(data.items);
const deletedCategories = categories.value.filter( return data.items;
},
refetchInterval(data) {
const deletingCategories = data?.filter(
(category) => !!category.metadata.deletionTimestamp (category) => !!category.metadata.deletionTimestamp
); );
return deletingCategories?.length ? 3000 : false;
if (deletedCategories.length) { },
refreshInterval.value = setInterval(() => { refetchOnWindowFocus: false,
handleFetchCategories({ mute: true }); onSuccess(data) {
}, 3000); categoriesTree.value = buildCategoriesTree(data);
} },
} catch (e) {
console.error("Failed to fetch categories", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
}); });
const handleDelete = async (category: CategoryTree) => { const handleDelete = async (category: CategoryTree) => {
@ -81,21 +62,17 @@ export function usePostCategory(options?: {
} catch (e) { } catch (e) {
console.error("Failed to delete tag", e); console.error("Failed to delete tag", e);
} finally { } finally {
await handleFetchCategories(); await refetch();
} }
}, },
}); });
}; };
onMounted(() => {
fetchOnMounted && handleFetchCategories();
});
return { return {
categories, categories,
categoriesTree, categoriesTree,
loading, isLoading,
handleFetchCategories, handleFetchCategories: refetch,
handleDelete, handleDelete,
}; };
} }

View File

@ -126,7 +126,7 @@ export const getCategoryPath = (
categories: CategoryTree[], categories: CategoryTree[],
name: string, name: string,
path: CategoryTree[] = [] path: CategoryTree[] = []
) => { ): CategoryTree[] | undefined => {
for (const category of categories) { for (const category of categories) {
if (category.metadata && category.metadata.name === name) { if (category.metadata && category.metadata.name === name) {
return path.concat([category]); return path.concat([category]);

View File

@ -46,9 +46,7 @@ const viewTypes = [
const viewType = ref("list"); const viewType = ref("list");
const { tags, loading, handleFetchTags, handleDelete } = usePostTag({ const { tags, isLoading, handleFetchTags, handleDelete } = usePostTag();
fetchOnMounted: true,
});
const editingModal = ref(false); const editingModal = ref(false);
const selectedTag = ref<Tag | null>(null); const selectedTag = ref<Tag | null>(null);
@ -59,6 +57,8 @@ const handleOpenEditingModal = (tag: Tag | null) => {
}; };
const handleSelectPrevious = () => { const handleSelectPrevious = () => {
if (!tags.value) return;
const currentIndex = tags.value.findIndex( const currentIndex = tags.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name (tag) => tag.metadata.name === selectedTag.value?.metadata.name
); );
@ -74,6 +74,8 @@ const handleSelectPrevious = () => {
}; };
const handleSelectNext = () => { const handleSelectNext = () => {
if (!tags.value) return;
if (!selectedTag.value) { if (!selectedTag.value) {
selectedTag.value = tags.value[0]; selectedTag.value = tags.value[0];
return; return;
@ -140,7 +142,7 @@ onMounted(async () => {
> >
<div class="flex w-full flex-1 sm:w-auto"> <div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium"> <span class="text-base font-medium">
{{ tags.length }} 个标签 {{ tags?.length || 0 }} 个标签
</span> </span>
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
@ -159,8 +161,8 @@ onMounted(async () => {
</div> </div>
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!tags.length" appear name="fade"> <Transition v-else-if="!tags?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建标签" title="当前没有标签"> <VEmpty message="你可以尝试刷新或者新建标签" title="当前没有标签">
<template #actions> <template #actions>
<VSpace> <VSpace>

View File

@ -1,63 +1,39 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { Tag } from "@halo-dev/api-client"; import type { Tag } from "@halo-dev/api-client";
import { onUnmounted, type Ref } from "vue"; import type { Ref } from "vue";
import { onMounted, ref } from "vue";
import { Dialog, Toast } from "@halo-dev/components"; import { Dialog, Toast } from "@halo-dev/components";
import { onBeforeRouteLeave } from "vue-router"; import { useQuery } from "@tanstack/vue-query";
interface usePostTagReturn { interface usePostTagReturn {
tags: Ref<Tag[]>; tags: Ref<Tag[] | undefined>;
loading: Ref<boolean>; isLoading: Ref<boolean>;
handleFetchTags: (fetchOptions?: { mute?: boolean }) => void; handleFetchTags: () => void;
handleDelete: (tag: Tag) => void; handleDelete: (tag: Tag) => void;
} }
export function usePostTag(options?: { export function usePostTag(): usePostTagReturn {
fetchOnMounted: boolean; const {
}): usePostTagReturn { data: tags,
const { fetchOnMounted } = options || {}; isLoading,
refetch,
const tags = ref<Tag[]>([] as Tag[]); } = useQuery({
const loading = ref(false); queryKey: ["post-tags"],
const refreshInterval = ref(); queryFn: async () => {
const handleFetchTags = async (fetchOptions?: { mute?: boolean }) => {
try {
clearInterval(refreshInterval.value);
if (!fetchOptions?.mute) {
loading.value = true;
}
const { data } = const { data } =
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({ await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
page: 0, page: 0,
size: 0, size: 0,
}); });
tags.value = data.items; return data.items;
},
const deletedTags = tags.value.filter( refetchInterval(data) {
const deletingTags = data?.filter(
(tag) => !!tag.metadata.deletionTimestamp (tag) => !!tag.metadata.deletionTimestamp
); );
return deletingTags?.length ? 3000 : false;
if (deletedTags.length) { },
refreshInterval.value = setInterval(() => { refetchOnWindowFocus: false,
handleFetchTags({ mute: true });
}, 3000);
}
} catch (e) {
console.error("Failed to fetch tags", e);
} finally {
loading.value = false;
}
};
onUnmounted(() => {
clearInterval(refreshInterval.value);
});
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
}); });
const handleDelete = async (tag: Tag) => { const handleDelete = async (tag: Tag) => {
@ -75,20 +51,16 @@ export function usePostTag(options?: {
} catch (e) { } catch (e) {
console.error("Failed to delete tag", e); console.error("Failed to delete tag", e);
} finally { } finally {
await handleFetchTags(); await refetch();
} }
}, },
}); });
}; };
onMounted(() => {
fetchOnMounted && handleFetchTags();
});
return { return {
tags, tags,
loading, isLoading,
handleFetchTags, handleFetchTags: refetch,
handleDelete, handleDelete,
}; };
} }

View File

@ -6,16 +6,15 @@ import {
VEntityField, VEntityField,
IconExternalLinkLine, IconExternalLinkLine,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { onMounted, ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client"; import type { ListedPost } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { postLabels } from "@/constants/labels"; import { postLabels } from "@/constants/labels";
import { useQuery } from "@tanstack/vue-query";
const posts = ref<ListedPost[]>([] as ListedPost[]); const { data } = useQuery<ListedPost[]>({
queryKey: ["widget-recent-posts"],
const handleFetchPosts = async () => { queryFn: async () => {
try {
const { data } = await apiClient.post.listPosts({ const { data } = await apiClient.post.listPosts({
labelSelector: [ labelSelector: [
`${postLabels.DELETED}=false`, `${postLabels.DELETED}=false`,
@ -26,13 +25,10 @@ const handleFetchPosts = async () => {
page: 1, page: 1,
size: 10, size: 10,
}); });
posts.value = data.items; return data.items;
} catch (e) { },
console.error("Failed to fetch posts", e); refetchOnWindowFocus: false,
} });
};
onMounted(handleFetchPosts);
</script> </script>
<template> <template>
<VCard <VCard
@ -41,7 +37,7 @@ onMounted(handleFetchPosts);
title="最近文章" title="最近文章"
> >
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list"> <ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(post, index) in posts" :key="index"> <li v-for="(post, index) in data" :key="index">
<VEntity> <VEntity>
<template #start> <template #start>
<VEntityField <VEntityField