mirror of https://github.com/halo-dev/halo-admin
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
parent
307cc2e318
commit
3fbe8bd0a5
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"),
|
||||
|
|
Loading…
Reference in New Issue