From 00650f487a957b5bf795f4aec4b2b97cbfc40094 Mon Sep 17 00:00:00 2001 From: zou Date: Fri, 28 Aug 2026 17:24:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(F006):=20=E5=AF=8C=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E4=BF=9D=E7=95=99=E5=85=AC=E4=BC=97=E5=8F=B7?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E6=8E=92=E7=89=88=20=E2=80=94=20customPaste?= =?UTF-8?q?=20=E8=BD=AC=E5=AD=98=E5=A4=96=E9=93=BE=E5=9B=BE+=E6=B8=85?= =?UTF-8?q?=E6=B4=97=E6=8F=92=E5=85=A5+=E5=8E=9F=E6=96=87=E4=BC=98?= =?UTF-8?q?=E5=85=88=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RichEditor/RichEditor.vue | 172 +++++++++++++----- .../other/information/InformationListView.vue | 5 +- 2 files changed, 128 insertions(+), 49 deletions(-) diff --git a/src/components/RichEditor/RichEditor.vue b/src/components/RichEditor/RichEditor.vue index 9a94e31..e4c6a6d 100644 --- a/src/components/RichEditor/RichEditor.vue +++ b/src/components/RichEditor/RichEditor.vue @@ -21,6 +21,10 @@ const model = defineModel({ default: "" }) const editorRef = shallowRef(null) const valueHtml = ref(model.value || "") +// 粘贴保留的原始 HTML (公众号排版完整版); 用户手动编辑后清空 +let rawPasteHtml = "" +// 标记是否为程序 setHtml (避免 onChange 清掉 rawPasteHtml) +let programmaticSet = false // === 单文件上传 (admin_new: /api 前缀 + access_token, 5MB) === async function uploadFile(file: File): Promise { @@ -44,7 +48,56 @@ async function uploadFile(file: File): Promise { throw new Error(data.msg || "图片上传失败") } -// === 图片上传配置 (工具栏选择/拖拽/粘贴位图统一走 customUpload) === +// === 外链图片转存: 公众号图片有防盗链, 下载后上传平台存储 === +async function remoteToLocal(src: string): Promise { + try { + const resp = await fetch(src, { referrerPolicy: "no-referrer", mode: "cors" }) + if (!resp.ok) return src + const blob = await resp.blob() + if (!blob.type.startsWith("image/")) return src + const file = new File([blob], "paste-image." + (blob.type.split("/")[1] || "png"), { type: blob.type }) + return await uploadFile(file) + } catch { + return src + } +} + +// === 处理粘贴 HTML: 转存外链图 -> 清洗危险/不兼容标签 -> 返回 [原始版, 编辑器版] === +async function processPasteHtml(html: string): Promise<{ raw: string; editorSafe: string }> { + const imgRe = /]+src=["\x27]([^"\x27]+)["\x27][^>]*>/gi + const srcs: string[] = [] + let m: RegExpExecArray | null + while ((m = imgRe.exec(html)) !== null) { + const src = m[1] + if (!srcs.includes(src)) srcs.push(src) + } + + let raw = html + let editorSafe = html + for (const src of srcs) { + if (!/^https?:\/\//.test(src)) continue + if (src.includes("/uploads/") || src.includes(location.hostname)) continue + const local = await remoteToLocal(src) + if (local !== src) { + raw = raw.split(src).join(local) + editorSafe = editorSafe.split(src).join(local) + } + } + + // 编辑器版: 移除 section/iframe/script/style/class 等 slate 不兼容或危险标签, 保留排版 + editorSafe = editorSafe + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(/\sclass=["\x27][^"\x27]*["\x27]/gi, "") + .replace(//gi, "
") + + return { raw, editorSafe } +} + +// === 图片上传配置 (工具栏选择/拖拽) === const editorConfig = { placeholder: "请输入资讯内容…支持直接粘贴公众号图文(保留排版,图片自动转存平台)", MENU_CONF: { @@ -60,62 +113,73 @@ const editorConfig = { }, }, }, - // 自定义粘贴: 处理剪贴板中的位图(截图) -> 上传平台后插入; 排版 HTML 交给编辑器默认逻辑 + // 自定义粘贴: ① HTML -> 转存外链图 + 保留原文(rawPasteHtml) + 插入清洗版(编辑器不崩) ② 位图 -> 上传插入 customPaste: (editor: any, event: ClipboardEvent, callback: (v: boolean) => void) => { - const items = event.clipboardData?.items - if (!items) return callback(true) // 无剪贴板数据, 走默认 - const imageFiles: File[] = [] - for (const it of Array.from(items)) { - if (it.type.startsWith("image/")) { - const f = it.getAsFile() - if (f) imageFiles.push(f) - } - } - // 无位图: 交给默认粘贴 (HTML 排版由 wangEditor 保留) - if (!imageFiles.length) return callback(true) + const cd = event.clipboardData + if (!cd) return callback(true) - // 有位图: 阻止默认, 上传后插入 - event.preventDefault() - ;(async () => { - for (const f of imageFiles) { - try { - const url = await uploadFile(f) - editor.insertNode({ type: "image", src: url, alt: "", href: "" }) - editor.insertNode({ type: "paragraph", children: [{ text: "" }] }) - } catch (e: any) { - alert(e?.message || "图片上传失败") + const html = cd.getData("text/html") + const items = cd.items + const imageFiles: File[] = [] + if (items) { + for (const it of Array.from(items)) { + if (it.type.startsWith("image/")) { + const f = it.getAsFile() + if (f) imageFiles.push(f) } } - })() - callback(false) + } + + if (html) { + event.preventDefault() + ;(async () => { + try { + const { raw, editorSafe } = await processPasteHtml(html) + // 保存原始 HTML: 提交时用 (公众号排版完整保留) + rawPasteHtml = raw + programmaticSet = true + editor.dangerouslyInsertHtml(editorSafe) + setTimeout(() => { programmaticSet = false }, 100) + } catch (e: any) { + console.error("paste html error:", e) + event.preventDefault() + editor.dangerouslyInsertHtml(html) + } + })() + callback(false) + return + } + + if (imageFiles.length) { + event.preventDefault() + ;(async () => { + for (const f of imageFiles) { + try { + const url = await uploadFile(f) + rawPasteHtml = "" + editor.insertNode({ type: "image", src: url, alt: "", href: "" }) + editor.insertNode({ type: "paragraph", children: [{ text: "" }] }) + } catch (e: any) { + alert(e?.message || "图片上传失败") + } + } + })() + callback(false) + return + } + + callback(true) }, } const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"], toolbarKeys: [ - "headerSelect", - "bold", - "italic", - "underline", - "through", - "color", - "bgColor", - "fontSize", - "fontFamily", - "lineHeight", - "justifyLeft", - "justifyCenter", - "justifyRight", - "bulletedList", - "numberedList", - "blockquote", - "codeBlock", - "insertLink", - "uploadImage", - "insertTable", - "undo", - "redo", + "headerSelect", "bold", "italic", "underline", "through", + "color", "bgColor", "fontSize", "fontFamily", "lineHeight", + "justifyLeft", "justifyCenter", "justifyRight", + "bulletedList", "numberedList", "blockquote", "codeBlock", + "insertLink", "uploadImage", "insertTable", "undo", "redo", ], } @@ -126,15 +190,29 @@ function handleCreated(editor: any) { function handleChange(editor: any) { valueHtml.value = editor.getHtml() model.value = valueHtml.value + // 用户手动编辑 (非程序 setHtml) -> 放弃 rawPasteHtml, 以编辑器内容为准 + if (!programmaticSet) { + rawPasteHtml = "" + } } // 外部 v-model 变化时同步 (编辑回显) watch(model, (v) => { if (v !== valueHtml.value && editorRef.value) { + programmaticSet = true editorRef.value.setHtml(v || "") + setTimeout(() => { programmaticSet = false }, 100) } }) +// === 对外: 获取最终内容 (粘贴原文优先, 编辑器修改后以编辑器为准) === +function getContent(): string { + if (rawPasteHtml) return rawPasteHtml + return editorRef.value ? editorRef.value.getHtml() : valueHtml.value +} + +defineExpose({ getContent }) + onBeforeUnmount(() => { if (editorRef.value) editorRef.value.destroy() }) diff --git a/src/modules/other/information/InformationListView.vue b/src/modules/other/information/InformationListView.vue index f70fd4c..e2f7e81 100644 --- a/src/modules/other/information/InformationListView.vue +++ b/src/modules/other/information/InformationListView.vue @@ -37,6 +37,7 @@ const categoryLoading = ref(false) // === 弹窗状态 === const dialogVisible = ref(false) +const richEditorRef = ref(null) const editMode = ref<'create' | 'update'>('create') const saving = ref(false) const uploadLoading = ref(false) @@ -121,7 +122,7 @@ async function submit() { title: editForm.title.trim(), categoryId: editForm.categoryId, categoryName: editForm.categoryName, - content: editForm.content, + content: richEditorRef.value?.getContent?.() ?? editForm.content, images: editForm.images, } if (editMode.value === 'create') { @@ -240,7 +241,7 @@ async function customUpload(opt: any) { - +