feat(F006): 富文本粘贴保留公众号原始排版 — customPaste 转存外链图+清洗插入+原文优先提交

This commit is contained in:
zou 2026-08-28 17:24:30 +08:00
parent b15b5c1e6a
commit 00650f487a
2 changed files with 128 additions and 49 deletions

View File

@ -21,6 +21,10 @@ const model = defineModel<string>({ default: "" })
const editorRef = shallowRef<any>(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<string> {
@ -44,7 +48,56 @@ async function uploadFile(file: File): Promise<string> {
throw new Error(data.msg || "图片上传失败")
}
// === 图片上传配置 (工具栏选择/拖拽/粘贴位图统一走 customUpload) ===
// === 外链图片转存: 公众号图片有防盗链, 下载后上传平台存储 ===
async function remoteToLocal(src: string): Promise<string> {
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 = /<img[^>]+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(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<iframe[\s\S]*?<\/iframe>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<section\b/gi, "<div")
.replace(/<\/section>/gi, "</div>")
.replace(/\sclass=["\x27][^"\x27]*["\x27]/gi, "")
.replace(/<br\s*\/?\s*>/gi, "<br>")
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()
})

View File

@ -37,6 +37,7 @@ const categoryLoading = ref(false)
// === 弹窗状态 ===
const dialogVisible = ref(false)
const richEditorRef = ref<any>(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) {
</div>
</el-form-item>
<el-form-item label="资讯内容">
<RichEditor v-model="editForm.content" style="margin-bottom: 8px" />
<RichEditor ref="richEditorRef" v-model="editForm.content" style="margin-bottom: 8px" />
</el-form-item>
</el-form>
<template #footer>