feat: add comment management support (#612)

#### What type of PR is this?

/kind feature
/milestone 2.0

#### What this PR does / why we need it:

评论管理模块。适配 https://github.com/halo-dev/halo/pull/2412

#### Which issue(s) this PR fixes:

Fixes halo-dev/halo#2409

#### Screenshots:

#### Special notes for your reviewer:

测试方式:

1. 本地的 halo-admin 仓库需要 checkout 到当前分支。
2. 后端需要 checkout 到 <https://github.com/halo-dev/halo/pull/2412>。
3. 使用最新的主题:<https://github.com/halo-sigs/theme-anatole>。
4. 更新 halo-admin 的依赖和构建 packages:`pnpm install` `pnpm build:packages`
5. 启用主题之后在文章详情页面即可看到评论框。
6. 测试主题端的评论和后台评论的管理。

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

```release-note
None
```
pull/613/head
Ryan Wang 2022-09-19 17:26:50 +08:00 committed by GitHub
parent 307cc2e318
commit 3fbe8bd0a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1198 additions and 103 deletions

View File

@ -25,6 +25,7 @@
"packages/*"
],
"dependencies": {
"@emoji-mart/data": "^1.0.6",
"@formkit/addons": "1.0.0-beta.10",
"@formkit/auto-animate": "1.0.0-beta.3",
"@formkit/core": "1.0.0-beta.10",
@ -33,7 +34,7 @@
"@formkit/themes": "1.0.0-beta.10",
"@formkit/vue": "1.0.0-beta.10",
"@halo-dev/admin-shared": "workspace:*",
"@halo-dev/api-client": "^0.0.19",
"@halo-dev/api-client": "^0.0.22",
"@halo-dev/components": "workspace:*",
"@halo-dev/richtext-editor": "^0.0.0-alpha.7",
"@tiptap/extension-character-count": "2.0.0-beta.31",
@ -43,6 +44,7 @@
"axios": "^0.27.2",
"colorjs.io": "^0.4.0",
"dayjs": "^1.11.5",
"emoji-mart": "^5.2.2",
"filepond": "^4.30.4",
"floating-vue": "2.0.0-beta.20",
"lodash.clonedeep": "^4.5.0",

View File

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { IconMore, VSpace } from "@halo-dev/components";
import { VSpace } from "../space";
import { IconMore } from "../../icons/icons";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
@ -48,6 +49,9 @@ const classes = computed(() => {
</FloatingDropdown>
</div>
</div>
<div v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<style lang="scss">

View File

@ -63,7 +63,7 @@ const emit = defineEmits<{
@apply inline-flex items-center;
.entity-field-description {
@apply text-xs text-gray-500;
@apply text-xs text-gray-500 truncate;
}
}
}

View File

@ -47,6 +47,9 @@ import IconCharacterRecognition from "~icons/ri/character-recognition-line";
import IconCalendar from "~icons/ri/calendar-line";
import IconLink from "~icons/ri/link";
import IconUserLine from "~icons/ri/user-line";
import IconMotionLine from "~icons/ri/emotion-line";
import IconReplyLine from "~icons/ri/reply-line";
import IconExternalLinkLine from "~icons/ri/external-link-line";
export {
IconDashboard,
@ -98,4 +101,7 @@ export {
IconCalendar,
IconLink,
IconUserLine,
IconMotionLine,
IconReplyLine,
IconExternalLinkLine,
};

View File

@ -38,7 +38,6 @@
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
"license": "MIT",
"dependencies": {
"@halo-dev/api-client": "^0.0.19",
"@halo-dev/components": "workspace:*",
"axios": "^0.27.2",
"lodash.merge": "^4.6.2"

View File

@ -5,6 +5,7 @@ import {
ApiHaloRunV1alpha1SinglePageApi,
ApiHaloRunV1alpha1ThemeApi,
ApiHaloRunV1alpha1UserApi,
ApiHaloRunV1alpha1ReplyApi,
ContentHaloRunV1alpha1CategoryApi,
ContentHaloRunV1alpha1CommentApi,
ContentHaloRunV1alpha1PostApi,
@ -27,6 +28,7 @@ import {
V1alpha1RoleBindingApi,
V1alpha1SettingApi,
V1alpha1UserApi,
ApiHaloRunV1alpha1CommentApi,
} from "@halo-dev/api-client";
import type { AxiosInstance } from "axios";
import axios from "axios";
@ -112,6 +114,8 @@ function setupApiClient(axios: AxiosInstance) {
post: new ApiHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
singlePage: new ApiHaloRunV1alpha1SinglePageApi(undefined, apiUrl, axios),
content: new ApiHaloRunV1alpha1ContentApi(undefined, apiUrl, axios),
comment: new ApiHaloRunV1alpha1CommentApi(undefined, apiUrl, axios),
reply: new ApiHaloRunV1alpha1ReplyApi(undefined, apiUrl, axios),
};
}

View File

@ -5,6 +5,7 @@ importers:
.:
specifiers:
'@changesets/cli': ^2.24.4
'@emoji-mart/data': ^1.0.6
'@formkit/addons': 1.0.0-beta.10
'@formkit/auto-animate': 1.0.0-beta.3
'@formkit/core': 1.0.0-beta.10
@ -13,7 +14,7 @@ importers:
'@formkit/themes': 1.0.0-beta.10
'@formkit/vue': 1.0.0-beta.10
'@halo-dev/admin-shared': workspace:*
'@halo-dev/api-client': ^0.0.19
'@halo-dev/api-client': ^0.0.22
'@halo-dev/components': workspace:*
'@halo-dev/richtext-editor': ^0.0.0-alpha.7
'@iconify-json/mdi': ^1.1.33
@ -44,6 +45,7 @@ importers:
colorjs.io: ^0.4.0
cypress: ^9.7.0
dayjs: ^1.11.5
emoji-mart: ^5.2.2
eslint: ^8.23.0
eslint-plugin-cypress: ^2.12.1
eslint-plugin-vue: ^9.4.0
@ -85,6 +87,7 @@ importers:
vuedraggable: ^4.1.0
yaml: ^2.1.1
dependencies:
'@emoji-mart/data': 1.0.6
'@formkit/addons': 1.0.0-beta.10
'@formkit/auto-animate': 1.0.0-beta.3
'@formkit/core': 1.0.0-beta.10
@ -93,7 +96,7 @@ importers:
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
'@formkit/vue': 1.0.0-beta.10_jhzixbi2r7n2xnmwczrcaimaey
'@halo-dev/admin-shared': link:packages/shared
'@halo-dev/api-client': 0.0.19
'@halo-dev/api-client': 0.0.22
'@halo-dev/components': link:packages/components
'@halo-dev/richtext-editor': 0.0.0-alpha.7_vue@3.2.39
'@tiptap/extension-character-count': 2.0.0-beta.31
@ -103,6 +106,7 @@ importers:
axios: 0.27.2
colorjs.io: 0.4.0
dayjs: 1.11.5
emoji-mart: 5.2.2
filepond: 4.30.4
floating-vue: 2.0.0-beta.20_vue@3.2.39
lodash.clonedeep: 4.5.0
@ -196,14 +200,12 @@ importers:
packages/shared:
specifiers:
'@halo-dev/api-client': ^0.0.19
'@halo-dev/components': workspace:*
'@types/lodash.merge': ^4.6.7
axios: ^0.27.2
lodash.merge: ^4.6.2
vite-plugin-dts: ^1.4.1
dependencies:
'@halo-dev/api-client': 0.0.19
'@halo-dev/components': link:../components
axios: 0.27.2
lodash.merge: 4.6.2
@ -1740,6 +1742,10 @@ packages:
- supports-color
dev: true
/@emoji-mart/data/1.0.6:
resolution: {integrity: sha512-8wu3ec/kLCB0Y3K+pOKyY6Ob+xtQu3XhZvntdrpOTUQZ/PO6FW5PpFw7RE1kQ/up1fsVSJBl5mZ8Gs4SPwTYeg==}
dev: false
/@esbuild/linux-loong64/0.15.7:
resolution: {integrity: sha512-IKznSJOsVUuyt7cDzzSZyqBEcZe+7WlBqTVXiF1OXP/4Nm387ToaXZ0fyLwI1iBlI/bzpxVq411QE2/Bt2XWWw==}
engines: {node: '>=12'}
@ -1880,8 +1886,8 @@ packages:
- windicss
dev: false
/@halo-dev/api-client/0.0.19:
resolution: {integrity: sha512-5+tVk0BFeLXpqw5+yVl5b3CV0S0jjenA8/0Co8QdiQjspWmef1kXGzKpRKDBTqASTs6AOJk3cqSVoDrsVKftRg==}
/@halo-dev/api-client/0.0.22:
resolution: {integrity: sha512-0RBc2N+oqPIy+fE6otp8cjeWTvBgjH986lzhz2T8Gr6Byzp0cHzj3Y3+aT6t39Wg4Ux+wPRSV+Fh0x8VA8Jz5A==}
dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.7_vue@3.2.39:
@ -4633,6 +4639,10 @@ packages:
batch-processor: 1.0.0
dev: false
/emoji-mart/5.2.2:
resolution: {integrity: sha512-BvcrX+Ps9MxSVEjnvxupclU3MBD6WVC4WZOY26csfC6oFdaWpFhdrzeVNVBmCLPOmzY1SE0aAsqZJRNVbZ1yhQ==}
dev: false
/emoji-regex/8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true

View File

@ -1,7 +1,12 @@
export function setFocus(id: string) {
const inputElement = document.getElementById(id);
if (inputElement instanceof HTMLInputElement) {
if (
inputElement instanceof HTMLInputElement ||
inputElement instanceof HTMLTextAreaElement
) {
const timer = setTimeout(() => {
const end = inputElement.value.length;
inputElement.setSelectionRange(end, end);
inputElement?.focus();
clearTimeout(timer);
}, 0);

View File

@ -2,28 +2,236 @@
import {
IconArrowDown,
IconMessage,
IconSettings,
VButton,
VCard,
VPageHeader,
VPagination,
VSpace,
VAvatar,
IconCloseCircle,
VEmpty,
useDialog,
} from "@halo-dev/components";
import { ref } from "vue";
import CommentListItem from "./components/CommentListItem.vue";
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
import type {
ListedComment,
ListedCommentList,
User,
} from "@halo-dev/api-client";
import { onMounted, ref, watch } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
const dialog = useDialog();
const comments = ref<ListedCommentList>({
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
});
const loading = ref(false);
const checkAll = ref(false);
const selectedComment = ref<ListedComment>();
const selectedCommentNames = ref<string[]>([]);
const keyword = ref("");
const handleFetchComments = async () => {
try {
loading.value = true;
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,
ownerKind: "User",
ownerName: selectedUser.value?.metadata.name,
});
comments.value = data;
} catch (error) {
console.log("Failed to fetch comments", error);
} finally {
loading.value = false;
}
};
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 = [];
} 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;
return apiClient.extension.comment.updatecontentHaloRunV1alpha1Comment(
{
name: commentToUpdate.metadata.name,
comment: commentToUpdate,
}
);
});
await Promise.all(promises);
selectedCommentNames.value = [];
} catch (e) {
console.error("Failed to approve comments in batch", e);
} finally {
await handleFetchComments();
}
},
});
};
onMounted(handleFetchComments);
// Filters
const ApprovedFilterItems: { label: string; value?: boolean }[] = [
{
label: "全部",
value: undefined,
},
{
label: "已审核",
value: true,
},
{
label: "待审核",
value: false,
},
];
type Sort = "LAST_REPLY_TIME" | "REPLY_COUNT" | "CREATE_TIME";
const SortFilterItems: {
label: string;
value?: Sort;
}[] = [
{
label: "默认",
value: "LAST_REPLY_TIME",
},
{
label: "回复数",
value: "REPLY_COUNT",
},
{
label: "创建时间",
value: "CREATE_TIME",
},
];
const selectedApprovedFilterItem = ref<{ label: string; value?: boolean }>(
ApprovedFilterItems[0]
);
const selectedSortFilterItem = ref<{
label: string;
value?: Sort;
}>(SortFilterItems[0]);
const selectedUser = ref<User>();
const handleApprovedFilterItemChange = (filterItem: {
label: string;
value?: boolean;
}) => {
selectedApprovedFilterItem.value = filterItem;
selectedCommentNames.value = [];
handlePaginationChange({ page: 1, size: 20 });
};
const handleSortFilterItemChange = (filterItem: {
label: string;
value?: Sort;
}) => {
selectedSortFilterItem.value = filterItem;
selectedCommentNames.value = [];
handlePaginationChange({ page: 1, size: 20 });
};
function handleSelectUser(user: User | undefined) {
selectedUser.value = user;
handlePaginationChange({ page: 1, size: 20 });
}
</script>
<template>
<VPageHeader title="评论">
<template #icon>
<IconMessage class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton type="default">回收站</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
@ -38,109 +246,190 @@ const checkAll = ref(false);
v-model="checkAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 sm:w-auto">
<FormKit
v-if="!checkAll"
placeholder="输入关键词搜索"
type="text"
></FormKit>
<div class="flex w-full flex-1 items-center sm:w-auto">
<div
v-if="!selectedCommentNames.length"
class="flex items-center gap-2"
>
<FormKit
v-model="keyword"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchComments"
></FormKit>
<div
v-if="selectedApprovedFilterItem.value != undefined"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
>
<span class="text-xs text-gray-600 group-hover:text-gray-900">
状态{{ selectedApprovedFilterItem.label }}
</span>
<IconCloseCircle
class="h-4 w-4 text-gray-600"
@click="
handleApprovedFilterItemChange(ApprovedFilterItems[0])
"
/>
</div>
<div
v-if="selectedUser"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
>
<span class="text-xs text-gray-600 group-hover:text-gray-900">
评论者{{ selectedUser?.spec.displayName }}
</span>
<IconCloseCircle
class="h-4 w-4 text-gray-600"
@click="handleSelectUser(undefined)"
/>
</div>
<div
v-if="selectedSortFilterItem.value != 'LAST_REPLY_TIME'"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
>
<span class="text-xs text-gray-600 group-hover:text-gray-900">
排序{{ selectedSortFilterItem.label }}
</span>
<IconCloseCircle
class="h-4 w-4 text-gray-600"
@click="handleSortFilterItemChange(SortFilterItems[0])"
/>
</div>
</div>
<VSpace v-else>
<VButton type="default">设置</VButton>
<VButton type="danger">删除</VButton>
<VButton type="secondary" @click="handleApproveInBatch">
审核通过
</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
删除
</VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<div
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 状态 </span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(filterItem, index) in ApprovedFilterItems"
:key="index"
v-close-popper
:class="{
'bg-gray-100':
selectedApprovedFilterItem.value ===
filterItem.value,
}"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleApprovedFilterItemChange(filterItem)"
>
<span class="truncate">
{{ filterItem.label }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
<UserDropdownSelector
v-model:selected="selectedUser"
@select="handleSelectUser"
>
<span class="mr-0.5">状态</span>
<span>
<IconArrowDown />
</span>
</div>
<div
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">评论者</span>
<span>
<IconArrowDown />
</span>
</div>
<div
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span>
<IconArrowDown />
</span>
</div>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">评论者</span>
<span>
<IconArrowDown />
</span>
</div>
</UserDropdownSelector>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 排序 </span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(filterItem, index) in SortFilterItems"
:key="index"
v-close-popper
:class="{
'bg-gray-100':
selectedSortFilterItem.value === filterItem.value,
}"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleSortFilterItemChange(filterItem)"
>
<span class="truncate">
{{ filterItem.label }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</VSpace>
</div>
</div>
</div>
</template>
<VEmpty
v-if="!comments.items.length && !loading"
message="你可以尝试刷新或者修改筛选条件"
title="当前没有评论"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchComments"></VButton>
</VSpace>
</template>
</VEmpty>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="index in 10" :key="index">
<div
:class="{
'bg-gray-100': checkAll,
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
<li v-for="(comment, index) in comments.items" :key="index">
<CommentListItem
:comment="comment"
:is-selected="checkSelection(comment)"
@reload="handleFetchComments"
>
<div
v-show="checkAll"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="relative flex flex-row items-center">
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="checkAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
</div>
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
Ryan Wang
</span>
</div>
<div class="mt-1 flex">
<VSpace>
<p class="text-xs text-gray-500">评论测试</p>
</VSpace>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<VAvatar
size="xs"
src="https://ryanc.cc/avatar"
circle
></VAvatar>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
</time>
<span class="cursor-pointer">
<IconSettings />
</span>
</div>
</div>
</div>
</div>
<template #checkbox>
<input
v-model="selectedCommentNames"
:value="comment?.comment?.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
name="comment-checkbox"
type="checkbox"
/>
</template>
</CommentListItem>
</li>
</ul>
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination :page="1" :size="10" :total="20" />
<VPagination
:page="comments.page"
:size="comments.size"
:total="comments.total"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>

View File

@ -0,0 +1,396 @@
<script lang="ts" setup>
import {
useDialog,
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 "@halo-dev/admin-shared";
import type { RouteLocationRaw } from "vue-router";
import cloneDeep from "lodash.clonedeep";
const props = withDefaults(
defineProps<{
comment?: ListedComment;
isSelected?: boolean;
}>(),
{
comment: undefined,
isSelected: false,
}
);
const emit = defineEmits<{
(event: "reload"): void;
}>();
const dialog = useDialog();
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 #checkbox>
<slot name="checkbox" />
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar circle :src="comment?.owner.avatar" 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
:description="
formatDatetime(comment?.comment?.metadata.creationTimestamp)
"
>
</VEntityField>
</template>
<template #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>

View File

@ -0,0 +1,184 @@
<script lang="ts" setup>
import { VModal, VSpace, VButton, IconMotionLine } from "@halo-dev/components";
import type {
ListedComment,
ListedReply,
ReplyRequest,
} from "@halo-dev/api-client";
// @ts-ignore
import { Picker } from "emoji-mart";
import data from "@emoji-mart/data";
import i18n from "@emoji-mart/data/i18n/zh.json";
import { computed, nextTick, ref, watch, watchEffect } from "vue";
import { reset, submitForm } from "@formkit/core";
import cloneDeep from "lodash.clonedeep";
import { useMagicKeys } from "@vueuse/core";
import { setFocus } from "@/formkit/utils/focus";
import { apiClient } from "@halo-dev/admin-shared";
const props = withDefaults(
defineProps<{
visible?: boolean;
comment?: ListedComment;
reply?: ListedReply;
}>(),
{
visible: false,
comment: undefined,
reply: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const initialFormState: ReplyRequest = {
raw: "",
content: "",
allowNotification: true,
quoteReply: undefined,
};
const formState = ref<ReplyRequest>(cloneDeep(initialFormState));
const saving = ref(false);
watch(
() => formState.value.raw,
(newValue) => {
formState.value.content = newValue;
}
);
const formId = computed(() => {
return `comment-reply-form-${[
props.comment?.comment.metadata.name,
props.reply?.reply.metadata.name,
].join("-")}`;
});
const contentInputId = computed(() => {
return `content-input-${[
props.comment?.comment.metadata.name,
props.reply?.reply.metadata.name,
].join("-")}`;
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
reset(formId.value);
};
const { Command_Enter } = useMagicKeys();
watchEffect(() => {
if (Command_Enter.value && props.visible) {
submitForm(formId.value);
}
});
watch(
() => props.visible,
async (visible) => {
if (visible) {
await nextTick();
setFocus(contentInputId.value);
} else {
handleResetForm();
}
}
);
const handleCreateReply = async () => {
try {
saving.value = true;
if (props.reply) {
formState.value.quoteReply = props.reply.reply.metadata.name;
}
await apiClient.comment.createReply({
name: props.comment?.comment.metadata.name,
replyRequest: formState.value,
});
onVisibleChange(false);
} catch (error) {
console.error("Failed to create comment reply", error);
} finally {
saving.value = false;
}
};
// Emoji picker
const emojiPickerRef = ref<HTMLElement | null>(null);
const handleCreateEmojiPicker = () => {
const emojiPicker = new Picker({
data: data,
theme: "light",
autoFocus: true,
i18n: i18n,
onEmojiSelect: onEmojiSelect,
});
emojiPickerRef.value?.appendChild(emojiPicker);
};
const onEmojiSelect = (emoji: { native: string }) => {
formState.value.raw += emoji.native;
setFocus(contentInputId.value);
};
watchEffect(() => {
if (emojiPickerRef.value) {
handleCreateEmojiPicker();
}
});
</script>
<template>
<VModal
title="回复"
:visible="visible"
:width="600"
@update:visible="onVisibleChange"
>
<FormKit
:id="formId"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleCreateReply"
>
<FormKit
:id="contentInputId"
v-model="formState.raw"
type="textarea"
validation="required"
validation-label="内容"
></FormKit>
</FormKit>
<div class="mt-2 flex justify-end">
<FloatingDropdown>
<IconMotionLine
class="h-5 w-5 cursor-pointer text-gray-500 transition-all hover:text-gray-900"
/>
<template #popper>
<div ref="emojiPickerRef"></div>
</template>
</FloatingDropdown>
</div>
<template #footer>
<VSpace>
<VButton type="secondary" :loading="saving" @click="submitForm(formId)">
保存 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,184 @@
<script lang="ts" setup>
import {
VAvatar,
VButton,
VTag,
VEntityField,
VEntity,
useDialog,
VStatusDot,
IconReplyLine,
} from "@halo-dev/components";
import type { ListedReply } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date";
import { apiClient } from "@halo-dev/admin-shared";
import { computed, inject, type Ref } from "vue";
import cloneDeep from "lodash.clonedeep";
const props = withDefaults(
defineProps<{
reply?: ListedReply;
replies?: ListedReply[];
}>(),
{
reply: undefined,
replies: undefined,
}
);
const emit = defineEmits<{
(event: "reload"): void;
(event: "reply", reply: ListedReply): void;
}>();
const dialog = useDialog();
const quoteReply = computed(() => {
const { quoteReply: replyName } = props.reply.reply.spec;
if (!replyName) {
return undefined;
}
return props.replies?.find(
(reply) => reply.reply.metadata.name === replyName
);
});
const handleDelete = async () => {
dialog.warning({
title: "是否确认删除该回复?",
description: "该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.reply.deletecontentHaloRunV1alpha1Reply({
name: props.reply?.reply.metadata.name as string,
});
} catch (error) {
console.log("Failed to delete comment reply", error);
} finally {
emit("reload");
}
},
});
};
const handleApprove = async () => {
try {
const replyToUpdate = cloneDeep(props.reply.reply);
replyToUpdate.spec.approved = true;
await apiClient.extension.reply.updatecontentHaloRunV1alpha1Reply({
name: replyToUpdate.metadata.name,
reply: replyToUpdate,
});
} catch (error) {
console.error("Failed to approve comment reply", error);
} finally {
emit("reload");
}
};
const handleTriggerReply = () => {
emit("reply", props.reply);
};
// Show hovered reply
const hoveredReply = inject<Ref<ListedReply | undefined>>("hoveredReply");
const handleShowQuoteReply = (show: boolean) => {
if (hoveredReply) {
hoveredReply.value = show ? quoteReply.value : undefined;
}
};
const isHoveredReply = computed(() => {
return (
hoveredReply?.value?.reply.metadata.name === props.reply.reply.metadata.name
);
});
</script>
<template>
<VEntity class="!px-0 !py-2" :class="{ 'animate-breath': isHoveredReply }">
<template #start>
<VEntityField>
<template #description>
<VAvatar circle :src="reply?.owner.avatar" size="md"></VAvatar>
</template>
</VEntityField>
<VEntityField
class="w-28 min-w-[7rem]"
:title="reply?.owner.displayName"
:description="reply?.owner.email"
></VEntityField>
<VEntityField>
<template #description>
<div class="flex flex-col gap-2">
<div class="w-96 text-sm text-gray-800">
<p>
<a
v-if="quoteReply"
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-gray-200 py-0.5 px-1 text-xs font-medium text-gray-600 hover:text-blue-500 hover:underline"
href="javascript:void(0)"
@mouseenter="handleShowQuoteReply(true)"
@mouseleave="handleShowQuoteReply(false)"
>
<IconReplyLine />
<span>{{ quoteReply.owner.displayName }}</span>
</a>
<br v-if="quoteReply" />
{{ reply?.reply.spec.content }}
</p>
</div>
<div class="flex items-center gap-3 text-xs">
<span
class="select-none text-gray-700 hover:text-gray-900"
@click="handleTriggerReply"
>
回复
</span>
<div v-if="false" class="flex items-center">
<VTag>New</VTag>
</div>
</div>
</div>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="!reply?.reply.spec.approved">
<template #description>
<VStatusDot state="success">
<template #text>
<span class="text-xs text-gray-500">待审核</span>
</template>
</VStatusDot>
</template>
</VEntityField>
<VEntityField v-if="reply?.reply.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
</template>
</VEntityField>
<VEntityField
:description="formatDatetime(reply?.reply.metadata.creationTimestamp)"
>
</VEntityField>
</template>
<template #dropdownItems>
<VButton
v-if="!reply?.reply.spec.approved"
v-close-popper
type="secondary"
block
@click="handleApprove"
>
审核通过
</VButton>
<VButton v-close-popper block type="danger" @click="handleDelete">
删除
</VButton>
</template>
</VEntity>
</template>

View File

@ -1,3 +1,4 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./index.html",
@ -5,7 +6,18 @@ module.exports = {
"./packages/shared/src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
extend: {
animation: {
breath: "breath 1s ease-in-out infinite",
},
keyframes: {
breath: {
"0%": { transform: "scale(1)", opacity: 0.8 },
"50%": { transform: "scale(1.02)", opacity: 1 },
"100%": { transform: "scale(1)", opacity: 0.8 },
},
},
},
},
plugins: [
require("tailwindcss-safe-area"),