Files
PC-Light-Forum/js/edit.js
A1300399510 57bf0ed3eb refactor(editor): 优化富文本编辑器内容格式转换逻辑
- 改进 HTML 到 Markdown 的转换规则,处理块级元素和行内元素的嵌套关系
- 优化图片和视频标签的解析逻辑,确保附件正确提取
- 修复换行符处理问题,避免产生多余空行
- 增强居中文本和标题的格式转换准确性
- 清理调试日志和冗余代码
2025-11-30 23:54:41 +08:00

965 lines
42 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 简单版本的论坛编辑器,确保图片插入功能正常
const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue;
const { headTop } = await import(withVer("../component/head-top/head-top.js"));
const { createEditor, createToolbar, SlateTransforms, Boot, SlateEditor } = window.wangEditor;
class MyButtonMenu {
// JS 语法
constructor() {
this.title = "居中"; // 自定义菜单标题
this.tag = "button";
}
// 获取菜单执行时的 value ,用不到则返回空 字符串或 false
getValue(editor) {
return " hello ";
}
// 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
isActive(editor) {
if (editor.getFragment()?.[0]?.textAlign == "center") return true;
return false;
}
// 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
isDisabled(editor) {
return false;
}
// 点击菜单时触发的函数
exec(editor, value) {
let align = this.isActive(editor) ? "left" : "center";
SlateTransforms.setNodes(editor, {
textAlign: align,
});
}
}
const editApp = createApp({
setup() {
let titleLength = ref(200);
let uniqid = ref("");
const valueA = ref(null);
let valueUrl = "";
onMounted(() => {
const params = getUrlParams();
uniqid.value = params.uniqid || "";
getUserInfoWin();
checkWConfig();
cUpload();
valueUrl = valueA.value.innerText;
if (location.hostname == "127.0.0.1") {
realname.value = 1;
userInfoWin.value = {
uin: 1234567890,
uid: 1234567890,
realname: "测试用户",
};
isLogin.value = true;
}
});
let imageLength = 10;
let videoLength = 5;
const checkWConfig = () => {
const wConfig = JSON.parse(localStorage.getItem("wConfig")) || {};
if (wConfig.time) {
const time = new Date(wConfig.time);
const now = new Date();
if (now - time > 24 * 60 * 60 * 1000) getWConfig();
else {
const config = wConfig.config || {};
titleLength.value = config.max_topic_title_length;
imageLength = config.topic_image_count || 0;
videoLength = config.topic_video_count || 0;
}
} else getWConfig();
};
const getWConfig = () => {
ajaxGet("/v2/api/config/website").then((res) => {
if (res.code == 200) {
let data = res["data"] || {};
const config = data.config || {};
titleLength.value = config.max_topic_title_length;
imageLength = config.topic_image_count || 0;
videoLength = config.topic_video_count || 0;
data.time = new Date().toISOString();
localStorage.setItem("wConfig", JSON.stringify(data));
}
});
};
let isLogin = ref(false);
let realname = ref(0); // 是否已经实名
let userInfoWin = ref({});
let permissions = ref([]);
const getUserInfoWin = () => {
const checkUser = () => {
const user = window.userInfoWin;
if (!user) return;
document.removeEventListener("getUser", checkUser);
realname.value = user.realname;
userInfoWin.value = user;
if (user?.uin > 0 || user?.uid > 0) isLogin.value = true;
permissions.value = user?.authority || [];
};
document.addEventListener("getUser", checkUser);
};
const openAttest = () => {
const handleAttestClose = () => {
document.removeEventListener("closeAttest", handleAttestClose);
realname.value = window.userInfoWin?.realname || 0;
};
// 启动认证流程时添加监听
document.addEventListener("closeAttest", handleAttestClose);
loadAttest(2);
};
// 跳转登录
const goLogin = () => {
if (typeof window === "undefined") return;
if (window["userInfoWin"] && Object.keys(window["userInfoWin"]).length !== 0) {
if (window["userInfoWin"]["uid"]) isLogin.value = true;
else ajax_login();
} else ajax_login();
};
let uConfigData = {};
const cUpload = () => {
ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => {
const data = res.data;
uConfigData = data;
init();
});
};
let info = ref({});
let tagList = ref([]);
let token = ref("");
const init = () => {
ajax("/v2/api/forum/postPublishInit", {
uniqid: uniqid.value,
})
.then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox("error", res.message || "操作失败");
return;
}
const infoTarget = data.info || {};
// console.log("content", infoTarget.content);
if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoTarget.attachments);
// console.log("content", infoTarget.content);
info.value = infoTarget;
token.value = data.token;
initEditor();
})
.catch((err) => {
console.log("err", err);
});
};
let editor = null;
let toolbarRef = ref(null);
// 自定义转换视频
function customParseVideoSrc(src) {
// console.log("customParseVideoSrc", "src:", src);
// if (src.includes(".bilibili.com")) {
// // 转换 bilibili url 为 iframe (仅作为示例,不保证代码正确和完整)
// const arr = location.pathname.split("/");
// const vid = arr[arr.length - 1];
// return `<iframe src="//player.bilibili.com/player.html?bvid=${vid}" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>`;
// }
return src;
}
const initEditor = () => {
let infoTarget = info.value || {};
const editorConfig = {
placeholder: "输入正文",
enabledMenus: [],
MENU_CONF: {
["emotion"]: {
emotions: optionEmoji.value,
},
["insertImage"]: {
onInsertedImage(imageNode) {
const { src, alt, url, href } = imageNode;
},
async parseImageSrc(src) {
// 如果图片链接中已经包含了 ?aid= ,则说明是本站图片,直接返回,无需处理
if (src.includes("?aid=")) return src;
// 对于不含 ?aid= 的外部图片,执行上传转换
if (!uConfigData || !uConfigData.url) return src;
try {
const formData = new FormData();
formData.append("uploadType", "url");
formData.append("url", src);
if (uConfigData.params && uConfigData.params.data) {
formData.append("data", uConfigData.params.data);
}
const res = await ajax(uConfigData.url, formData);
if (res.code == 200 && res.data) return `${res.data.url}?aid=${res.data.aid}`;
else {
creationAlertBox("error", res.message || "操作失败");
return "";
}
} catch (e) {
console.error("Transform network image failed", e);
}
},
},
["uploadImage"]: {
server: uConfigData.url,
fieldName: uConfigData.requestName,
maxFileSize: maxSize, // 1M
maxNumberOfFiles: imageLength,
allowedFileTypes: ["image/png", "image/jpeg", "image/jpg"], // .png, .jpg, .jpeg
meta: { ...uConfigData.params },
metaWithUrl: false,
headers: { accept: "application/json, text/plain, */*", ...uConfigData.headers },
withCredentials: true,
timeout: 60 * 1000, // 15 秒
async customUpload(file, insertFn) {
try {
const img = await uploading(file, file.name, "image");
insertFn(`${img.url}?aid=${img.aid}`);
} catch (err) {
console.error("上传出错:", err);
}
},
},
["insertVideo"]: {
onInsertedVideo(videoNode) {
if (videoNode == null) return;
// console.log(videoNode);
const { src } = videoNode;
// console.log("inserted video", src);
},
parseVideoSrc: customParseVideoSrc, // 也支持 async 函数
},
["uploadVideo"]: {
server: uConfigData.url,
fieldName: uConfigData.requestName,
maxFileSize: maxSize, // 1M
maxNumberOfFiles: videoLength,
allowedFileTypes: ["video/flv", "video/mkv", "video/avi", "video/rm", "video/rmvb", "video/mpeg", "video/mpg", "video/ogg", "video/ogv", "video/mov", "video/wmv", "video/mp4", "video/webm", "video/m4v"],
meta: { ...uConfigData.params },
metaWithUrl: false,
headers: { accept: "application/json, text/plain, */*", ...uConfigData.headers },
withCredentials: true,
timeout: 60 * 1000, // 15 秒
async customUpload(file, insertFn) {
try {
const videoUploadRes = await uploading(file, file.name, "video");
progress.value = 0;
const coverFile = await getVideoFirstFrame(file);
// console.log("第一帧提取成功", coverFile);
const coverUploadRes = await uploading(coverFile, coverFile.name, "image");
// console.log("封面上传成功", coverUploadRes);
insertFn(`${videoUploadRes.url}?aid=${videoUploadRes.aid}`, `${coverUploadRes.url}?aid=${coverUploadRes.aid}`);
} catch (err) {
console.error("上传出错:", err);
progress.value = 0;
}
},
},
},
onChange(editor) {
const html = editor.getHtml();
console.log("edior content", html);
},
hoverbarKeys: { text: { menuKeys: [] }, video: { menuKeys: [] } },
};
editor = createEditor({
selector: "#editor-container",
html: infoTarget.content,
// html: "",
config: editorConfig,
mode: "default",
});
const toolbarConfig = {
toolbarKeys: [
"header1",
{
key: "group-image",
title: "图片",
menuKeys: ["insertImage", "uploadImage"],
},
"uploadVideo",
"emotion",
"insertLink",
"bold",
],
};
const menu1Conf = {
key: "customCenter", // 定义 menu key :要保证唯一、不重复(重要)
factory() {
return new MyButtonMenu(); // 把 `YourMenuClass` 替换为你菜单的 class
},
};
Boot.registerMenu(menu1Conf);
toolbarConfig.insertKeys = {
index: 7, // 插入的位置,基于当前的 toolbarKeys
keys: ["customCenter"],
};
const toolbar = createToolbar({
editor,
selector: "#toolbar-container",
config: toolbarConfig,
mode: "default",
});
nextTick(() => {
const h1 = toolbarRef.value.querySelector('[data-menu-key="header1"]');
const h1Item = h1.parentElement;
h1Item.classList.add("toolbar-item", "flexacenter");
h1.innerHTML = `<img class="icon" src="${valueUrl}/img/t-icon.png" alt="段落标题" /> <span>段落标题</span>`;
const image = toolbarRef.value.querySelector('[data-menu-key="group-image"]');
const imageItem = image.parentElement;
imageItem.classList.add("toolbar-item", "flexacenter");
image.innerHTML = `<img class="icon" src="${valueUrl}/img/img-icon.png" alt="图片" /> <span>图片</span>`;
const video = toolbarRef.value.querySelector('[data-menu-key="uploadVideo"]');
const videoItem = video.parentElement;
videoItem.classList.add("toolbar-item", "flexacenter");
video.innerHTML = `<img class="icon" src="${valueUrl}/img/video-icon.png" alt="视频" /> <span>视频</span>`;
const emotion = toolbarRef.value.querySelector('[data-menu-key="emotion"]');
const emotionItem = emotion.parentElement;
emotionItem.classList.add("toolbar-item", "flexacenter");
emotion.innerHTML = `<img class="icon" src="${valueUrl}/img/smiling-face-round-black.png" alt="表情" /> <span>表情</span>`;
const link = toolbarRef.value.querySelector('[data-menu-key="insertLink"]');
const linkItem = link.parentElement;
linkItem.classList.add("toolbar-item", "flexacenter");
link.innerHTML = `<img class="icon" src="${valueUrl}/img/link-icon.png" alt="链接" /> <span>链接</span>`;
const bold = toolbarRef.value.querySelector('[data-menu-key="bold"]');
const boldItem = bold.parentElement;
boldItem.classList.add("toolbar-item", "flexacenter");
bold.innerHTML = `<img style="width: 14px;height: 14px;" class="icon" src="${valueUrl}/img/overstriking-icon.png" alt="加粗" /> <span>加粗</span>`;
const customCenter = toolbarRef.value.querySelector('[data-menu-key="customCenter"]');
const customCenterItem = customCenter.parentElement;
customCenterItem.classList.add("toolbar-item", "flexacenter");
customCenter.innerHTML = `<img class="icon" src="${valueUrl}/img/between -icon.png" alt="居中" /> <span>居中</span>`;
});
};
const restoreHtml = (formattedText, attachments) => {
const imageList = attachments?.images || [];
const filesList = attachments?.files || [];
const videosList = attachments?.videos || [];
let html = formattedText || "";
// 0.5 [p] -> <p>
// html = html.replace(/\[p\]([\s\S]*?)\[\/p\]/gi, "<p>$1</p>");
// 0. 基础清理:换行符转 <br>div 转 p (保持原有逻辑)
html = html.replace(/(\r\n|\n|\r)/g, "<br>");
html = html.replace(/<div(\s|>)/gi, "<p$1");
html = html.replace(/<\/div>/g, "</p>");
html = html.replace(/<br><div>/g, "<div>");
html = html.replace(/<\/div><br>/g, "</div>");
html = html.replace(/<br><p>/g, "<p>");
html = html.replace(/<\/p><br>/g, "</p>");
// 1. [section] -> h1
html = html.replace(/\[section\]([\s\S]*?)\[\/section\]/gi, "<h1>$1</h1>");
// 2. [b] -> strong
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<strong>$1</strong>");
// 3. [align=center]
// 特殊处理嵌套在 h1 中的居中
html = html.replace(/<h1>\s*\[align=center\]([\s\S]*?)\[\/align\]\s*<\/h1>/gi, '<h1 style="text-align: center;">$1</h1>');
// 普通居中
html = html.replace(/\[align=center\]([\s\S]*?)\[\/align\]/gi, '<p style="text-align: center;">$1</p>');
// 移除 align 后紧跟的换行符(因为 align 转换为了块级元素 p/h1其后的换行符通常是多余的
html = html.replace(/(<p style="text-align: center;">.*?<\/p>)\s*<br>/gi, "$1");
html = html.replace(/(<h1 style="text-align: center;">.*?<\/h1>)\s*<br>/gi, "$1");
// 定义图片处理函数
const processImg = (aid, width, height, href) => {
const image = imageList.find((img) => String(img.aid) === String(aid));
if (!image) return "";
// 移除已使用的图片
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
let style = "";
const formatStyleVal = (val) => {
if (!val) return null;
if (String(val).endsWith('%')) return val;
const num = Number(val);
return num > 0 ? `${num}px` : null;
};
const wStyle = formatStyleVal(width);
const hStyle = formatStyleVal(height);
let styleParts = [];
if (wStyle) styleParts.push(`width: ${wStyle}`);
if (hStyle) styleParts.push(`height: ${hStyle}`);
if (styleParts.length > 0) style = `style="${styleParts.join('; ')};"`;
let dataHref = href ? ` data-href="${href}"` : "";
return `<img src="${image.url}?aid=${aid}" data-aid="${aid}"${dataHref} ${style}>`;
};
// 4. [url] + [img] 组合 (带链接的图片)
// 匹配 [url=...][img...]...[/img][/url]
html = html.replace(/\[url=([^\]]+)\]\[img(?:=([0-9.%]+)(?:,([0-9.%]+))?)?\](\d+)\[\/img\]\[\/url\]/gi, (match, href, w, h, aid) => {
const imgTag = processImg(aid, w, h, href);
return imgTag || match;
});
// 5. 单独 [img]
html = html.replace(/\[img(?:=([0-9.%]+)(?:,([0-9.%]+))?)?\](\d+)\[\/img\]/gi, (match, w, h, aid) => {
const imgTag = processImg(aid, w, h, null);
return imgTag || match;
});
// 6. [attachimg] (兼容旧格式)
html = html.replace(/\[attachimg\](\d+)\[\/attachimg\]/gi, (match, aid) => {
const imgTag = processImg(aid, 0, 0, null);
return imgTag || match;
});
// 7. [attach] (图片、视频或附件)
html = html.replace(/\[attach\](\d+)\[\/attach\]/gi, (match, aid) => {
// 尝试匹配视频
const video = videosList.find((v) => String(v.aid) === String(aid));
if (video) {
const index = videosList.findIndex((v) => String(v.aid) === String(aid));
if (index > -1) videosList.splice(index, 1);
return `<p><video src="${video.url}?aid=${video.aid}" preload="none" poster="${video.posterurl}?aid=${video.posterid}" controls></video></p>`;
}
// 尝试匹配图片
const image = imageList.find((img) => String(img.aid) === String(aid));
if (image) {
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
if (index > -1) imageList.splice(index, 1);
return `<img src="${image.url}?aid=${image.aid}"><br/>`;
}
// 尝试匹配文件
const file = filesList.find((f) => String(f.aid) === String(aid));
if (file) {
return `<a href="${file.url}?aid=${aid}" target="_blank">附件: ${file.name || "Download"}</a>`;
}
return match;
});
// 8. [url] (普通链接)
html = html.replace(/\[url=([^\]]+)\]([\s\S]*?)\[\/url\]/gi, '<a href="$1" target="_blank">$2</a>');
// 9. 剩余资源追加
imageList.forEach((element) => (html += `<img src="${element.url}?aid=${element.aid}"><br/>`));
videosList.forEach((element) => (html += `<video src="${element.url}?aid=${element.aid}" preload="none" poster="${element.posterurl}?aid=${element.posterid}" controls></video><br/>`));
// 10. 最终 HTML 结构包裹
const __c = document.createElement("div");
__c.innerHTML = html;
// 确保所有顶层元素都是块级元素 (Slate/WangEditor 要求)
const blockTags = ['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'UL', 'OL', 'PRE', 'TABLE', 'FIGURE', 'HR'];
const newContainer = document.createElement('div');
let currentInlineNodes = [];
let lastWasBlock = false;
const flushInlines = () => {
if (currentInlineNodes.length > 0) {
if (currentInlineNodes.length > 0) {
const p = document.createElement('p');
currentInlineNodes.forEach(node => p.appendChild(node));
newContainer.appendChild(p);
}
currentInlineNodes = [];
}
};
Array.from(__c.childNodes).forEach((node) => {
// 判断是否为块级元素
const isBlock = node.nodeType === 1 && blockTags.includes(node.tagName);
if (isBlock) {
flushInlines();
newContainer.appendChild(node);
// 记录最后添加的是块级元素
lastWasBlock = true;
} else if (node.nodeName === 'BR') {
if (currentInlineNodes.length > 0) {
flushInlines();
lastWasBlock = false; // 刚刚结束了一个段落,不算紧挨着块级
} else {
// 如果前面紧挨着块级元素,忽略这个 BR避免块级元素后的换行产生空行
if (lastWasBlock) {
// 忽略
lastWasBlock = false; // 消耗掉块级后的换行状态,避免连续 BR 被吞
} else {
const p = document.createElement('p');
p.innerHTML = '<br>';
newContainer.appendChild(p);
lastWasBlock = false;
}
}
} else {
// 过滤掉块级元素之间或开头的纯空白文本节点,避免产生空的 P 标签
if (node.nodeType === 3 && !node.textContent.trim()) {
if (currentInlineNodes.length > 0) {
currentInlineNodes.push(node);
}
} else {
currentInlineNodes.push(node);
}
lastWasBlock = false;
}
});
flushInlines();
html = newContainer.innerHTML;
console.log("初始化显示的html", html);
return html;
};
onMounted(() => {
// setTimeout(() => focusLastNode(), 1000);
// document.addEventListener("keydown", handleUndoKeydown);
});
const editorRef = ref(null);
let loading = ref(false);
const maxSize = 20 * 1024 * 1024; // 20MB
const cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1);
const optionEmoji = ref(["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵‍💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"]);
// 提交
const submit = (status) => {
if (location.hostname == "127.0.0.1") status = 0
if (realname.value == 0 && userInfoWin.value?.uin > 0) {
openAttest();
return;
}
if (!isLogin.value) {
goLogin();
return;
}
const infoTarget = { ...info.value } || {};
let content = editor.getHtml();
const images = extractImages(editorRef.value);
const videos = extractVideos(editorRef.value);
infoTarget.attachments = infoTarget.attachments || {};
infoTarget.attachments.images = images;
infoTarget.attachments.videos = videos;
info.value["attachments"] = info.value["attachments"] || {};
info.value["attachments"]["images"] = images;
info.value["attachments"]["videos"] = videos;
// console.log("原始html", content);
content = formatContent(content);
// console.log("最终html", content);
const data = {
...infoTarget,
content,
};
// console.log("data", data);
ajax("/v2/api/forum/postPublishTopic", {
info: data,
token: token.value,
status,
}).then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox("error", res.message);
return;
}
creationAlertBox("success", res.message || "操作成功");
const back = () => {
if (status == 1) redirectToExternalWebsite("/details/" + data.uniqid, "_self");
else redirectToExternalWebsite("/", "_self");
};
setTimeout(() => back(), 1500);
});
};
const formatContent = (html) => {
if (!html) return "";
// <p><br></p> 转换为单个换行符
html = html.replace(/<p><br><\/p>/gi, "\n");
// 1. 处理居中 [align=center] (p, div, h1-h6)
html = html.replace(/<(p|div|h[1-6])[^>]*style="[^"]*text-align:\s*center;[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi, (match, tag, content) => {
if (tag.toLowerCase() === "h1") {
// h1 特殊处理:保留标签,内容居中,供后续转 [section]
return `<h1>[align=center]${content}[/align]</h1>`;
}
return `[align=center]${content}[/align]`;
});
// 2. 处理章节 [section] (对应 h1)
html = html.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "[section]$1[/section]");
// 2.5 处理段落 [p]
// 优先处理空段落 <p><br></p>,将其替换为单个换行符,避免后续双重换行
html = html.replace(/<p[^>]*>\s*<br\s*\/?>\s*<\/p>/gi, "\n");
// 处理普通段落结束符
html = html.replace(/<\/p>/gi, "\n");
// 3. 处理加粗 [b] (对应 strong, b)
html = html.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "[b]$2[/b]");
// 4. 处理图片 [img]
html = html.replace(/<img[^>]*>/gi, (imgTag) => {
let aid = "";
// 尝试从 src 中获取 aid
const srcMatch = imgTag.match(/src="([^"]+)"/i);
if (srcMatch) {
const aidMatch = srcMatch[1].match(/[?&]aid=(\d+)/);
if (aidMatch) aid = aidMatch[1];
}
// 尝试从 data-aid 中获取 aid
if (!aid) {
const dataAid = imgTag.match(/data-aid="(\d+)"/i);
if (dataAid) aid = dataAid[1];
}
if (!aid) return ""; // 无法获取 aid跳过
// 获取宽高 (支持 px 和 %)
let w = 0,
h = 0;
const styleMatch = imgTag.match(/style="([^"]+)"/i);
if (styleMatch) {
// 匹配数字+单位 (px或%)
const wMatch = styleMatch[1].match(/width:\s*([\d.]+(?:px|%)?)/i);
const hMatch = styleMatch[1].match(/height:\s*([\d.]+(?:px|%)?)/i);
if (wMatch) {
// 如果是百分比,直接保留字符串;如果是纯数字默认视为 px如果是 px 去掉单位
let val = wMatch[1];
if (val.endsWith('%')) w = val; // 保留百分比字符串
else w = parseFloat(val); // 转为数字 (px)
}
if (hMatch) {
let val = hMatch[1];
if (val.endsWith('%')) h = val;
else h = parseFloat(val);
}
}
// 兼容 width/height 属性 (通常只有数字)
if (!w) {
const wAttr = imgTag.match(/\swidth="(\d+)"/i);
if (wAttr) w = Number(wAttr[1]);
}
if (!h) {
const hAttr = imgTag.match(/\sheight="(\d+)"/i);
if (hAttr) h = Number(hAttr[1]);
}
let result = "";
if (w || h) { // 只要有一个有值
const formatVal = (val) => {
if (typeof val === 'string' && val.endsWith('%')) return val;
return val ? parseFloat(Number(val).toFixed(2)) : 0;
};
result = `[img=${formatVal(w)},${formatVal(h)}]${aid}[/img]`;
} else {
result = `[img]${aid}[/img]`;
}
// 处理 data-href包裹在 [url] 中
const hrefMatch = imgTag.match(/data-href="([^"]+)"/i);
if (hrefMatch && hrefMatch[1]) result = `[url=${hrefMatch[1]}]${result}[/url]`;
return result;
});
// 5. 处理视频 [attach]
html = html.replace(/<div[^>]*data-w-e-type="video"[^>]*>([\s\S]*?)<\/div>/gi, (match, content) => {
return content.trim(); // 去掉包裹视频的 div并去除首尾空白防止产生额外换行
});
html = html.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, (videoTag) => {
let aid = "";
const dataAid = videoTag.match(/data-aid="(\d+)"/i);
if (dataAid) aid = dataAid[1];
if (!aid) {
const srcMatch = videoTag.match(/src="([^"]+)"/i);
if (srcMatch) {
const aidMatch = srcMatch[1].match(/[?&]aid=(\d+)/);
if (aidMatch) aid = aidMatch[1];
}
}
return aid ? `[attach]${aid}[/attach]` : "";
});
// 6. 处理链接 [url] 和 附件下载 [attach]
html = html.replace(/<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (match, href, content) => {
// 尝试提取 aid
const aidMatch = href.match(/[?&]aid=(\d+)/);
// 如果是下载链接(包含 download 属性 或 明确是附件)且有 aid转为 [attach]
if (match.includes("download=") && aidMatch) {
return `[attach]${aidMatch[1]}[/attach]`;
}
// 普通链接
return `[url=${href}]${content}[/url]`;
});
// 7. 换行处理
html = html.replace(/<br\s*\/?>/gi, "\n");
html = html.replace(/<\/(div)>/gi, "\n"); // 块级元素结束视为换行
// 8. 清理剩余标签和解码
html = html.replace(/<[^>]+>/g, ""); // 移除所有剩余标签
// 简单的 HTML 实体解码
const entities = {
"&nbsp;": " ",
"&lt;": "<",
"&gt;": ">",
"&amp;": "&",
"&quot;": '"',
"&apos;": "'",
};
html = html.replace(/&[a-z]+;/gi, (match) => entities[match] || match);
return html.trim();
};
const extractImages = (dom) => {
const images = [];
// 直接查找页面中所有带 data-aid 的 img 标签
const imgElements = dom.querySelectorAll("img");
imgElements.forEach((imgEl) => {
let url = imgEl.getAttribute("src")?.trim() || "";
const urlObj = new URL(url);
const aid = urlObj.searchParams.get("aid");
const queryIndex = url.indexOf("?");
const cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
images.push({
url: cleanUrl,
aid: Number(aid),
});
});
return images;
};
const extractVideos = (dom) => {
// 1. 查找页面中所有 <video> 节点(返回 NodeList 集合)
const videoElements = dom.querySelectorAll("video");
const result = [];
// 2. 遍历每个 video 节点,直接获取属性
videoElements.forEach((videoEl) => {
const posterurl = videoEl.getAttribute("poster")?.trim() || ""; // 视频地址
// 1. 用 URL 构造函数解析链接(自动处理查询参数)
const urlObj = new URL(posterurl);
// 2. 获取 aid 参数get 方法找不到时返回 null
const posterid = urlObj.searchParams.get("aid");
const sourceEl = videoEl.querySelector("source");
const url = sourceEl.getAttribute("src") || null;
const obj = new URL(url);
// 2. 获取 aid 参数get 方法找不到时返回 null
const aid = obj.searchParams.get("aid");
const queryIndex = url.indexOf("?");
const cleanUrl = queryIndex !== -1 ? url.substring(0, queryIndex) : url;
const queryIndex2 = posterurl.indexOf("?");
const cleanPosterurl = queryIndex2 !== -1 ? posterurl.substring(0, queryIndex2) : posterurl;
result.push({
aid: Number(aid),
posterid: Number(posterid),
url: cleanUrl,
posterurl: cleanPosterurl,
});
});
return result;
};
const progress = ref(0);
const uploading = (target, name, type) => {
return new Promise((resolve, reject) => {
let config = uConfigData;
const formData = new FormData();
formData.append(config.requestName, target);
formData.append("name", name);
formData.append("type", type);
if (config.params && config.params.data) {
formData.append("data", config.params.data);
}
axios
.post(config.url, formData, {
headers: {
...config.headers,
"Content-Type": "multipart/form-data",
},
onUploadProgress: (e) => {
progress.value = Math.round((e.loaded / e.total) * 100);
},
withCredentials: true,
})
.then((response) => {
const res = response.data;
if (res.code == 200) {
resolve(res.data);
} else {
creationAlertBox("error", res.message || "上传失败");
reject(res);
}
})
.catch((error) => {
if (error.response) {
creationAlertBox("error", `HTTP错误: ${error.response.status}`);
} else if (error.request) {
creationAlertBox("error", "网络错误");
} else {
creationAlertBox("error", "请求设置错误");
}
reject(error);
});
});
};
// 封装:提取视频第一帧(返回 Promiseresolve 第一帧 Blob
const getVideoFirstFrame = (videoFile) => {
return new Promise((resolve, reject) => {
if (!(videoFile instanceof File) || !videoFile.type.startsWith("video/")) {
reject(new Error("请传入合法的视频文件"));
return;
}
const objectUrl = URL.createObjectURL(videoFile);
const video = document.createElement("video");
video.src = objectUrl;
video.preload = "auto";
video.muted = true;
video.playsInline = true;
const cleanup = () => {
URL.revokeObjectURL(objectUrl);
video.src = "";
};
video.addEventListener(
"error",
() => {
cleanup();
reject(new Error("视频加载失败,请检查文件完整性"));
},
{ once: true }
);
video.addEventListener(
"loadeddata",
() => {
const canvas = document.createElement("canvas");
const w = video.videoWidth || 320;
const h = video.videoHeight || 240;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
try {
ctx.drawImage(video, 0, 0, w, h);
} catch (e) {
cleanup();
reject(e);
return;
}
canvas.toBlob(
(blob) => {
cleanup();
if (!blob) {
reject(new Error("第一帧提取失败Blob 生成异常"));
return;
}
const frameFile = new File([blob], `video_cover_${Date.now()}.png`, { type: "image/png" });
resolve(frameFile);
},
"image/png",
0.9
);
},
{ once: true }
);
});
};
return { progress, valueA, toolbarRef, uniqid, userInfoWin, titleLength, submit, info, tagList, token, cutAnonymity, editorRef };
},
});
editApp.component("headTop", headTop);
editApp.mount("#edit");