mirror of https://github.com/halo-dev/halo
feat: add comment management support (halo-dev/console#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/3445/head
parent
17759ee9a5
commit
dea60b0ebc
|
@ -25,6 +25,7 @@
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.0.6",
|
||||||
"@formkit/addons": "1.0.0-beta.10",
|
"@formkit/addons": "1.0.0-beta.10",
|
||||||
"@formkit/auto-animate": "1.0.0-beta.3",
|
"@formkit/auto-animate": "1.0.0-beta.3",
|
||||||
"@formkit/core": "1.0.0-beta.10",
|
"@formkit/core": "1.0.0-beta.10",
|
||||||
|
@ -33,7 +34,7 @@
|
||||||
"@formkit/themes": "1.0.0-beta.10",
|
"@formkit/themes": "1.0.0-beta.10",
|
||||||
"@formkit/vue": "1.0.0-beta.10",
|
"@formkit/vue": "1.0.0-beta.10",
|
||||||
"@halo-dev/admin-shared": "workspace:*",
|
"@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/components": "workspace:*",
|
||||||
"@halo-dev/richtext-editor": "^0.0.0-alpha.7",
|
"@halo-dev/richtext-editor": "^0.0.0-alpha.7",
|
||||||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||||
|
@ -43,6 +44,7 @@
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"colorjs.io": "^0.4.0",
|
"colorjs.io": "^0.4.0",
|
||||||
"dayjs": "^1.11.5",
|
"dayjs": "^1.11.5",
|
||||||
|
"emoji-mart": "^5.2.2",
|
||||||
"filepond": "^4.30.4",
|
"filepond": "^4.30.4",
|
||||||
"floating-vue": "2.0.0-beta.20",
|
"floating-vue": "2.0.0-beta.20",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { IconMore, VSpace } from "@halo-dev/components";
|
import { VSpace } from "../space";
|
||||||
|
import { IconMore } from "../../icons/icons";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -48,6 +49,9 @@ const classes = computed(() => {
|
||||||
</FloatingDropdown>
|
</FloatingDropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="$slots.footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -63,7 +63,7 @@ const emit = defineEmits<{
|
||||||
@apply inline-flex items-center;
|
@apply inline-flex items-center;
|
||||||
|
|
||||||
.entity-field-description {
|
.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 IconCalendar from "~icons/ri/calendar-line";
|
||||||
import IconLink from "~icons/ri/link";
|
import IconLink from "~icons/ri/link";
|
||||||
import IconUserLine from "~icons/ri/user-line";
|
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 {
|
export {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
|
@ -98,4 +101,7 @@ export {
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconUserLine,
|
IconUserLine,
|
||||||
|
IconMotionLine,
|
||||||
|
IconReplyLine,
|
||||||
|
IconExternalLinkLine,
|
||||||
};
|
};
|
||||||
|
|
|
@ -38,7 +38,6 @@
|
||||||
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
|
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@halo-dev/api-client": "^0.0.19",
|
|
||||||
"@halo-dev/components": "workspace:*",
|
"@halo-dev/components": "workspace:*",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"lodash.merge": "^4.6.2"
|
"lodash.merge": "^4.6.2"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ApiHaloRunV1alpha1SinglePageApi,
|
ApiHaloRunV1alpha1SinglePageApi,
|
||||||
ApiHaloRunV1alpha1ThemeApi,
|
ApiHaloRunV1alpha1ThemeApi,
|
||||||
ApiHaloRunV1alpha1UserApi,
|
ApiHaloRunV1alpha1UserApi,
|
||||||
|
ApiHaloRunV1alpha1ReplyApi,
|
||||||
ContentHaloRunV1alpha1CategoryApi,
|
ContentHaloRunV1alpha1CategoryApi,
|
||||||
ContentHaloRunV1alpha1CommentApi,
|
ContentHaloRunV1alpha1CommentApi,
|
||||||
ContentHaloRunV1alpha1PostApi,
|
ContentHaloRunV1alpha1PostApi,
|
||||||
|
@ -27,6 +28,7 @@ import {
|
||||||
V1alpha1RoleBindingApi,
|
V1alpha1RoleBindingApi,
|
||||||
V1alpha1SettingApi,
|
V1alpha1SettingApi,
|
||||||
V1alpha1UserApi,
|
V1alpha1UserApi,
|
||||||
|
ApiHaloRunV1alpha1CommentApi,
|
||||||
} from "@halo-dev/api-client";
|
} from "@halo-dev/api-client";
|
||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
@ -112,6 +114,8 @@ function setupApiClient(axios: AxiosInstance) {
|
||||||
post: new ApiHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
|
post: new ApiHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
|
||||||
singlePage: new ApiHaloRunV1alpha1SinglePageApi(undefined, apiUrl, axios),
|
singlePage: new ApiHaloRunV1alpha1SinglePageApi(undefined, apiUrl, axios),
|
||||||
content: new ApiHaloRunV1alpha1ContentApi(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:
|
specifiers:
|
||||||
'@changesets/cli': ^2.24.4
|
'@changesets/cli': ^2.24.4
|
||||||
|
'@emoji-mart/data': ^1.0.6
|
||||||
'@formkit/addons': 1.0.0-beta.10
|
'@formkit/addons': 1.0.0-beta.10
|
||||||
'@formkit/auto-animate': 1.0.0-beta.3
|
'@formkit/auto-animate': 1.0.0-beta.3
|
||||||
'@formkit/core': 1.0.0-beta.10
|
'@formkit/core': 1.0.0-beta.10
|
||||||
|
@ -13,7 +14,7 @@ importers:
|
||||||
'@formkit/themes': 1.0.0-beta.10
|
'@formkit/themes': 1.0.0-beta.10
|
||||||
'@formkit/vue': 1.0.0-beta.10
|
'@formkit/vue': 1.0.0-beta.10
|
||||||
'@halo-dev/admin-shared': workspace:*
|
'@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/components': workspace:*
|
||||||
'@halo-dev/richtext-editor': ^0.0.0-alpha.7
|
'@halo-dev/richtext-editor': ^0.0.0-alpha.7
|
||||||
'@iconify-json/mdi': ^1.1.33
|
'@iconify-json/mdi': ^1.1.33
|
||||||
|
@ -44,6 +45,7 @@ importers:
|
||||||
colorjs.io: ^0.4.0
|
colorjs.io: ^0.4.0
|
||||||
cypress: ^9.7.0
|
cypress: ^9.7.0
|
||||||
dayjs: ^1.11.5
|
dayjs: ^1.11.5
|
||||||
|
emoji-mart: ^5.2.2
|
||||||
eslint: ^8.23.0
|
eslint: ^8.23.0
|
||||||
eslint-plugin-cypress: ^2.12.1
|
eslint-plugin-cypress: ^2.12.1
|
||||||
eslint-plugin-vue: ^9.4.0
|
eslint-plugin-vue: ^9.4.0
|
||||||
|
@ -85,6 +87,7 @@ importers:
|
||||||
vuedraggable: ^4.1.0
|
vuedraggable: ^4.1.0
|
||||||
yaml: ^2.1.1
|
yaml: ^2.1.1
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@emoji-mart/data': 1.0.6
|
||||||
'@formkit/addons': 1.0.0-beta.10
|
'@formkit/addons': 1.0.0-beta.10
|
||||||
'@formkit/auto-animate': 1.0.0-beta.3
|
'@formkit/auto-animate': 1.0.0-beta.3
|
||||||
'@formkit/core': 1.0.0-beta.10
|
'@formkit/core': 1.0.0-beta.10
|
||||||
|
@ -93,7 +96,7 @@ importers:
|
||||||
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
|
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
|
||||||
'@formkit/vue': 1.0.0-beta.10_jhzixbi2r7n2xnmwczrcaimaey
|
'@formkit/vue': 1.0.0-beta.10_jhzixbi2r7n2xnmwczrcaimaey
|
||||||
'@halo-dev/admin-shared': link:packages/shared
|
'@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/components': link:packages/components
|
||||||
'@halo-dev/richtext-editor': 0.0.0-alpha.7_vue@3.2.39
|
'@halo-dev/richtext-editor': 0.0.0-alpha.7_vue@3.2.39
|
||||||
'@tiptap/extension-character-count': 2.0.0-beta.31
|
'@tiptap/extension-character-count': 2.0.0-beta.31
|
||||||
|
@ -103,6 +106,7 @@ importers:
|
||||||
axios: 0.27.2
|
axios: 0.27.2
|
||||||
colorjs.io: 0.4.0
|
colorjs.io: 0.4.0
|
||||||
dayjs: 1.11.5
|
dayjs: 1.11.5
|
||||||
|
emoji-mart: 5.2.2
|
||||||
filepond: 4.30.4
|
filepond: 4.30.4
|
||||||
floating-vue: 2.0.0-beta.20_vue@3.2.39
|
floating-vue: 2.0.0-beta.20_vue@3.2.39
|
||||||
lodash.clonedeep: 4.5.0
|
lodash.clonedeep: 4.5.0
|
||||||
|
@ -196,14 +200,12 @@ importers:
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@halo-dev/api-client': ^0.0.19
|
|
||||||
'@halo-dev/components': workspace:*
|
'@halo-dev/components': workspace:*
|
||||||
'@types/lodash.merge': ^4.6.7
|
'@types/lodash.merge': ^4.6.7
|
||||||
axios: ^0.27.2
|
axios: ^0.27.2
|
||||||
lodash.merge: ^4.6.2
|
lodash.merge: ^4.6.2
|
||||||
vite-plugin-dts: ^1.4.1
|
vite-plugin-dts: ^1.4.1
|
||||||
dependencies:
|
dependencies:
|
||||||
'@halo-dev/api-client': 0.0.19
|
|
||||||
'@halo-dev/components': link:../components
|
'@halo-dev/components': link:../components
|
||||||
axios: 0.27.2
|
axios: 0.27.2
|
||||||
lodash.merge: 4.6.2
|
lodash.merge: 4.6.2
|
||||||
|
@ -1740,6 +1742,10 @@ packages:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
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:
|
/@esbuild/linux-loong64/0.15.7:
|
||||||
resolution: {integrity: sha512-IKznSJOsVUuyt7cDzzSZyqBEcZe+7WlBqTVXiF1OXP/4Nm387ToaXZ0fyLwI1iBlI/bzpxVq411QE2/Bt2XWWw==}
|
resolution: {integrity: sha512-IKznSJOsVUuyt7cDzzSZyqBEcZe+7WlBqTVXiF1OXP/4Nm387ToaXZ0fyLwI1iBlI/bzpxVq411QE2/Bt2XWWw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -1880,8 +1886,8 @@ packages:
|
||||||
- windicss
|
- windicss
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/api-client/0.0.19:
|
/@halo-dev/api-client/0.0.22:
|
||||||
resolution: {integrity: sha512-5+tVk0BFeLXpqw5+yVl5b3CV0S0jjenA8/0Co8QdiQjspWmef1kXGzKpRKDBTqASTs6AOJk3cqSVoDrsVKftRg==}
|
resolution: {integrity: sha512-0RBc2N+oqPIy+fE6otp8cjeWTvBgjH986lzhz2T8Gr6Byzp0cHzj3Y3+aT6t39Wg4Ux+wPRSV+Fh0x8VA8Jz5A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/richtext-editor/0.0.0-alpha.7_vue@3.2.39:
|
/@halo-dev/richtext-editor/0.0.0-alpha.7_vue@3.2.39:
|
||||||
|
@ -4633,6 +4639,10 @@ packages:
|
||||||
batch-processor: 1.0.0
|
batch-processor: 1.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/emoji-mart/5.2.2:
|
||||||
|
resolution: {integrity: sha512-BvcrX+Ps9MxSVEjnvxupclU3MBD6WVC4WZOY26csfC6oFdaWpFhdrzeVNVBmCLPOmzY1SE0aAsqZJRNVbZ1yhQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/emoji-regex/8.0.0:
|
/emoji-regex/8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
export function setFocus(id: string) {
|
export function setFocus(id: string) {
|
||||||
const inputElement = document.getElementById(id);
|
const inputElement = document.getElementById(id);
|
||||||
if (inputElement instanceof HTMLInputElement) {
|
if (
|
||||||
|
inputElement instanceof HTMLInputElement ||
|
||||||
|
inputElement instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
const end = inputElement.value.length;
|
||||||
|
inputElement.setSelectionRange(end, end);
|
||||||
inputElement?.focus();
|
inputElement?.focus();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
|
@ -2,28 +2,236 @@
|
||||||
import {
|
import {
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
IconMessage,
|
IconMessage,
|
||||||
IconSettings,
|
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VPagination,
|
VPagination,
|
||||||
VSpace,
|
VSpace,
|
||||||
VAvatar,
|
IconCloseCircle,
|
||||||
|
VEmpty,
|
||||||
|
useDialog,
|
||||||
} from "@halo-dev/components";
|
} 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 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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VPageHeader title="评论">
|
<VPageHeader title="评论">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconMessage class="mr-2 self-center" />
|
<IconMessage class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
|
||||||
<VSpace>
|
|
||||||
<VButton type="default">回收站</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
|
|
||||||
<div class="m-0 md:m-4">
|
<div class="m-0 md:m-4">
|
||||||
|
@ -38,109 +246,190 @@ const checkAll = ref(false);
|
||||||
v-model="checkAll"
|
v-model="checkAll"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@change="handleCheckAllChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-1 sm:w-auto">
|
<div class="flex w-full flex-1 items-center sm:w-auto">
|
||||||
<FormKit
|
<div
|
||||||
v-if="!checkAll"
|
v-if="!selectedCommentNames.length"
|
||||||
placeholder="输入关键词搜索"
|
class="flex items-center gap-2"
|
||||||
type="text"
|
>
|
||||||
></FormKit>
|
<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>
|
<VSpace v-else>
|
||||||
<VButton type="default">设置</VButton>
|
<VButton type="secondary" @click="handleApproveInBatch">
|
||||||
<VButton type="danger">删除</VButton>
|
审核通过
|
||||||
|
</VButton>
|
||||||
|
<VButton type="danger" @click="handleDeleteInBatch">
|
||||||
|
删除
|
||||||
|
</VButton>
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex sm:mt-0">
|
<div class="mt-4 flex sm:mt-0">
|
||||||
<VSpace spacing="lg">
|
<VSpace spacing="lg">
|
||||||
<div
|
<FloatingDropdown>
|
||||||
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
|
<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>
|
<div
|
||||||
<span>
|
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
|
||||||
<IconArrowDown />
|
>
|
||||||
</span>
|
<span class="mr-0.5">评论者</span>
|
||||||
</div>
|
<span>
|
||||||
<div
|
<IconArrowDown />
|
||||||
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
|
</span>
|
||||||
>
|
</div>
|
||||||
<span class="mr-0.5">评论者</span>
|
</UserDropdownSelector>
|
||||||
<span>
|
<FloatingDropdown>
|
||||||
<IconArrowDown />
|
<div
|
||||||
</span>
|
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
|
||||||
</div>
|
>
|
||||||
<div
|
<span class="mr-0.5"> 排序 </span>
|
||||||
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
|
<span>
|
||||||
>
|
<IconArrowDown />
|
||||||
<span class="mr-0.5">排序</span>
|
</span>
|
||||||
<span>
|
</div>
|
||||||
<IconArrowDown />
|
<template #popper>
|
||||||
</span>
|
<div class="w-72 p-4">
|
||||||
</div>
|
<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>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
|
||||||
<li v-for="index in 10" :key="index">
|
<li v-for="(comment, index) in comments.items" :key="index">
|
||||||
<div
|
<CommentListItem
|
||||||
:class="{
|
:comment="comment"
|
||||||
'bg-gray-100': checkAll,
|
:is-selected="checkSelection(comment)"
|
||||||
}"
|
@reload="handleFetchComments"
|
||||||
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
|
||||||
>
|
>
|
||||||
<div
|
<template #checkbox>
|
||||||
v-show="checkAll"
|
<input
|
||||||
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
|
v-model="selectedCommentNames"
|
||||||
></div>
|
:value="comment?.comment?.metadata.name"
|
||||||
<div class="relative flex flex-row items-center">
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
<div class="mr-4 hidden items-center sm:flex">
|
name="comment-checkbox"
|
||||||
<input
|
type="checkbox"
|
||||||
v-model="checkAll"
|
/>
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
</template>
|
||||||
type="checkbox"
|
</CommentListItem>
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="bg-white sm:flex sm:items-center sm:justify-end">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VCard>
|
</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 = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
|
@ -5,7 +6,18 @@ module.exports = {
|
||||||
"./packages/shared/src/**/*.{vue,js,ts,jsx,tsx}",
|
"./packages/shared/src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
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: [
|
plugins: [
|
||||||
require("tailwindcss-safe-area"),
|
require("tailwindcss-safe-area"),
|
||||||
|
|
Loading…
Reference in New Issue