mirror of https://github.com/halo-dev/halo
Comments now support rich text formatting display (#7674)
#### What type of PR is this? /area ui /kind feature /milestone 2.21.x #### What this PR does / why we need it: Comments now support rich text format display. Still need to: 1. Test for XSS vulnerabilities 2. Optimize content styling 3. Editor #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/7671 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 评论内容支持以富文本格式显示 ```pull/7681/head
parent
535fe01624
commit
09cd1f7f74
|
@ -19,9 +19,10 @@ import { useQueryClient } from "@tanstack/vue-query";
|
|||
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
|
||||
import { useSubjectRef } from "../composables/use-subject-ref";
|
||||
import CommentEditor from "./CommentEditor.vue";
|
||||
import OwnerButton from "./OwnerButton.vue";
|
||||
import ReplyFormItems from "./ReplyFormItems.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -48,10 +49,19 @@ const creationTime = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const newReply = ref("");
|
||||
const editorContent = ref("");
|
||||
const editorCharacterCount = ref(0);
|
||||
|
||||
function onCommentEditorUpdate(value: {
|
||||
content: string;
|
||||
characterCount: number;
|
||||
}) {
|
||||
editorContent.value = value.content;
|
||||
editorCharacterCount.value = value.characterCount;
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!newReply.value) {
|
||||
if (!editorCharacterCount.value) {
|
||||
await coreApiClient.content.comment.patchComment({
|
||||
name: props.comment.comment.metadata.name,
|
||||
jsonPatchInner: [
|
||||
|
@ -71,8 +81,8 @@ async function handleApprove() {
|
|||
await consoleApiClient.content.comment.createReply({
|
||||
name: props.comment?.comment.metadata.name as string,
|
||||
replyRequest: {
|
||||
raw: newReply.value,
|
||||
content: newReply.value,
|
||||
raw: editorContent.value,
|
||||
content: editorContent.value,
|
||||
allowNotification: true,
|
||||
quoteReply: undefined,
|
||||
},
|
||||
|
@ -88,6 +98,8 @@ const { subjectRefResult } = useSubjectRef(props.comment);
|
|||
const websiteOfAnonymous = computed(() => {
|
||||
return props.comment.comment.spec.owner.annotations?.["website"];
|
||||
});
|
||||
|
||||
const { data: contentProvider } = useContentProviderExtensionPoint();
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
|
@ -165,20 +177,17 @@ const websiteOfAnonymous = computed(() => {
|
|||
<VDescriptionItem
|
||||
:label="$t('core.comment.comment_detail_modal.fields.content')"
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm text-gray-900">{{
|
||||
comment.comment.spec.content
|
||||
}}</pre>
|
||||
<component
|
||||
:is="contentProvider?.component"
|
||||
:content="comment.comment.spec.content"
|
||||
/>
|
||||
</VDescriptionItem>
|
||||
<HasPermission :permissions="['system:comments:manage']">
|
||||
<VDescriptionItem
|
||||
v-if="!comment.comment.spec.approved"
|
||||
:label="$t('core.comment.detail_modal.fields.new_reply')"
|
||||
>
|
||||
<ReplyFormItems
|
||||
:required="false"
|
||||
:auto-focus="false"
|
||||
@update="newReply = $event"
|
||||
/>
|
||||
<CommentEditor @update="onCommentEditorUpdate" />
|
||||
</VDescriptionItem>
|
||||
</HasPermission>
|
||||
</VDescription>
|
||||
|
@ -192,7 +201,7 @@ const websiteOfAnonymous = computed(() => {
|
|||
@click="handleApprove"
|
||||
>
|
||||
{{
|
||||
newReply
|
||||
editorCharacterCount > 0
|
||||
? $t("core.comment.operations.reply_and_approve.button")
|
||||
: $t("core.comment.operations.approve.button")
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts" setup>
|
||||
import { usePluginModuleStore } from "@/stores/plugin";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
import type { CommentEditorProvider } from "@halo-dev/console-shared";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { markRaw } from "vue";
|
||||
import DefaultCommentEditor from "./DefaultCommentEditor.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
autoFocus?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autoFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const defaultProvider: CommentEditorProvider = {
|
||||
component: markRaw(DefaultCommentEditor),
|
||||
};
|
||||
|
||||
const { pluginModules } = usePluginModuleStore();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update", value: { content: string; characterCount: number }): void;
|
||||
}>();
|
||||
|
||||
const { data: provider, isLoading } = useQuery({
|
||||
queryKey: ["core:comment:provider"],
|
||||
queryFn: async () => {
|
||||
const result: CommentEditorProvider[] = [];
|
||||
for (const pluginModule of pluginModules) {
|
||||
const callbackFunction =
|
||||
pluginModule?.extensionPoints?.["comment:editor:replace"];
|
||||
|
||||
if (typeof callbackFunction !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = await callbackFunction();
|
||||
|
||||
result.push(item);
|
||||
}
|
||||
|
||||
if (result.length) {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
return defaultProvider;
|
||||
},
|
||||
});
|
||||
|
||||
function onUpdate(value: { content: string; characterCount: number }) {
|
||||
emit("update", value);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VLoading v-if="isLoading" />
|
||||
<component
|
||||
:is="provider?.component"
|
||||
v-else
|
||||
:auto-focus="autoFocus"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</template>
|
|
@ -27,6 +27,7 @@ import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
|||
import { computed, markRaw, provide, ref, type Ref, toRefs } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useCommentLastReadTimeMutate } from "../composables/use-comment-last-readtime-mutate";
|
||||
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
|
||||
import { useSubjectRef } from "../composables/use-subject-ref";
|
||||
import CommentDetailModal from "./CommentDetailModal.vue";
|
||||
import OwnerButton from "./OwnerButton.vue";
|
||||
|
@ -257,6 +258,8 @@ const { operationItems } = useOperationItemExtensionPoint<ListedComment>(
|
|||
},
|
||||
])
|
||||
);
|
||||
|
||||
const { data: contentProvider } = useContentProviderExtensionPoint();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -305,10 +308,10 @@ const { operationItems } = useOperationItemExtensionPoint<ListedComment>(
|
|||
<IconExternalLinkLine class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words text-sm text-gray-900"
|
||||
>{{ comment?.comment?.spec.content }}</pre
|
||||
>
|
||||
<component
|
||||
:is="contentProvider?.component"
|
||||
:content="comment?.comment?.spec.content"
|
||||
/>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
class="cursor-pointer select-none text-gray-700 hover:text-gray-900"
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts" setup>
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
defineProps<{
|
||||
content: string;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="comment-content markdown-body whitespace-pre-wrap rounded-lg !bg-transparent !text-sm !text-gray-900"
|
||||
v-html="sanitizeHtml(content)"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.comment-content :deep(ul) {
|
||||
list-style: disc !important;
|
||||
}
|
||||
|
||||
.comment-content :deep(ol) {
|
||||
list-style: decimal !important;
|
||||
}
|
||||
</style>
|
|
@ -2,22 +2,19 @@
|
|||
import { setFocus } from "@/formkit/utils/focus";
|
||||
import i18n from "@emoji-mart/data/i18n/zh.json";
|
||||
import { IconMotionLine, VDropdown } from "@halo-dev/components";
|
||||
import { Picker } from "emoji-mart";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
required?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}>(),
|
||||
{
|
||||
required: true,
|
||||
autoFocus: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update", value: string): void;
|
||||
(event: "update", value: { content: string; characterCount: number }): void;
|
||||
}>();
|
||||
|
||||
const emojiPickerRef = ref<HTMLElement | null>(null);
|
||||
|
@ -27,6 +24,7 @@ const handleCreateEmojiPicker = async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { Picker } = await import("emoji-mart");
|
||||
const data = await import("@emoji-mart/data");
|
||||
|
||||
const emojiPicker = new Picker({
|
||||
|
@ -56,7 +54,10 @@ onMounted(() => {
|
|||
watch(
|
||||
() => raw.value,
|
||||
(value) => {
|
||||
emit("update", value);
|
||||
emit("update", {
|
||||
content: value,
|
||||
characterCount: value.length,
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
@ -69,7 +70,6 @@ watch(
|
|||
:validation-label="$t('core.comment.reply_modal.fields.content.label')"
|
||||
:rows="6"
|
||||
value=""
|
||||
:validation="['length:0,1024', required ? 'required' : ''].join('|')"
|
||||
></FormKit>
|
||||
<div class="flex w-full justify-end sm:max-w-lg">
|
||||
<VDropdown :classes="['!p-0']" @show="handleCreateEmojiPicker">
|
|
@ -5,7 +5,7 @@ import { consoleApiClient } from "@halo-dev/api-client";
|
|||
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import ReplyFormItems from "./ReplyFormItems.vue";
|
||||
import CommentEditor from "./CommentEditor.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
@ -27,16 +27,18 @@ const emit = defineEmits<{
|
|||
|
||||
const modal = ref<InstanceType<typeof VModal> | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
const characterCount = ref(0);
|
||||
const content = ref("");
|
||||
|
||||
const onSubmit = async (data: { raw: string }) => {
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
|
||||
await consoleApiClient.content.comment.createReply({
|
||||
name: props.comment?.comment.metadata.name as string,
|
||||
replyRequest: {
|
||||
raw: data.raw,
|
||||
content: data.raw,
|
||||
raw: content.value,
|
||||
content: content.value,
|
||||
allowNotification: true,
|
||||
quoteReply: props.reply?.reply.metadata.name,
|
||||
},
|
||||
|
@ -53,35 +55,33 @@ const onSubmit = async (data: { raw: string }) => {
|
|||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function onUpdate(value: { content: string; characterCount: number }) {
|
||||
content.value = value.content;
|
||||
characterCount.value = value.characterCount;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:title="$t('core.comment.reply_modal.title')"
|
||||
:width="500"
|
||||
:width="600"
|
||||
:mount-to-body="true"
|
||||
:centered="false"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<FormKit
|
||||
id="create-reply-form"
|
||||
name="create-reply-form"
|
||||
type="form"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<ReplyFormItems />
|
||||
</FormKit>
|
||||
<div>
|
||||
<CommentEditor :auto-focus="true" @update="onUpdate" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<SubmitButton
|
||||
:loading="isSubmitting"
|
||||
type="secondary"
|
||||
:text="$t('core.common.buttons.submit')"
|
||||
@submit="$formkit.submit('create-reply-form')"
|
||||
:disabled="characterCount === 0"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
</SubmitButton>
|
||||
<VButton @click="modal?.close()">
|
||||
|
|
|
@ -18,11 +18,13 @@ import {
|
|||
} from "@halo-dev/components";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { computed, ref, useTemplateRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
|
||||
import { useSubjectRef } from "../composables/use-subject-ref";
|
||||
import CommentEditor from "./CommentEditor.vue";
|
||||
import OwnerButton from "./OwnerButton.vue";
|
||||
import ReplyFormItems from "./ReplyFormItems.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -53,10 +55,19 @@ const creationTime = computed(() => {
|
|||
);
|
||||
});
|
||||
|
||||
const newReply = ref("");
|
||||
const editorContent = ref("");
|
||||
const editorCharacterCount = ref(0);
|
||||
|
||||
function onCommentEditorUpdate(value: {
|
||||
content: string;
|
||||
characterCount: number;
|
||||
}) {
|
||||
editorContent.value = value.content;
|
||||
editorCharacterCount.value = value.characterCount;
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!newReply.value) {
|
||||
if (!editorCharacterCount.value) {
|
||||
await coreApiClient.content.reply.patchReply({
|
||||
name: props.reply.reply.metadata.name,
|
||||
jsonPatchInner: [
|
||||
|
@ -76,8 +87,8 @@ async function handleApprove() {
|
|||
await consoleApiClient.content.comment.createReply({
|
||||
name: props.comment?.comment.metadata.name as string,
|
||||
replyRequest: {
|
||||
raw: newReply.value,
|
||||
content: newReply.value,
|
||||
raw: editorContent.value,
|
||||
content: editorContent.value,
|
||||
allowNotification: true,
|
||||
quoteReply: props.reply.reply.metadata.name,
|
||||
},
|
||||
|
@ -95,6 +106,8 @@ const { subjectRefResult } = useSubjectRef(props.comment);
|
|||
const websiteOfAnonymous = computed(() => {
|
||||
return props.reply.reply.spec.owner.annotations?.["website"];
|
||||
});
|
||||
|
||||
const { data: contentProvider } = useContentProviderExtensionPoint();
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
|
@ -173,34 +186,40 @@ const websiteOfAnonymous = computed(() => {
|
|||
:label="$t('core.comment.reply_detail_modal.fields.original_comment')"
|
||||
>
|
||||
<OwnerButton :owner="comment.owner" />
|
||||
<pre
|
||||
class="mt-2 whitespace-pre-wrap break-words text-sm text-gray-900"
|
||||
>{{ comment.comment.spec.content }}</pre
|
||||
>
|
||||
<div class="mt-2">
|
||||
<component
|
||||
:is="contentProvider?.component"
|
||||
:content="comment.comment.spec.content"
|
||||
/>
|
||||
</div>
|
||||
</VDescriptionItem>
|
||||
<VDescriptionItem
|
||||
:label="$t('core.comment.reply_detail_modal.fields.content')"
|
||||
>
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words text-sm text-gray-900"
|
||||
><span
|
||||
v-if="quoteReply"
|
||||
v-tooltip="`${quoteReply.owner.displayName}: ${quoteReply.reply.spec.content}`"
|
||||
class="mr-1 inline-flex cursor-pointer flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
|
||||
>
|
||||
<IconReplyLine />
|
||||
<span>{{ quoteReply.owner.displayName }}</span>
|
||||
</span><br v-if="quoteReply" />{{ reply?.reply.spec.content }}</pre>
|
||||
<div>
|
||||
<span
|
||||
v-if="quoteReply"
|
||||
v-tooltip="{
|
||||
content: sanitizeHtml(
|
||||
`${quoteReply.owner.displayName}: ${quoteReply.reply.spec.content}`
|
||||
),
|
||||
html: true,
|
||||
}"
|
||||
class="mr-1 inline-flex cursor-pointer flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
|
||||
>
|
||||
<IconReplyLine />
|
||||
<span>{{ quoteReply.owner.displayName }}</span> </span
|
||||
><br v-if="quoteReply" /><component
|
||||
:is="contentProvider?.component"
|
||||
:content="reply?.reply.spec.content"
|
||||
/>
|
||||
</div>
|
||||
</VDescriptionItem>
|
||||
<VDescriptionItem
|
||||
v-if="!reply.reply.spec.approved"
|
||||
:label="$t('core.comment.detail_modal.fields.new_reply')"
|
||||
>
|
||||
<ReplyFormItems
|
||||
:required="false"
|
||||
:auto-focus="false"
|
||||
@update="newReply = $event"
|
||||
/>
|
||||
<CommentEditor @update="onCommentEditorUpdate" />
|
||||
</VDescriptionItem>
|
||||
</VDescription>
|
||||
</div>
|
||||
|
@ -212,7 +231,7 @@ const websiteOfAnonymous = computed(() => {
|
|||
@click="handleApprove"
|
||||
>
|
||||
{{
|
||||
newReply
|
||||
editorCharacterCount > 0
|
||||
? $t("core.comment.operations.reply_and_approve.button")
|
||||
: $t("core.comment.operations.approve.button")
|
||||
}}
|
||||
|
|
|
@ -21,6 +21,7 @@ import { useQueryClient } from "@tanstack/vue-query";
|
|||
import { computed, inject, markRaw, ref, type Ref, toRefs } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useCommentLastReadTimeMutate } from "../composables/use-comment-last-readtime-mutate";
|
||||
import { useContentProviderExtensionPoint } from "../composables/use-content-provider-extension-point";
|
||||
import OwnerButton from "./OwnerButton.vue";
|
||||
import ReplyCreationModal from "./ReplyCreationModal.vue";
|
||||
import ReplyDetailModal from "./ReplyDetailModal.vue";
|
||||
|
@ -193,6 +194,8 @@ const { operationItems } = useOperationItemExtensionPoint<ListedReply>(
|
|||
},
|
||||
])
|
||||
);
|
||||
|
||||
const { data: contentProvider } = useContentProviderExtensionPoint();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -227,18 +230,21 @@ const { operationItems } = useOperationItemExtensionPoint<ListedReply>(
|
|||
{{ $t("core.comment.text.replied_below") }}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words text-sm text-gray-900"
|
||||
><a
|
||||
v-if="quoteReply"
|
||||
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 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 }}</pre>
|
||||
<div>
|
||||
<a
|
||||
v-if="quoteReply"
|
||||
class="mr-1 inline-flex flex-row items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-200 hover:text-slate-800 hover:underline"
|
||||
href="javascript:void(0)"
|
||||
@mouseenter="handleShowQuoteReply(true)"
|
||||
@mouseleave="handleShowQuoteReply(false)"
|
||||
>
|
||||
<IconReplyLine />
|
||||
<span>{{ quoteReply.owner.displayName }}</span> </a
|
||||
><br v-if="quoteReply" /><component
|
||||
:is="contentProvider?.component"
|
||||
:content="reply?.reply.spec.content"
|
||||
/>
|
||||
</div>
|
||||
<HasPermission :permissions="['system:comments:manage']">
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { usePluginModuleStore } from "@/stores/plugin";
|
||||
import type { CommentContentProvider } from "@halo-dev/console-shared";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { markRaw } from "vue";
|
||||
import DefaultCommentContent from "../components/DefaultCommentContent.vue";
|
||||
|
||||
export function useContentProviderExtensionPoint() {
|
||||
const defaultProvider: CommentContentProvider = {
|
||||
component: markRaw(DefaultCommentContent),
|
||||
};
|
||||
|
||||
const { pluginModules } = usePluginModuleStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["core:comment:list-item:content:provider"],
|
||||
queryFn: async () => {
|
||||
const result: CommentContentProvider[] = [];
|
||||
for (const pluginModule of pluginModules) {
|
||||
const callbackFunction =
|
||||
pluginModule?.extensionPoints?.["comment:list-item:content:replace"];
|
||||
|
||||
if (typeof callbackFunction !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = await callbackFunction();
|
||||
|
||||
result.push(item);
|
||||
}
|
||||
|
||||
if (result.length) {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
return defaultProvider;
|
||||
},
|
||||
});
|
||||
}
|
|
@ -3,6 +3,7 @@ import HasPermission from "@/components/permission/HasPermission.vue";
|
|||
import { formatDatetime, relativeTimeTo } from "@/utils/date";
|
||||
import CommentDetailModal from "@console/modules/contents/comments/components/CommentDetailModal.vue";
|
||||
import OwnerButton from "@console/modules/contents/comments/components/OwnerButton.vue";
|
||||
import { useContentProviderExtensionPoint } from "@console/modules/contents/comments/composables/use-content-provider-extension-point";
|
||||
import { useSubjectRef } from "@console/modules/contents/comments/composables/use-subject-ref";
|
||||
import { coreApiClient, type ListedComment } from "@halo-dev/api-client";
|
||||
import {
|
||||
|
@ -63,6 +64,8 @@ const handleDelete = async () => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { data: contentProvider } = useContentProviderExtensionPoint();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -100,10 +103,10 @@ const handleDelete = async () => {
|
|||
<IconExternalLinkLine class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<pre
|
||||
class="line-clamp-4 whitespace-pre-wrap break-words text-sm text-gray-900"
|
||||
>{{ comment?.comment?.spec.content }}</pre
|
||||
>
|
||||
<component
|
||||
:is="contentProvider?.component"
|
||||
:content="comment?.comment?.spec.content"
|
||||
/>
|
||||
<HasPermission :permissions="['system:comments:manage']">
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# 评论列表内容显示扩展点
|
||||
|
||||
用于替换 Halo 在 Console 的默认评论列表内容显示组件。
|
||||
|
||||
> 注意:
|
||||
> 此扩展点并非通用扩展点,由于 Halo 早期设定,Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本渲染的功能,所以为了保持 Console 的评论列表内容显示与前台一致,所以专为此插件提供了替换输入框的扩展点。
|
||||
|
||||
## 定义方式
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import { markRaw } from "vue";
|
||||
import CommentContent from "./components/CommentContent.vue";
|
||||
|
||||
export default definePlugin({
|
||||
extensionPoints: {
|
||||
"comment:list-item:content:replace": () => {
|
||||
return {
|
||||
component: markRaw(CommentContent),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
其中,组件需要包含的 props 如下:
|
||||
|
||||
1. `content`:评论内容,`html` 格式。
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# 评论编辑器扩展点
|
||||
|
||||
用于替换 Halo 在 Console 的默认评论输入框。
|
||||
|
||||
> 注意:
|
||||
> 此扩展点并非通用扩展点,由于 Halo 早期设定,Halo 在前台的评论组件 UI 部分由 [评论组件插件](http://github.com/halo-dev/plugin-comment-widget) 提供,而在此插件的后续版本中提供了富文本编辑器的功能,所以为了保持 Console 的评论输入框与前台一致,所以专为此插件提供了替换输入框的扩展点。
|
||||
|
||||
## 定义方式
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import { markRaw } from "vue";
|
||||
import CommentEditor from "./components/CommentEditor.vue";
|
||||
|
||||
export default definePlugin({
|
||||
extensionPoints: {
|
||||
"comment:editor:replace": () => {
|
||||
return {
|
||||
component: markRaw(CommentEditor),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
其中,组件需要包含的 props 如下:
|
||||
|
||||
1. `autoFocus`:是否自动聚焦,需要在组件中判断是否为 `true`,然后聚焦输入框。
|
||||
|
||||
需要定义的 emit 如下:
|
||||
|
||||
1. `(event: "update", value: { content: string; characterCount: number })`:向调用方传递内容和字符数更新的事件。
|
|
@ -104,6 +104,7 @@
|
|||
"pretty-bytes": "^6.0.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"qs": "^6.11.1",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"short-unique-id": "^5.0.2",
|
||||
"transliteration": "^2.3.5",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
|
@ -127,6 +128,7 @@
|
|||
"@types/node": "^18.11.19",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/qs": "^6.9.7",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript/native-preview": "7.0.0-dev.20250619.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
export * from "./core/plugins";
|
||||
export * from "./states/attachment-selector";
|
||||
export * from "./states/backup";
|
||||
export * from "./states/comment";
|
||||
export * from "./states/comment-subject-ref";
|
||||
export * from "./states/editor";
|
||||
export * from "./states/entity";
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import type { Component, Raw } from "vue";
|
||||
|
||||
export interface CommentEditorProvider {
|
||||
component: Raw<Component>;
|
||||
}
|
||||
|
||||
export interface CommentContentProvider {
|
||||
component: Raw<Component>;
|
||||
}
|
|
@ -1,4 +1,8 @@
|
|||
import type { BackupTab } from "@/states/backup";
|
||||
import type {
|
||||
CommentContentProvider,
|
||||
CommentEditorProvider,
|
||||
} from "@/states/comment";
|
||||
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
|
||||
import type { EntityFieldItem } from "@/states/entity";
|
||||
import type { OperationItem } from "@/states/operation";
|
||||
|
@ -50,6 +54,14 @@ export interface ExtensionPoint {
|
|||
|
||||
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
|
||||
|
||||
"comment:editor:replace"?: () =>
|
||||
| CommentEditorProvider
|
||||
| Promise<CommentEditorProvider>;
|
||||
|
||||
"comment:list-item:content:replace"?: () =>
|
||||
| CommentContentProvider
|
||||
| Promise<CommentContentProvider>;
|
||||
|
||||
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
|
||||
|
||||
"plugin:installation:tabs:create"?: () =>
|
||||
|
|
|
@ -198,6 +198,9 @@ importers:
|
|||
qs:
|
||||
specifier: ^6.11.1
|
||||
version: 6.11.1
|
||||
sanitize-html:
|
||||
specifier: ^2.17.0
|
||||
version: 2.17.0
|
||||
short-unique-id:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
|
@ -262,6 +265,9 @@ importers:
|
|||
'@types/qs':
|
||||
specifier: ^6.9.7
|
||||
version: 6.9.7
|
||||
'@types/sanitize-html':
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
'@types/ua-parser-js':
|
||||
specifier: ^0.7.39
|
||||
version: 0.7.39
|
||||
|
@ -4452,6 +4458,9 @@ packages:
|
|||
'@types/retry@0.12.2':
|
||||
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||
|
||||
'@types/scheduler@0.16.8':
|
||||
resolution: {integrity: sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==}
|
||||
|
||||
|
@ -6116,6 +6125,9 @@ packages:
|
|||
dom-serializer@1.4.1:
|
||||
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
|
@ -6123,9 +6135,16 @@ packages:
|
|||
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
dot-case@3.0.4:
|
||||
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
|
||||
|
||||
|
@ -6987,6 +7006,9 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
http-cache-semantics@4.1.1:
|
||||
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
|
||||
|
||||
|
@ -8397,6 +8419,9 @@ packages:
|
|||
parse-path@7.0.0:
|
||||
resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==}
|
||||
|
||||
parse-srcset@1.0.2:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
parse-url@8.1.0:
|
||||
resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==}
|
||||
|
||||
|
@ -9389,6 +9414,9 @@ packages:
|
|||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||
|
||||
sass-embedded-android-arm64@1.83.0:
|
||||
resolution: {integrity: sha512-GBiCvM4a2rkWBLdYDxI6XYnprfk5U5c81g69RC2X6kqPuzxzx8qTArQ9M6keFK4+iDQ5N9QTwFCr0KbZTn+ZNQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
@ -10568,8 +10596,8 @@ packages:
|
|||
vue-component-type-helpers@2.0.19:
|
||||
resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==}
|
||||
|
||||
vue-component-type-helpers@3.0.4:
|
||||
resolution: {integrity: sha512-WtR3kPk8vqKYfCK/HGyT47lK/T3FaVyWxaCNuosaHYE8h9/k0lYRZ/PI/+T/z2wP+uuNKmL6z30rOcBboOu/YA==}
|
||||
vue-component-type-helpers@3.0.5:
|
||||
resolution: {integrity: sha512-uoNZaJ+a1/zppa/Vgmi8zIOP2PHXDN2rT8NyF+zQRK6ZG94lNB9prcV0GdLJbY9i9lrD47JOVIH92SaiA7oJ1A==}
|
||||
|
||||
vue-demi@0.13.11:
|
||||
resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
|
||||
|
@ -15484,7 +15512,7 @@ snapshots:
|
|||
ts-dedent: 2.2.0
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.16(typescript@5.8.3)
|
||||
vue-component-type-helpers: 3.0.4
|
||||
vue-component-type-helpers: 3.0.5
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
@ -15962,6 +15990,10 @@ snapshots:
|
|||
|
||||
'@types/retry@0.12.2': {}
|
||||
|
||||
'@types/sanitize-html@2.16.0':
|
||||
dependencies:
|
||||
htmlparser2: 8.0.2
|
||||
|
||||
'@types/scheduler@0.16.8': {}
|
||||
|
||||
'@types/semver@7.3.13': {}
|
||||
|
@ -17837,18 +17869,34 @@ snapshots:
|
|||
domhandler: 4.3.1
|
||||
entities: 2.2.0
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@4.3.1:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@2.8.0:
|
||||
dependencies:
|
||||
dom-serializer: 1.4.1
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 4.3.1
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
dot-case@3.0.4:
|
||||
dependencies:
|
||||
no-case: 3.0.4
|
||||
|
@ -18951,6 +18999,13 @@ snapshots:
|
|||
relateurl: 0.2.7
|
||||
terser: 5.37.0
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
http-cache-semantics@4.1.1: {}
|
||||
|
||||
http-errors@2.0.0:
|
||||
|
@ -20380,6 +20435,8 @@ snapshots:
|
|||
dependencies:
|
||||
protocols: 2.0.1
|
||||
|
||||
parse-srcset@1.0.2: {}
|
||||
|
||||
parse-url@8.1.0:
|
||||
dependencies:
|
||||
parse-path: 7.0.0
|
||||
|
@ -21444,6 +21501,15 @@ snapshots:
|
|||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sanitize-html@2.17.0:
|
||||
dependencies:
|
||||
deepmerge: 4.3.1
|
||||
escape-string-regexp: 4.0.0
|
||||
htmlparser2: 8.0.2
|
||||
is-plain-object: 5.0.0
|
||||
parse-srcset: 1.0.2
|
||||
postcss: 8.5.6
|
||||
|
||||
sass-embedded-android-arm64@1.83.0:
|
||||
optional: true
|
||||
|
||||
|
@ -22700,7 +22766,7 @@ snapshots:
|
|||
|
||||
vue-component-type-helpers@2.0.19: {}
|
||||
|
||||
vue-component-type-helpers@3.0.4: {}
|
||||
vue-component-type-helpers@3.0.5: {}
|
||||
|
||||
vue-demi@0.13.11(vue@3.5.16(typescript@5.8.3)):
|
||||
dependencies:
|
||||
|
|
|
@ -88,7 +88,6 @@ export function createViteConfig(options: Options) {
|
|||
"vue-grid-layout",
|
||||
"transliteration",
|
||||
"vue-draggable-plus",
|
||||
"emoji-mart",
|
||||
"colorjs.io",
|
||||
"overlayscrollbars",
|
||||
"overlayscrollbars-vue",
|
||||
|
|
Loading…
Reference in New Issue