Add subject-based comment list modal (#7681)

pull/7685/head
Ryan Wang 2025-08-13 15:43:50 +08:00 committed by GitHub
parent 2bcfbbc371
commit f5af5a1550
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 461 additions and 84 deletions

View File

@ -1,7 +1,7 @@
<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 { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconMessage,
@ -16,12 +16,12 @@ import {
VPagination,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { chunk } from "lodash-es";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import CommentListItem from "./components/CommentListItem.vue";
import useCommentsFetch from "./composables/use-comments-fetch";
const { t } = useI18n();
@ -72,58 +72,21 @@ const page = useRouteQuery<number>("page", 1, {
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const total = ref(0);
const {
data: comments,
isLoading,
isFetching,
refetch,
} = useQuery<ListedComment[]>({
queryKey: [
} = useCommentsFetch(
"core: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
keyword
);
return hasDeletingData ? 1000 : false;
},
});
// Selection
const handleCheckAllChange = (e: Event) => {
@ -131,7 +94,7 @@ const handleCheckAllChange = (e: Event) => {
if (checked) {
selectedCommentNames.value =
comments.value?.map((comment) => {
comments.value?.items.map((comment) => {
return comment.comment.metadata.name;
}) || [];
} else {
@ -146,7 +109,7 @@ const isSelection = (comment: ListedComment) => {
watch(
() => selectedCommentNames.value,
(newValue) => {
checkAll.value = newValue.length === comments.value?.length;
checkAll.value = newValue.length === comments.value?.items.length;
}
);
@ -192,7 +155,7 @@ const handleApproveInBatch = async () => {
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const commentsToUpdate = comments.value?.filter((comment) => {
const commentsToUpdate = comments.value?.items.filter((comment) => {
return (
selectedCommentNames.value.includes(
comment.comment.metadata.name
@ -370,7 +333,7 @@ const handleApproveInBatch = async () => {
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!comments?.length" appear name="fade">
<Transition v-else-if="!comments?.items.length" appear name="fade">
<VEmpty
:message="$t('core.comment.empty.message')"
:title="$t('core.comment.empty.title')"
@ -385,7 +348,7 @@ const handleApproveInBatch = async () => {
<Transition v-else appear name="fade">
<VEntityContainer>
<CommentListItem
v-for="comment in comments"
v-for="comment in comments.items"
:key="comment.comment.metadata.name"
:comment="comment"
:is-selected="isSelection(comment)"
@ -409,9 +372,11 @@ const handleApproveInBatch = async () => {
: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 })
$t('core.components.pagination.total_label', {
total: comments?.total || 0,
})
"
:total="total"
:total="comments?.total || 0"
:size-options="[20, 30, 50, 100]"
/>
</template>

View File

@ -275,7 +275,9 @@ const { data: contentProvider } = useContentProviderExtensionPoint();
/>
<VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:comments:manage'])"
v-if="
currentUserHasPermission(['system:comments:manage']) && $slots.checkbox
"
#checkbox
>
<slot name="checkbox" />

View File

@ -0,0 +1,192 @@
<script lang="ts" setup>
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import FilterDropdown from "@/components/filter/FilterDropdown.vue";
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import SearchInput from "@/components/input/SearchInput.vue";
import HasPermission from "@/components/permission/HasPermission.vue";
import {
IconRefreshLine,
VButton,
VEmpty,
VEntityContainer,
VLoading,
VPagination,
VSpace,
} from "@halo-dev/components";
import { computed, ref, toRefs, watch } from "vue";
import useCommentsFetch from "../composables/use-comments-fetch";
import CommentListItem from "./CommentListItem.vue";
const props = defineProps<{
subjectRefKey: string;
}>();
const { subjectRefKey } = toRefs(props);
const selectedApprovedStatus = ref();
const selectedSort = ref();
const selectedUser = ref();
const page = ref(1);
const size = ref(20);
const keyword = ref("");
const {
data: comments,
isLoading,
isFetching,
refetch,
} = useCommentsFetch(
"core:comments:with-subject",
page,
size,
selectedApprovedStatus,
selectedSort,
selectedUser,
keyword,
subjectRefKey
);
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;
}
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<SearchInput v-model="keyword" />
<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>
<VLoading v-if="isLoading" />
<Transition v-else-if="!comments?.items.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">
<div class="overflow-hidden rounded-base border">
<VEntityContainer>
<CommentListItem
v-for="comment in comments.items"
:key="comment.comment.metadata.name"
:comment="comment"
>
</CommentListItem>
</VEntityContainer>
</div>
</Transition>
<div class="mt-4">
<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: comments?.total || 0,
})
"
:total="comments?.total || 0"
:size-options="[20, 30, 50, 100]"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import { VButton, VModal } from "@halo-dev/components";
import { useTemplateRef } from "vue";
import SubjectQueryCommentList from "./SubjectQueryCommentList.vue";
const props = defineProps<{
subjectRefKey: string;
}>();
const emit = defineEmits<{
(event: "close"): void;
}>();
const modal = useTemplateRef<InstanceType<typeof VModal> | null>("modal");
</script>
<template>
<VModal
ref="modal"
:centered="false"
:width="1400"
:title="$t('core.comment.title')"
:layer-closable="true"
mount-to-body
@close="emit('close')"
>
<SubjectQueryCommentList :subject-ref-key="props.subjectRefKey" />
<template #footer>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</template>
</VModal>
</template>

View File

@ -0,0 +1,50 @@
import { consoleApiClient, type ListedCommentList } from "@halo-dev/api-client";
import { useQuery } from "@tanstack/vue-query";
import type { Ref } from "vue";
export default function useCommentsFetch(
queryKey: string,
page: Ref<number>,
size: Ref<number>,
approved: Ref<boolean | undefined>,
sort: Ref<string | undefined>,
user: Ref<string | undefined>,
keyword: Ref<string | undefined>,
subjectRefKey?: Ref<string | undefined>
) {
return useQuery<ListedCommentList>({
queryKey: [queryKey, page, size, approved, sort, user, keyword],
queryFn: async () => {
const fieldSelectorMap: Record<string, string | boolean | undefined> = {
"spec.approved": approved.value,
"spec.subjectRef": subjectRefKey?.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: [sort.value].filter(Boolean) as string[],
keyword: keyword.value,
ownerName: user.value,
ownerKind: user.value ? "User" : undefined,
});
return data;
},
refetchInterval(data) {
const hasDeletingData = data?.items.some(
(comment) => !!comment.comment.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
});
}

View File

@ -3,8 +3,14 @@ import { IconMessage } from "@halo-dev/components";
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import CommentList from "./CommentList.vue";
import SubjectQueryCommentList from "./components/SubjectQueryCommentList.vue";
import SubjectQueryCommentListModal from "./components/SubjectQueryCommentListModal.vue";
export default definePlugin({
components: {
SubjectQueryCommentList,
SubjectQueryCommentListModal,
},
routes: [
{
path: "/comments",

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { singlePageLabels } from "@/constants/labels";
import SubjectQueryCommentListModal from "@console/modules/contents/comments/components/SubjectQueryCommentListModal.vue";
import type { ListedSinglePage } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
@ -7,7 +8,8 @@ import {
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { computed } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
const props = withDefaults(
defineProps<{
@ -16,6 +18,8 @@ const props = withDefaults(
{}
);
const { t } = useI18n();
const externalUrl = computed(() => {
const { metadata, status } = props.singlePage.page;
if (metadata.labels?.[singlePageLabels.PUBLISHED] === "true") {
@ -23,6 +27,30 @@ const externalUrl = computed(() => {
}
return `/preview/singlepages/${metadata.name}`;
});
const commentSubjectRefKey = `content.halo.run/SinglePage/${props.singlePage.page.metadata.name}`;
const commentListVisible = ref(false);
const commentText = computed(() => {
const { totalComment, approvedComment } = props.singlePage.stats || {};
let text = t("core.page.list.fields.comments", {
comments: totalComment || 0,
});
if (!totalComment || !approvedComment) {
return text;
}
const pendingComments = totalComment - approvedComment;
if (pendingComments > 0) {
text += t("core.page.list.fields.comments-with-pending", {
count: pendingComments,
});
}
return text;
});
</script>
<template>
@ -66,15 +94,20 @@ const externalUrl = computed(() => {
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
<span
class="cursor-pointer text-xs text-gray-500 hover:text-gray-900 hover:underline"
@click="commentListVisible = true"
>
{{ commentText }}
</span>
</VSpace>
</div>
<SubjectQueryCommentListModal
v-if="commentListVisible"
:subject-ref-key="commentSubjectRefKey"
@close="commentListVisible = false"
/>
</template>
</VEntityField>
</template>

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import SubjectQueryCommentListModal from "@console/modules/contents/comments/components/SubjectQueryCommentListModal.vue";
import type { ListedPost } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
@ -7,7 +8,8 @@ import {
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { computed } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import PostTag from "../../tags/components/PostTag.vue";
const props = withDefaults(
@ -17,6 +19,8 @@ const props = withDefaults(
{}
);
const { t } = useI18n();
const externalUrl = computed(() => {
const { status, metadata } = props.post.post;
if (metadata.labels?.[postLabels.PUBLISHED] === "true") {
@ -24,6 +28,30 @@ const externalUrl = computed(() => {
}
return `/preview/posts/${metadata.name}`;
});
const commentSubjectRefKey = `content.halo.run/Post/${props.post.post.metadata.name}`;
const commentListVisible = ref(false);
const commentText = computed(() => {
const { totalComment, approvedComment } = props.post.stats || {};
let text = t("core.post.list.fields.comments", {
comments: totalComment || 0,
});
if (!totalComment || !approvedComment) {
return text;
}
const pendingComments = totalComment - approvedComment;
if (pendingComments > 0) {
text += t("core.post.list.fields.comments-with-pending", {
count: pendingComments,
});
}
return text;
});
</script>
<template>
@ -83,12 +111,11 @@ const externalUrl = computed(() => {
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
<span
class="cursor-pointer text-xs text-gray-500 hover:text-gray-900 hover:underline"
@click="commentListVisible = true"
>
{{ commentText }}
</span>
<span v-if="post.post.spec.pinned" class="text-xs text-gray-500">
{{ $t("core.post.list.fields.pinned") }}
@ -103,6 +130,12 @@ const externalUrl = computed(() => {
></PostTag>
</VSpace>
</div>
<SubjectQueryCommentListModal
v-if="commentListVisible"
:subject-ref-key="commentSubjectRefKey"
@close="commentListVisible = false"
/>
</template>
</VEntityField>
</template>

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import SubjectQueryCommentListModal from "@console/modules/contents/comments/components/SubjectQueryCommentListModal.vue";
import type { ListedPost } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
@ -9,7 +10,10 @@ import {
VSpace,
VTag,
} from "@halo-dev/components";
import { computed } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps<{
post: ListedPost;
@ -33,6 +37,33 @@ const datetime = computed(() => {
props.post.post.metadata.creationTimestamp
);
});
const commentSubjectRefKey = `content.halo.run/Post/${props.post.post.metadata.name}`;
const commentListVisible = ref(false);
const commentText = computed(() => {
const { totalComment, approvedComment } = props.post.stats || {};
let text = t("core.dashboard.widgets.presets.recent_published.comments", {
comments: totalComment || 0,
});
if (!totalComment || !approvedComment) {
return text;
}
const pendingComments = totalComment - approvedComment;
if (pendingComments > 0) {
text += t(
"core.dashboard.widgets.presets.recent_published.comments-with-pending",
{
count: pendingComments,
}
);
}
return text;
});
</script>
<template>
@ -54,14 +85,18 @@ const datetime = computed(() => {
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.dashboard.widgets.presets.recent_published.comments", {
comments: post.stats.totalComment || 0,
})
}}
<span
class="cursor-pointer text-xs text-gray-500 hover:text-gray-900 hover:underline"
@click="commentListVisible = true"
>
{{ commentText }}
</span>
</VSpace>
<SubjectQueryCommentListModal
v-if="commentListVisible"
:subject-ref-key="commentSubjectRefKey"
@close="commentListVisible = false"
/>
</template>
<template #extra>
<VSpace>

View File

@ -33,6 +33,7 @@ core:
recent_published:
empty:
title: No published posts
comments-with-pending: " ({count} pending comments)"
notification:
title: Notifications
empty:
@ -116,6 +117,7 @@ core:
fields:
schedule_publish:
tooltip: Schedule publish
comments-with-pending: ({count} pending comments)
settings:
fields:
publish_time:
@ -175,6 +177,10 @@ core:
fields:
prevent_parent_post_cascade_query: Prevent parent category from including this category and its subcategories in cascade post queries
hide_from_list: This category is hidden, This category and its subcategories, as well as its posts, will not be displayed in the front-end list. You need to actively visit the category archive page
page:
list:
fields:
comments-with-pending: " ({count} pending comments)"
page_editor:
actions:
snapshots: Snapshots
@ -375,10 +381,16 @@ core:
operations:
enable:
title: Enable
description: Are you sure you want to enable this user?
description: Are you sure you want to enable this user? Once enabled, the user will be able to log back into the system.
enable_in_batch:
title: Enable
description: Are you sure you want to enable the selected users? Once enabled, these users will be able to log back into the system.
disable:
title: Disable
description: Are you sure you want to disable this user? This user will not be able to log in after being disabled
description: Are you sure you want to disable this user? Once disabled, the user will no longer be able to log in.
disable_in_batch:
title: Disable
description: Are you sure you want to disable the selected users? Once disabled, these users will no longer be able to log in.
grant_permission_modal:
roles_preview:
all: The currently selected role contains all permissions

View File

@ -73,6 +73,7 @@ core:
comments: "{comments} Comments"
empty:
title: No published posts
comments-with-pending: " ({count} pending comments)"
notification:
title: Notifications
empty:
@ -238,6 +239,7 @@ core:
pinned: Pinned
schedule_publish:
tooltip: Schedule publish
comments-with-pending: " ({count} pending comments)"
settings:
title: Settings
groups:
@ -404,7 +406,10 @@ core:
help: Theme adaptation is required to support
description:
label: Description
help: "The description will be automatically added to the page's meta description tag for SEO; other display purposes require theme adaptation"
help: >-
The description will be automatically added to the page's meta
description tag for SEO; other display purposes require theme
adaptation
prevent_parent_post_cascade_query:
label: Prevent Parent Post Cascade Query
help: >-
@ -473,6 +478,7 @@ core:
fields:
visits: "{visits} Visits"
comments: "{comments} Comments"
comments-with-pending: " ({count} pending comments)"
settings:
title: Settings
groups:
@ -1143,19 +1149,23 @@ core:
enable:
title: Enable
description: >-
Are you sure you want to enable this user? Once enabled, the user will be able to log back into the system.
Are you sure you want to enable this user? Once enabled, the user will
be able to log back into the system.
enable_in_batch:
title: Enable
description: >-
Are you sure you want to enable the selected users? Once enabled, these users will be able to log back into the system.
Are you sure you want to enable the selected users? Once enabled,
these users will be able to log back into the system.
disable:
title: Disable
description: >-
Are you sure you want to disable this user? Once disabled, the user will no longer be able to log in.
Are you sure you want to disable this user? Once disabled, the user
will no longer be able to log in.
disable_in_batch:
title: Disable
description: >-
Are you sure you want to disable the selected users? Once disabled, these users will no longer be able to log in.
Are you sure you want to disable the selected users? Once disabled,
these users will no longer be able to log in.
filters:
role:
label: Role

View File

@ -67,6 +67,7 @@ core:
comments: 评论 {comments}
empty:
title: 暂无已发布文章
comments-with-pending: " ({count} 条待审核)"
notification:
title: 通知
empty:
@ -224,6 +225,7 @@ core:
pinned: 已置顶
schedule_publish:
tooltip: 定时发布
comments-with-pending: {count} 条待审核)
settings:
title: 文章设置
groups:
@ -451,6 +453,7 @@ core:
fields:
visits: 访问量 {visits}
comments: 评论 {comments}
comments-with-pending: {count} 条待审核)
settings:
title: 页面设置
groups:

View File

@ -67,6 +67,7 @@ core:
comments: 留言 {comments}
empty:
title: 沒有已發布文章
comments-with-pending: " ({count} 條待審核)"
notification:
title: 通知
empty:
@ -224,6 +225,7 @@ core:
pinned: 已置頂
schedule_publish:
tooltip: 定時發佈
comments-with-pending: ({count} 條待審核)
settings:
title: 文章設置
groups:
@ -436,6 +438,7 @@ core:
fields:
visits: 訪問量 {visits}
comments: 留言 {comments}
comments-with-pending: " ({count} 條待審核)"
settings:
title: 頁面設置
groups: