- 改进 HTML 到 Markdown 的转换规则,处理块级元素和行内元素的嵌套关系 - 优化图片和视频标签的解析逻辑,确保附件正确提取 - 修复换行符处理问题,避免产生多余空行 - 增强居中文本和标题的格式转换准确性 - 清理调试日志和冗余代码
965 lines
42 KiB
JavaScript
965 lines
42 KiB
JavaScript
// 简单版本的论坛编辑器,确保图片插入功能正常
|
||
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 = {
|
||
" ": " ",
|
||
"<": "<",
|
||
">": ">",
|
||
"&": "&",
|
||
""": '"',
|
||
"'": "'",
|
||
};
|
||
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);
|
||
});
|
||
});
|
||
};
|
||
|
||
// 封装:提取视频第一帧(返回 Promise,resolve 第一帧 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");
|