feat(F006): 富文本粘贴保留公众号原始排版 — customPaste 转存外链图+清洗插入+原文优先提交
This commit is contained in:
parent
b15b5c1e6a
commit
00650f487a
@ -21,6 +21,10 @@ const model = defineModel<string>({ default: "" })
|
|||||||
|
|
||||||
const editorRef = shallowRef<any>(null)
|
const editorRef = shallowRef<any>(null)
|
||||||
const valueHtml = ref(model.value || "")
|
const valueHtml = ref(model.value || "")
|
||||||
|
// 粘贴保留的原始 HTML (公众号排版完整版); 用户手动编辑后清空
|
||||||
|
let rawPasteHtml = ""
|
||||||
|
// 标记是否为程序 setHtml (避免 onChange 清掉 rawPasteHtml)
|
||||||
|
let programmaticSet = false
|
||||||
|
|
||||||
// === 单文件上传 (admin_new: /api 前缀 + access_token, 5MB) ===
|
// === 单文件上传 (admin_new: /api 前缀 + access_token, 5MB) ===
|
||||||
async function uploadFile(file: File): Promise<string> {
|
async function uploadFile(file: File): Promise<string> {
|
||||||
@ -44,7 +48,56 @@ async function uploadFile(file: File): Promise<string> {
|
|||||||
throw new Error(data.msg || "图片上传失败")
|
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 = {
|
const editorConfig = {
|
||||||
placeholder: "请输入资讯内容…支持直接粘贴公众号图文(保留排版,图片自动转存平台)",
|
placeholder: "请输入资讯内容…支持直接粘贴公众号图文(保留排版,图片自动转存平台)",
|
||||||
MENU_CONF: {
|
MENU_CONF: {
|
||||||
@ -60,62 +113,73 @@ const editorConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 自定义粘贴: 处理剪贴板中的位图(截图) -> 上传平台后插入; 排版 HTML 交给编辑器默认逻辑
|
// 自定义粘贴: ① HTML -> 转存外链图 + 保留原文(rawPasteHtml) + 插入清洗版(编辑器不崩) ② 位图 -> 上传插入
|
||||||
customPaste: (editor: any, event: ClipboardEvent, callback: (v: boolean) => void) => {
|
customPaste: (editor: any, event: ClipboardEvent, callback: (v: boolean) => void) => {
|
||||||
const items = event.clipboardData?.items
|
const cd = event.clipboardData
|
||||||
if (!items) return callback(true) // 无剪贴板数据, 走默认
|
if (!cd) 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 html = cd.getData("text/html")
|
||||||
event.preventDefault()
|
const items = cd.items
|
||||||
;(async () => {
|
const imageFiles: File[] = []
|
||||||
for (const f of imageFiles) {
|
if (items) {
|
||||||
try {
|
for (const it of Array.from(items)) {
|
||||||
const url = await uploadFile(f)
|
if (it.type.startsWith("image/")) {
|
||||||
editor.insertNode({ type: "image", src: url, alt: "", href: "" })
|
const f = it.getAsFile()
|
||||||
editor.insertNode({ type: "paragraph", children: [{ text: "" }] })
|
if (f) imageFiles.push(f)
|
||||||
} catch (e: any) {
|
|
||||||
alert(e?.message || "图片上传失败")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
}
|
||||||
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 = {
|
const toolbarConfig = {
|
||||||
excludeKeys: ["group-video", "fullScreen"],
|
excludeKeys: ["group-video", "fullScreen"],
|
||||||
toolbarKeys: [
|
toolbarKeys: [
|
||||||
"headerSelect",
|
"headerSelect", "bold", "italic", "underline", "through",
|
||||||
"bold",
|
"color", "bgColor", "fontSize", "fontFamily", "lineHeight",
|
||||||
"italic",
|
"justifyLeft", "justifyCenter", "justifyRight",
|
||||||
"underline",
|
"bulletedList", "numberedList", "blockquote", "codeBlock",
|
||||||
"through",
|
"insertLink", "uploadImage", "insertTable", "undo", "redo",
|
||||||
"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) {
|
function handleChange(editor: any) {
|
||||||
valueHtml.value = editor.getHtml()
|
valueHtml.value = editor.getHtml()
|
||||||
model.value = valueHtml.value
|
model.value = valueHtml.value
|
||||||
|
// 用户手动编辑 (非程序 setHtml) -> 放弃 rawPasteHtml, 以编辑器内容为准
|
||||||
|
if (!programmaticSet) {
|
||||||
|
rawPasteHtml = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 外部 v-model 变化时同步 (编辑回显)
|
// 外部 v-model 变化时同步 (编辑回显)
|
||||||
watch(model, (v) => {
|
watch(model, (v) => {
|
||||||
if (v !== valueHtml.value && editorRef.value) {
|
if (v !== valueHtml.value && editorRef.value) {
|
||||||
|
programmaticSet = true
|
||||||
editorRef.value.setHtml(v || "")
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
if (editorRef.value) editorRef.value.destroy()
|
if (editorRef.value) editorRef.value.destroy()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const categoryLoading = ref(false)
|
|||||||
|
|
||||||
// === 弹窗状态 ===
|
// === 弹窗状态 ===
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
|
const richEditorRef = ref<any>(null)
|
||||||
const editMode = ref<'create' | 'update'>('create')
|
const editMode = ref<'create' | 'update'>('create')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploadLoading = ref(false)
|
const uploadLoading = ref(false)
|
||||||
@ -121,7 +122,7 @@ async function submit() {
|
|||||||
title: editForm.title.trim(),
|
title: editForm.title.trim(),
|
||||||
categoryId: editForm.categoryId,
|
categoryId: editForm.categoryId,
|
||||||
categoryName: editForm.categoryName,
|
categoryName: editForm.categoryName,
|
||||||
content: editForm.content,
|
content: richEditorRef.value?.getContent?.() ?? editForm.content,
|
||||||
images: editForm.images,
|
images: editForm.images,
|
||||||
}
|
}
|
||||||
if (editMode.value === 'create') {
|
if (editMode.value === 'create') {
|
||||||
@ -240,7 +241,7 @@ async function customUpload(opt: any) {
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="资讯内容">
|
<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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user