halo/src/modules/contents/comments/components/CommentListItem.vue

410 lines
11 KiB
Vue

<script lang="ts" setup>
import {
Dialog,
VAvatar,
VButton,
VEntity,
VEntityField,
VStatusDot,
VSpace,
VEmpty,
IconAddCircle,
IconExternalLinkLine,
} from "@halo-dev/components";
import ReplyCreationModal from "./ReplyCreationModal.vue";
import type {
Extension,
ListedComment,
ListedReply,
Post,
SinglePage,
} from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date";
import { computed, provide, ref, watch, type Ref } from "vue";
import ReplyListItem from "./ReplyListItem.vue";
import { apiClient } from "@/utils/api-client";
import type { RouteLocationRaw } from "vue-router";
import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission";
const { currentUserHasPermission } = usePermission();
const props = withDefaults(
defineProps<{
comment?: ListedComment;
isSelected?: boolean;
}>(),
{
comment: undefined,
isSelected: false,
}
);
const emit = defineEmits<{
(event: "reload"): void;
}>();
const replies = ref<ListedReply[]>([] as ListedReply[]);
const selectedReply = ref<ListedReply>();
const hoveredReply = ref<ListedReply>();
const loading = ref(false);
const showReplies = ref(false);
const replyModal = ref(false);
provide<Ref<ListedReply | undefined>>("hoveredReply", hoveredReply);
const handleDelete = async () => {
Dialog.warning({
title: "是否确认删除该评论?",
description: "删除评论的同时会删除该评论下的所有回复,该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.comment.deletecontentHaloRunV1alpha1Comment({
name: props.comment?.comment?.metadata.name as string,
});
} catch (error) {
console.log("Failed to delete comment", error);
} finally {
emit("reload");
}
},
});
};
const handleApproveReplyInBatch = async () => {
Dialog.warning({
title: "确定要审核通过该评论的所有回复吗?",
onConfirm: async () => {
try {
const repliesToUpdate = replies.value.filter((reply) => {
return !reply.reply.spec.approved;
});
const promises = repliesToUpdate.map((reply) => {
const replyToUpdate = reply.reply;
replyToUpdate.spec.approved = true;
return apiClient.extension.reply.updatecontentHaloRunV1alpha1Reply({
name: replyToUpdate.metadata.name,
reply: replyToUpdate,
});
});
await Promise.all(promises);
} catch (e) {
console.error("Failed to approve comment replies in batch", e);
} finally {
await handleFetchReplies();
}
},
});
};
const handleApprove = async () => {
try {
const commentToUpdate = cloneDeep(props.comment.comment);
commentToUpdate.spec.approved = true;
await apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment({
name: commentToUpdate.metadata.name,
comment: commentToUpdate,
});
} catch (error) {
console.error("Failed to approve comment", error);
} finally {
emit("reload");
}
};
const handleFetchReplies = async () => {
try {
loading.value = true;
const { data } = await apiClient.reply.listReplies({
commentName: props.comment.comment.metadata.name,
});
replies.value = data.items;
} catch (error) {
console.error("Failed to fetch comment replies", error);
} finally {
loading.value = false;
}
};
watch(
() => showReplies.value,
(newValue) => {
if (newValue) {
handleFetchReplies();
} else {
replies.value = [];
}
}
);
const handleToggleShowReplies = async () => {
showReplies.value = !showReplies.value;
if (showReplies.value) {
// update last read time
const commentToUpdate = cloneDeep(props.comment.comment);
commentToUpdate.spec.lastReadTime = new Date().toISOString();
await apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment({
name: commentToUpdate.metadata.name,
comment: commentToUpdate,
});
} else {
emit("reload");
}
};
const handleTriggerReply = () => {
replyModal.value = true;
};
const onTriggerReply = (reply: ListedReply) => {
selectedReply.value = reply;
replyModal.value = true;
};
const onReplyCreationModalClose = () => {
selectedReply.value = undefined;
handleFetchReplies();
};
// Subject ref processing
interface SubjectRefResult {
label: string;
title: string;
route?: RouteLocationRaw;
externalUrl?: string;
}
const SubjectRefProvider = ref<
Record<string, (subject: Extension) => SubjectRefResult>[]
>([
{
Post: (subject: Extension): SubjectRefResult => {
const post = subject as Post;
return {
label: "文章",
title: post.spec.title,
externalUrl: post.status?.permalink,
route: {
name: "PostEditor",
query: {
name: post.metadata.name,
},
},
};
},
},
{
SinglePage: (subject: Extension): SubjectRefResult => {
const singlePage = subject as SinglePage;
return {
label: "单页",
title: singlePage.spec.title,
externalUrl: singlePage.status?.permalink,
route: {
name: "SinglePageEditor",
query: {
name: singlePage.metadata.name,
},
},
};
},
},
]);
const subjectRefResult = computed(() => {
const { subject } = props.comment;
if (!subject) {
return {
label: "未知",
title: "未知",
};
}
const subjectRef = SubjectRefProvider.value.find((provider) =>
Object.keys(provider).includes(subject.kind)
);
if (!subjectRef) {
return {
label: "未知",
title: "未知",
};
}
return subjectRef[subject.kind](subject);
});
</script>
<template>
<ReplyCreationModal
:key="comment?.comment.metadata.name"
v-model:visible="replyModal"
:comment="comment"
:reply="selectedReply"
@close="onReplyCreationModalClose"
/>
<VEntity :is-selected="isSelected" :class="{ 'hover:bg-white': showReplies }">
<template v-if="showReplies" #prepend>
<div class="absolute inset-y-0 left-0 w-[1px] bg-black/50"></div>
<div class="absolute inset-y-0 right-0 w-[1px] bg-black/50"></div>
<div class="absolute inset-x-0 top-0 h-[1px] bg-black/50"></div>
<div class="absolute inset-x-0 bottom-0 h-[1px] bg-black/50"></div>
</template>
<template
v-if="currentUserHasPermission(['system:comments:manage'])"
#checkbox
>
<slot name="checkbox" />
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
circle
:src="comment?.owner.avatar"
:alt="comment?.owner.displayName"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
class="w-28 min-w-[7rem]"
:title="comment?.owner?.displayName"
:description="comment?.owner?.email"
:route="{
name: 'UserDetail',
params: {
name: comment?.owner?.name,
},
}"
></VEntityField>
<VEntityField>
<template #description>
<div class="flex flex-col gap-2">
<div class="w-1/2 text-sm text-gray-900">
{{ comment?.comment?.spec.content }}
</div>
<div class="flex items-center gap-3 text-xs">
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="handleToggleShowReplies"
>
{{ comment?.comment?.status?.replyCount || 0 }} 条回复
</span>
<VStatusDot
v-if="comment?.comment?.status?.unreadReplyCount || 0 > 0"
v-tooltip="`有新的回复`"
state="success"
animate
/>
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply"
>
回复
</span>
</div>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField
:title="subjectRefResult.title"
:description="subjectRefResult.label"
:route="subjectRefResult.route"
>
<template #extra>
<a
v-if="subjectRefResult.externalUrl"
:href="subjectRefResult.externalUrl"
target="_blank"
class="text-gray-600 hover:text-gray-900"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</template>
</VEntityField>
<VEntityField v-if="!comment?.comment.spec.approved">
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">待审核</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="comment?.comment?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="`删除中`" state="warning" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(comment?.comment?.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:comments:manage'])"
#dropdownItems
>
<VButton
v-if="!comment?.comment.spec.approved"
v-close-popper
type="secondary"
block
@click="handleApprove"
>
审核通过
</VButton>
<VButton
v-close-popper
type="secondary"
block
@click="handleApproveReplyInBatch"
>
审核通过所有回复
</VButton>
<VButton v-close-popper block type="danger" @click="handleDelete">
删除
</VButton>
</template>
<template v-if="showReplies" #footer>
<!-- Replies -->
<div
class="ml-8 mt-3 divide-y divide-gray-100 rounded-base border-t border-gray-100 pt-3"
>
<VEmpty
v-if="!replies.length && !loading"
message="你可以尝试刷新或者创建新回复"
title="当前没有回复"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchReplies">刷新</VButton>
<VButton type="secondary" @click="replyModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
创建新回复
</VButton>
</VSpace>
</template>
</VEmpty>
<ReplyListItem
v-for="reply in replies"
v-else
:key="reply.reply.metadata.name"
:class="{ 'hover:bg-white': showReplies }"
:reply="reply"
:replies="replies"
@reload="handleFetchReplies"
@reply="onTriggerReply"
></ReplyListItem>
</div>
</template>
</VEntity>
</template>