refactor: use tanstack query to refactor comment-related fetching (halo-dev/console#892)

#### 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/3445/head
Ryan Wang 2023-02-28 23:46:18 +08:00 committed by GitHub
parent 9646d411da
commit bd9c4dabf7
2 changed files with 208 additions and 259 deletions

View File

@ -15,187 +15,18 @@ import {
} from "@halo-dev/components"; } from "@halo-dev/components";
import CommentListItem from "./components/CommentListItem.vue"; import CommentListItem from "./components/CommentListItem.vue";
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue"; import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
import type { import type { ListedComment, User } from "@halo-dev/api-client";
ListedComment, import { computed, ref, watch } from "vue";
ListedCommentList,
User,
} from "@halo-dev/api-client";
import { computed, onMounted, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { onBeforeRouteLeave } from "vue-router";
import FilterTag from "@/components/filter/FilterTag.vue"; import FilterTag from "@/components/filter/FilterTag.vue";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue"; import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { getNode } from "@formkit/core"; import { getNode } from "@formkit/core";
import { useQuery } from "@tanstack/vue-query";
const comments = ref<ListedCommentList>({
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
totalPages: 0,
});
const loading = ref(false);
const checkAll = ref(false); const checkAll = ref(false);
const selectedComment = ref<ListedComment>(); const selectedComment = ref<ListedComment>();
const selectedCommentNames = ref<string[]>([]); const selectedCommentNames = ref<string[]>([]);
const keyword = ref(""); const keyword = ref("");
const refreshInterval = ref();
const handleFetchComments = async (options?: {
mute?: boolean;
page?: number;
}) => {
try {
clearInterval(refreshInterval.value);
if (!options?.mute) {
loading.value = true;
}
if (options?.page) {
comments.value.page = options.page;
}
const { data } = await apiClient.comment.listComments({
page: comments.value.page,
size: comments.value.size,
approved: selectedApprovedFilterItem.value.value,
sort: selectedSortFilterItem.value.value,
keyword: keyword.value,
ownerName: selectedUser.value?.metadata.name,
});
comments.value = data;
const deletedComments = comments.value.items.filter(
(comment) => !!comment.comment.metadata.deletionTimestamp
);
if (deletedComments.length) {
refreshInterval.value = setInterval(() => {
handleFetchComments({ mute: true });
}, 3000);
}
} catch (error) {
console.error("Failed to fetch comments", error);
} finally {
loading.value = false;
}
};
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
});
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
comments.value.page = page;
comments.value.size = size;
handleFetchComments();
};
// Selection
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedCommentNames.value =
comments.value.items.map((comment) => {
return comment.comment.metadata.name;
}) || [];
} else {
selectedCommentNames.value = [];
}
};
const checkSelection = (comment: ListedComment) => {
return (
comment.comment.metadata.name ===
selectedComment.value?.comment.metadata.name ||
selectedCommentNames.value.includes(comment.comment.metadata.name)
);
};
watch(
() => selectedCommentNames.value,
(newValue) => {
checkAll.value = newValue.length === comments.value.items?.length;
}
);
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除所选的评论吗?",
description: "将同时删除所有评论下的回复,该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
const promises = selectedCommentNames.value.map((name) => {
return apiClient.extension.comment.deletecontentHaloRunV1alpha1Comment(
{
name,
}
);
});
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success("删除成功");
} catch (e) {
console.error("Failed to delete comments", e);
} finally {
await handleFetchComments();
}
},
});
};
const handleApproveInBatch = async () => {
Dialog.warning({
title: "确定要审核通过所选的评论吗?",
onConfirm: async () => {
try {
const commentsToUpdate = comments.value.items.filter((comment) => {
return (
selectedCommentNames.value.includes(
comment.comment.metadata.name
) && !comment.comment.spec.approved
);
});
const promises = commentsToUpdate.map((comment) => {
const commentToUpdate = comment.comment;
commentToUpdate.spec.approved = true;
// TODO: see https://github.com/halo-dev/halo/pull/2746
commentToUpdate.spec.approvedTime = new Date().toISOString();
return apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment(
{
name: commentToUpdate.metadata.name,
comment: commentToUpdate,
}
);
});
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success("操作成功");
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
await handleFetchComments();
}
},
});
};
onMounted(handleFetchComments);
// Filters // Filters
const ApprovedFilterItems: { label: string; value?: boolean }[] = [ const ApprovedFilterItems: { label: string; value?: boolean }[] = [
@ -254,7 +85,7 @@ const handleApprovedFilterItemChange = (filterItem: {
}) => { }) => {
selectedApprovedFilterItem.value = filterItem; selectedApprovedFilterItem.value = filterItem;
selectedCommentNames.value = []; selectedCommentNames.value = [];
handleFetchComments({ page: 1 }); page.value = 1;
}; };
const handleSortFilterItemChange = (filterItem: { const handleSortFilterItemChange = (filterItem: {
@ -263,12 +94,12 @@ const handleSortFilterItemChange = (filterItem: {
}) => { }) => {
selectedSortFilterItem.value = filterItem; selectedSortFilterItem.value = filterItem;
selectedCommentNames.value = []; selectedCommentNames.value = [];
handleFetchComments({ page: 1 }); page.value = 1;
}; };
function handleSelectUser(user: User | undefined) { function handleSelectUser(user: User | undefined) {
selectedUser.value = user; selectedUser.value = user;
handleFetchComments({ page: 1 }); page.value = 1;
} }
function handleKeywordChange() { function handleKeywordChange() {
@ -276,12 +107,12 @@ function handleKeywordChange() {
if (keywordNode) { if (keywordNode) {
keyword.value = keywordNode._value as string; keyword.value = keywordNode._value as string;
} }
handleFetchComments({ page: 1 }); page.value = 1;
} }
function handleClearKeyword() { function handleClearKeyword() {
keyword.value = ""; keyword.value = "";
handleFetchComments({ page: 1 }); page.value = 1;
} }
const hasFilters = computed(() => { const hasFilters = computed(() => {
@ -298,8 +129,148 @@ function handleClearFilters() {
selectedSortFilterItem.value = SortFilterItems[0]; selectedSortFilterItem.value = SortFilterItems[0];
selectedUser.value = undefined; selectedUser.value = undefined;
keyword.value = ""; keyword.value = "";
handleFetchComments({ page: 1 }); page.value = 1;
} }
const page = ref(1);
const size = ref(20);
const total = ref(0);
const {
data: comments,
isLoading,
isFetching,
refetch,
} = useQuery<ListedComment[]>({
queryKey: [
"comments",
page,
size,
selectedApprovedFilterItem,
selectedSortFilterItem,
selectedUser,
keyword,
],
queryFn: async () => {
const { data } = await apiClient.comment.listComments({
page: page.value,
size: size.value,
approved: selectedApprovedFilterItem.value.value,
sort: selectedSortFilterItem.value.value,
keyword: keyword.value,
ownerName: selectedUser.value?.metadata.name,
});
total.value = data.total;
return data.items;
},
refetchOnWindowFocus: false,
refetchInterval(data) {
const deletingComments = data?.filter(
(comment) => !!comment.comment.metadata.deletionTimestamp
);
return deletingComments?.length ? 3000 : false;
},
});
// Selection
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedCommentNames.value =
comments.value?.map((comment) => {
return comment.comment.metadata.name;
}) || [];
} else {
selectedCommentNames.value = [];
}
};
const checkSelection = (comment: ListedComment) => {
return (
comment.comment.metadata.name ===
selectedComment.value?.comment.metadata.name ||
selectedCommentNames.value.includes(comment.comment.metadata.name)
);
};
watch(
() => selectedCommentNames.value,
(newValue) => {
checkAll.value = newValue.length === comments.value?.length;
}
);
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "确定要删除所选的评论吗?",
description: "将同时删除所有评论下的回复,该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
const promises = selectedCommentNames.value.map((name) => {
return apiClient.extension.comment.deletecontentHaloRunV1alpha1Comment(
{
name,
}
);
});
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success("删除成功");
} catch (e) {
console.error("Failed to delete comments", e);
} finally {
refetch();
}
},
});
};
const handleApproveInBatch = async () => {
Dialog.warning({
title: "确定要审核通过所选的评论吗?",
onConfirm: async () => {
try {
const commentsToUpdate = comments.value?.filter((comment) => {
return (
selectedCommentNames.value.includes(
comment.comment.metadata.name
) && !comment.comment.spec.approved
);
});
const promises = commentsToUpdate?.map((comment) => {
return apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment(
{
name: comment.comment.metadata.name,
comment: {
...comment.comment,
spec: {
...comment.comment.spec,
approved: true,
// TODO: see https://github.com/halo-dev/halo/pull/2746
approvedTime: new Date().toISOString(),
},
},
}
);
});
await Promise.all(promises || []);
selectedCommentNames.value = [];
Toast.success("操作成功");
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
refetch();
}
},
});
};
</script> </script>
<template> <template>
<VPageHeader title="评论"> <VPageHeader title="评论">
@ -463,11 +434,11 @@ function handleClearFilters() {
<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="handleFetchComments()" @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>
@ -477,12 +448,12 @@ function handleClearFilters() {
</div> </div>
</div> </div>
</template> </template>
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!comments.items.length" appear name="fade"> <Transition v-else-if="!comments?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者修改筛选条件" title="当前没有评论"> <VEmpty message="你可以尝试刷新或者修改筛选条件" title="当前没有评论">
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchComments"></VButton> <VButton @click="refetch"></VButton>
</VSpace> </VSpace>
</template> </template>
</VEmpty> </VEmpty>
@ -492,14 +463,11 @@ function handleClearFilters() {
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 <li v-for="comment in comments" :key="comment.comment.metadata.name">
v-for="comment in comments.items"
:key="comment.comment.metadata.name"
>
<CommentListItem <CommentListItem
:comment="comment" :comment="comment"
:is-selected="checkSelection(comment)" :is-selected="checkSelection(comment)"
@reload="handleFetchComments({ mute: true })" @reload="refetch()"
> >
<template #checkbox> <template #checkbox>
<input <input
@ -518,11 +486,10 @@ function handleClearFilters() {
<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="comments.page" v-model:page="page"
:size="comments.size" v-model:size="size"
:total="comments.total" :total="total"
:size-options="[20, 30, 50, 100]" :size-options="[20, 30, 50, 100]"
@change="handlePaginationChange"
/> />
</div> </div>
</template> </template>

View File

@ -22,12 +22,13 @@ import type {
SinglePage, SinglePage,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { computed, onMounted, provide, ref, watch, type Ref } from "vue"; import { computed, provide, ref, watch, type Ref } from "vue";
import ReplyListItem from "./ReplyListItem.vue"; import ReplyListItem from "./ReplyListItem.vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { onBeforeRouteLeave, type RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { useQuery } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -46,13 +47,10 @@ const emit = defineEmits<{
(event: "reload"): void; (event: "reload"): void;
}>(); }>();
const replies = ref<ListedReply[]>([] as ListedReply[]);
const selectedReply = ref<ListedReply>(); const selectedReply = ref<ListedReply>();
const hoveredReply = ref<ListedReply>(); const hoveredReply = ref<ListedReply>();
const loading = ref(false);
const showReplies = ref(false); const showReplies = ref(false);
const replyModal = ref(false); const replyModal = ref(false);
const refreshInterval = ref();
provide<Ref<ListedReply | undefined>>("hoveredReply", hoveredReply); provide<Ref<ListedReply | undefined>>("hoveredReply", hoveredReply);
@ -82,26 +80,30 @@ const handleApproveReplyInBatch = async () => {
title: "确定要审核通过该评论的所有回复吗?", title: "确定要审核通过该评论的所有回复吗?",
onConfirm: async () => { onConfirm: async () => {
try { try {
const repliesToUpdate = replies.value.filter((reply) => { const repliesToUpdate = replies.value?.filter((reply) => {
return !reply.reply.spec.approved; return !reply.reply.spec.approved;
}); });
const promises = repliesToUpdate.map((reply) => { const promises = repliesToUpdate?.map((reply) => {
const replyToUpdate = reply.reply;
replyToUpdate.spec.approved = true;
// TODO: see https://github.com/halo-dev/halo/pull/2746
replyToUpdate.spec.approvedTime = new Date().toISOString();
return apiClient.extension.reply.updatecontentHaloRunV1alpha1Reply({ return apiClient.extension.reply.updatecontentHaloRunV1alpha1Reply({
name: replyToUpdate.metadata.name, name: reply.reply.metadata.name,
reply: replyToUpdate, reply: {
...reply.reply,
spec: {
...reply.reply.spec,
approved: true,
// TODO: see https://github.com/halo-dev/halo/pull/2746
approvedTime: new Date().toISOString(),
},
},
}); });
}); });
await Promise.all(promises); await Promise.all(promises || []);
Toast.success("操作成功"); Toast.success("操作成功");
} catch (e) { } catch (e) {
console.error("Failed to approve comment replies in batch", e); console.error("Failed to approve comment replies in batch", e);
} finally { } finally {
await handleFetchReplies(); await refetch();
} }
}, },
}); });
@ -126,66 +128,46 @@ const handleApprove = async () => {
} }
}; };
const handleFetchReplies = async (options?: { mute?: boolean }) => { const {
try { data: replies,
clearInterval(refreshInterval.value); isLoading,
refetch,
if (!options?.mute) { } = useQuery<ListedReply[]>({
loading.value = true; queryKey: [
} "comment-replies",
props.comment.comment.metadata.name,
showReplies,
],
queryFn: async () => {
const { data } = await apiClient.reply.listReplies({ const { data } = await apiClient.reply.listReplies({
commentName: props.comment.comment.metadata.name, commentName: props.comment.comment.metadata.name,
page: 0, page: 0,
size: 0, size: 0,
}); });
replies.value = data.items; return data.items;
},
const deletedReplies = replies.value.filter( refetchOnWindowFocus: false,
refetchInterval(data) {
const deletingReplies = data?.filter(
(reply) => !!reply.reply.metadata.deletionTimestamp (reply) => !!reply.reply.metadata.deletionTimestamp
); );
return deletingReplies?.length ? 3000 : false;
if (deletedReplies.length) { },
refreshInterval.value = setInterval(() => { enabled: computed(() => showReplies.value),
handleFetchReplies({ mute: true });
}, 3000);
}
} catch (error) {
console.error("Failed to fetch comment replies", error);
} finally {
loading.value = false;
}
};
onMounted(() => {
clearInterval(refreshInterval.value);
}); });
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
});
watch(
() => showReplies.value,
(newValue) => {
if (newValue) {
handleFetchReplies();
} else {
replies.value = [];
}
}
);
const handleToggleShowReplies = async () => { const handleToggleShowReplies = async () => {
showReplies.value = !showReplies.value; showReplies.value = !showReplies.value;
if (showReplies.value) { if (showReplies.value) {
// update last read time // update last read time
const commentToUpdate = cloneDeep(props.comment.comment); if (props.comment.comment.status?.unreadReplyCount) {
commentToUpdate.spec.lastReadTime = new Date().toISOString(); const commentToUpdate = cloneDeep(props.comment.comment);
await apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment({ commentToUpdate.spec.lastReadTime = new Date().toISOString();
name: commentToUpdate.metadata.name, await apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment({
comment: commentToUpdate, name: commentToUpdate.metadata.name,
}); comment: commentToUpdate,
});
}
} else { } else {
emit("reload"); emit("reload");
} }
@ -202,7 +184,7 @@ const onTriggerReply = (reply: ListedReply) => {
const onReplyCreationModalClose = () => { const onReplyCreationModalClose = () => {
selectedReply.value = undefined; selectedReply.value = undefined;
handleFetchReplies({ mute: true }); refetch();
}; };
// Subject ref processing // Subject ref processing
@ -413,12 +395,12 @@ const subjectRefResult = computed(() => {
<div <div
class="ml-8 mt-3 divide-y divide-gray-100 rounded-base border-t border-gray-100 pt-3" class="ml-8 mt-3 divide-y divide-gray-100 rounded-base border-t border-gray-100 pt-3"
> >
<VLoading v-if="loading" /> <VLoading v-if="isLoading" />
<Transition v-else-if="!replies.length" appear name="fade"> <Transition v-else-if="!replies?.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者创建新回复" title="当前没有回复"> <VEmpty message="你可以尝试刷新或者创建新回复" title="当前没有回复">
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchReplies"></VButton> <VButton @click="refetch()"></VButton>
<VButton type="secondary" @click="replyModal = true"> <VButton type="secondary" @click="replyModal = true">
<template #icon> <template #icon>
<IconAddCircle class="h-full w-full" /> <IconAddCircle class="h-full w-full" />
@ -437,7 +419,7 @@ const subjectRefResult = computed(() => {
:class="{ 'hover:bg-white': showReplies }" :class="{ 'hover:bg-white': showReplies }"
:reply="reply" :reply="reply"
:replies="replies" :replies="replies"
@reload="handleFetchReplies({ mute: true })" @reload="refetch()"
@reply="onTriggerReply" @reply="onTriggerReply"
></ReplyListItem> ></ReplyListItem>
</div> </div>