halo/ui/console-src/modules/contents/comments/CommentList.vue

412 lines
12 KiB
Vue
Raw Normal View History

<script lang="ts" setup>
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import type { ListedComment } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconMessage,
IconRefreshLine,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import CommentListItem from "./components/CommentListItem.vue";
const { t } = useI18n();
const checkAll = ref(false);
const selectedComment = ref<ListedComment>();
const selectedCommentNames = ref<string[]>([]);
const keyword = useRouteQuery<string>("keyword", "");
const selectedApprovedStatus = useRouteQuery<
string | undefined,
boolean | undefined
>("approved", undefined, {
transform: (value) => {
return value ? value === "true" : undefined;
},
});
const selectedSort = useRouteQuery<string | undefined>("sort");
const selectedUser = useRouteQuery<string | undefined>("user");
watch(
() => [
selectedApprovedStatus.value,
selectedSort.value,
selectedUser.value,
keyword.value,
],
() => {
page.value = 1;
}
);
const hasFilters = computed(() => {
return (
selectedApprovedStatus.value !== undefined ||
selectedSort.value ||
selectedUser.value
);
});
function handleClearFilters() {
selectedApprovedStatus.value = undefined;
selectedSort.value = undefined;
selectedUser.value = undefined;
}
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const total = ref(0);
const {
data: comments,
isLoading,
isFetching,
refetch,
} = useQuery<ListedComment[]>({
queryKey: [
"comments",
page,
size,
selectedApprovedStatus,
selectedSort,
selectedUser,
keyword,
],
queryFn: async () => {
const fieldSelectorMap: Record<string, string | boolean | undefined> = {
"spec.approved": selectedApprovedStatus.value,
};
const fieldSelector = Object.entries(fieldSelectorMap)
.map(([key, value]) => {
if (value !== undefined) {
return `${key}=${value}`;
}
})
.filter(Boolean) as string[];
const { data } = await consoleApiClient.content.comment.listComments({
fieldSelector,
page: page.value,
size: size.value,
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
ownerName: selectedUser.value,
// TODO: email users are not supported at the moment.
ownerKind: selectedUser.value ? "User" : undefined,
});
total.value = data.total;
return data.items;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(comment) => !!comment.comment.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : 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: t("core.comment.operations.delete_comment_in_batch.title"),
description: t(
"core.comment.operations.delete_comment_in_batch.description"
),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const promises = selectedCommentNames.value.map((name) => {
return coreApiClient.content.comment.deleteComment({
name,
});
});
await Promise.all(promises);
selectedCommentNames.value = [];
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete comments", e);
} finally {
refetch();
}
},
});
};
const handleApproveInBatch = async () => {
Dialog.warning({
title: t("core.comment.operations.approve_comment_in_batch.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
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 coreApiClient.content.comment.patchComment({
name: comment.comment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/approved",
value: true,
},
{
op: "add",
path: "/spec/approvedTime",
// TODO: 暂时由前端设置发布时间。see https://github.com/halo-dev/halo/pull/2746
value: new Date().toISOString(),
},
],
});
});
await Promise.all(promises || []);
selectedCommentNames.value = [];
Toast.success(t("core.common.toast.operation_success"));
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
refetch();
}
},
});
};
</script>
<template>
<VPageHeader :title="$t('core.comment.title')">
<template #icon>
<IconMessage class="mr-2 self-center" />
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:comments:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput
v-if="!selectedCommentNames.length"
v-model="keyword"
/>
<VSpace v-else>
<VButton type="secondary" @click="handleApproveInBatch">
{{
$t(
"core.comment.operations.approve_comment_in_batch.button"
)
}}
</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedApprovedStatus"
:label="$t('core.common.filters.labels.status')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
{
label: t('core.comment.filters.status.items.approved'),
value: true,
},
{
label: t(
'core.comment.filters.status.items.pending_review'
),
value: false,
},
]"
/>
<HasPermission :permissions="['system:users:view']">
<UserFilterDropdown
v-model="selectedUser"
:label="$t('core.comment.filters.owner.label')"
/>
</HasPermission>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t(
'core.comment.filters.sort.items.last_reply_time_desc'
),
value: 'status.lastReplyTime,desc',
},
{
label: t(
'core.comment.filters.sort.items.last_reply_time_asc'
),
value: 'status.lastReplyTime,asc',
},
{
label: t(
'core.comment.filters.sort.items.reply_count_desc'
),
value: 'status.replyCount,desc',
},
{
label: t('core.comment.filters.sort.items.reply_count_asc'),
value: 'status.replyCount,asc',
},
{
label: t(
'core.comment.filters.sort.items.create_time_desc'
),
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.comment.filters.sort.items.create_time_asc'),
value: 'metadata.creationTimestamp,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!comments?.length" appear name="fade">
<VEmpty
:message="$t('core.comment.empty.message')"
:title="$t('core.comment.empty.title')"
>
<template #actions>
<VButton @click="refetch">
{{ $t("core.common.buttons.refresh") }}
</VButton>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="comment in comments" :key="comment.comment.metadata.name">
<CommentListItem
:comment="comment"
:is-selected="checkSelection(comment)"
>
<template #checkbox>
<input
v-model="selectedCommentNames"
:value="comment?.comment?.metadata.name"
name="comment-checkbox"
type="checkbox"
/>
</template>
</CommentListItem>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>