// 简单版本的论坛编辑器,确保图片插入功能正常 const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue; import { headTop } from "../component/head-top/head-top.js"; const editApp = createApp({ setup() { let titleLength = ref(200); onMounted(() => { getUserInfoWin(); cUpload(); init(); // 添加selectionchange事件监听,当鼠标选中区域内容时更新lastSelection document.addEventListener("selectionchange", handleSelectionChange); }); // 组件卸载时移除事件监听 onUnmounted(() => { document.removeEventListener("selectionchange", handleSelectionChange); }); let isLogin = ref(true); let realname = ref(1); // 是否已经实名 let userInfoWin = ref({ authority: ["comment.edit", "comment.delete", "offercollege.hide", "offersummary.hide", "mj.hide", "topic:manager", "topic:hide"], avatar: "https://nas.gter.net:9008/avatar/97K4EWIMLrsbGTWXslC2WFVSEKWOikN42jDKLNjtax7HL4xtfMOJSdU9oWFhY2E~/middle?random=1761733169", groupid: 3, nickname: "肖荣豪", realname: 1, token: "01346a38444d71aaadb3adad52b52c39", uid: 500144, uin: 4238049, }); 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; }); }; let info = ref({}); let tagList = ref([]); let token = ref(""); let infoImages = []; const init = () => { ajax("/v2/api/forum/postPublishInit") .then((res) => { const data = res.data; if (res.code != 200) { creationAlertBox(res.message || "操作失败"); return; } const infoTarget = data.info || {}; infoImages = infoTarget.attachments?.images || []; if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoImages); info.value = infoTarget; tagList.value = data.tagList; token.value = data.token; nextTick(() => { judgeIsEmpty(); }); }) .catch((err) => { console.log("err", err); }); }; const restoreHtml = (formattedText, imageList) => { let html = formattedText; // 1. 还原换行符为
标签 html = html.replace(/\n/g, "
"); // 2. 还原块级标签的换行标记 html = html.replace(/
/g, "
"); html = html.replace(/<\/div>
/g, "
"); // 3. 还原标签标记为span.blue html = html.replace(/\[tag\]([^[]+)\[\/tag\]/gi, '#$1 '); // 4. 还原粗体标记为h2标签 html = html.replace(/\[b\]([^[]+)\[\/b\]/gi, "

$1

"); // 5. 还原图片标记为img标签(使用提供的imageList) html = html.replace(/\[attachimg\](\d+)\[\/attachimg\]/gi, (match, aid) => { // 查找对应的图片信息 const image = imageList.find((img) => img.aid == aid); if (image) { return ``; } return match; // 未找到对应图片时保留原始标记 }); // 6. 还原填充标签 html = html.replace(/([^<]+<\/span>)\s+/gi, '$1 '); // 7. 清理多余的
标签 html = html.replace(/

/g, "
"); return html; }; onMounted(() => { setTimeout(() => focusLastNode(), 1000); }); const editorRef = ref(null); const focusLastNode = () => { const newRange = document.createRange(); const textNode = document.createTextNode(""); editorRef.value.appendChild(textNode); newRange.setStartAfter(textNode, 0); newRange.setEndAfter(textNode, 0); lastSelection = newRange; }; let lastSelection = null; let loading = ref(false); const maxSize = 20 * 1024 * 1024; // 20MB const insertImage = (event) => { let config = uConfigData; const target = event.target.files[0]; if (!target) return; // 处理未选择文件的情况 if (target.size > maxSize) { creationAlertBox("文件大小不能超过 20MB"); return; } loading.value = true; // 不要删除,后面会用 const formData = new FormData(); formData.append(config.requestName, target); // 文件数据 formData.append("name", target.name); // 文件名 formData.append("type", "image"); // 文件名 formData.append("data", config.params.data); // 文件名 ajax(config.url, formData) .then((res) => { const data = res.data; try { const range = lastSelection; const img = document.createElement("img"); img.src = data.url; img.setAttribute("data-aid", data.aid); range.insertNode(img); const div = document.createElement("div"); range.insertNode(div); judgeIsEmpty(); } catch (error) { console.error("插入图片出错:", error); } }) .finally(() => { loading.value = false; }); }; let isEmpty = ref(true); const onEditorInput = (event) => { console.log("onEditorInput"); const selection = window.getSelection(); if (selection.rangeCount > 0) { lastSelection = selection.getRangeAt(0); // console.log("更新选区"); updatePTitleStatus(); } judgeIsEmpty(); debouncedGetTagList(); }; // 防抖函数 const debounce = (fn, delay = 500) => { let timer = null; return function () { if (timer) { clearTimeout(timer); } timer = setTimeout(() => { fn.apply(this, arguments); timer = null; }, delay); }; }; const getRandomChinese = () => { // 中文 Unicode 范围:\u4e00 - \u9fa5(共约 2 万个汉字) const start = 0x4e00; // 起始编码 const end = 0x9fa5; // 结束编码 // 生成范围内的随机整数,转为字符 return String.fromCodePoint(Math.floor(Math.random() * (end - start + 1) + start)); }; const generateRandomString = (length = 5) => { // 定义字符集:包含大小写字母和数字 const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; // 循环生成指定长度的随机字符 for (let i = 0; i < length; i++) { // 从字符集中随机取一个字符 const randomIndex = Math.floor(Math.random() * chars.length); result += chars[randomIndex]; } return result; }; const getTagList = () => { const content = editorRef.value.innerText; axios .post("https://api.gter.net/v2/api/forum/postPublishTags", { content, }) .then((res) => { res = res.data; if (res.code != 200) return; let data = res.data || []; // 随机生成一下数据 for (let i = 0; i < 5; i++) { data.push({ title: getRandomChinese() + getRandomChinese(), tagId: generateRandomString(), }); } tagList.value = data; }); }; const debouncedGetTagList = debounce(getTagList, 500); let isBottomState = ref(false); // 底部按钮 显示 const onEditorFocus = () => { isBottomState.value = true; }; const onEditorBlur = () => { isBottomState.value = false; }; // 判断是否为空 const judgeIsEmpty = () => { const text = editorRef.value.innerText; isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img"); }; // 处理选中文本变化的函数 const handleSelectionChange = () => { const selection = window.getSelection(); // 确保有选中内容且选中区域在编辑器内 if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); // 检查选中区域是否在编辑器内 const commonAncestor = range.commonAncestorContainer; if (editorRef.value.contains(commonAncestor)) { console.log("选中区域在编辑器内", range); lastSelection = range; } } }; const isPTitle = ref(false); const paragraphTitle = () => { editorRef.value.focus(); if (!lastSelection) return; const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(lastSelection); // 使用try-catch确保即使命令执行失败也能恢复滚动位置 try { document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2"); } catch (error) { console.error("应用段落格式失败:", error); } // 更新状态 setTimeout(() => updatePTitleStatus(), 100); }; const updatePTitleStatus = () => { if (lastSelection) { let parentElement = lastSelection.commonAncestorContainer; // 死循环,直到遇到终止条件 while (true) { // 如果没有父元素了(到达文档根节点),退出循环返回false if (!parentElement) { isPTitle.value = false; return; } // 遇到id为"editor"的元素,返回false if (parentElement.id === "editor") { isPTitle.value = false; return; } // 遇到nodeName为"H2"的元素,返回true(注意nodeName是大写的) if (parentElement.nodeName === "H2") { isPTitle.value = true; return; } // 继续向上查找父元素 parentElement = parentElement.parentElement; } } }; const cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1); const insertLabel = (id) => { const index = tagList.value.findIndex((item) => item.tagId == id); if (index == -1) return; const label = tagList.value[index].title; const span = document.createElement("span"); span.innerHTML = `#${label} `; lastSelection.insertNode(span); // 移动光标到元素后面并确保光标位置被正确设置和获取 const newRange = document.createRange(); newRange.setStartAfter(span); newRange.setEndAfter(span); // 更新选择范围 const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(newRange); lastSelection = newRange; // 手动触发selectionchange事件,确保其他组件知道光标位置变化 const selectionChangeEvent = new Event("selectionchange", { bubbles: true }); document.dispatchEvent(selectionChangeEvent); judgeIsEmpty(); // 删除 tagList 中当前标签 tagList.value.splice(index, 1); }; let emojiState = ref(false); const optionEmoji = ref(["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵‍💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"]); const openEmoji = () => (emojiState.value = true); const closeEmoji = () => (emojiState.value = false); const selectEmoji = (emoji) => { const textNode = document.createTextNode(emoji); lastSelection.insertNode(textNode); // 移动光标到emoji后面并确保光标位置被正确设置和获取 const newRange = document.createRange(); newRange.setStartAfter(textNode); newRange.setEndAfter(textNode); // 更新选择范围 const selection = window.getSelection(); selection.removeAllRanges(); lastSelection = newRange; // 手动触发selectionchange事件,确保其他组件知道光标位置变化 const selectionChangeEvent = new Event("selectionchange", { bubbles: true }); document.dispatchEvent(selectionChangeEvent); closeEmoji(); judgeIsEmpty(); }; let format = ref(""); const submit = (status) => { const infoTarget = { ...info.value } || {}; let content = editorRef.value.innerHTML; const images = extractImages(content); infoTarget.attachments.images = images; info.value["attachments"]["images"] = images; console.log("转换前:", content); content = formatContent(content); console.log("转换后:", content); const data = { ...infoTarget, content, }; 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.html?uniqid=" + data.uniqid); else redirectToExternalWebsite("./index.html"); }; setTimeout(() => back(), 1500); }); }; const formatContent = (html) => { // 1. 替换图片标签 html = html.replace(/]*data-aid="(\d+)"[^>]*>/gi, "[attachimg]$1[/attachimg]"); // 2. 替换H2标签 html = html.replace(/]*>([\s\S]*?)<\/h2>/gi, "[b]$1[/b]"); // 3. 替换标签(保留与前后内容的连续性) html = html.replace(/#([^<]+)<\/span>/gi, "[tag]$1[/tag]"); // 4. 移除无关标签(如空的) html = html.replace(/[^<]*<\/span>/gi, ""); // 5. 处理块级标签换行(仅
等块级标签前后换行,保持行内内容连续) // 块级标签:div、p、h1-h6等,这里以div为例 html = html.replace(/<\/div>\s*/gi, "
\n"); // 闭合div后换行 html = html.replace(/\s*]*>/gi, "\n
"); // 开启div前换行 // 6. 处理
为换行 html = html.replace(//gi, "\n"); // 7. 移除所有剩余HTML标签 html = html.replace(/<[^>]+>/gi, ""); // 8. 清理连续换行(最多保留两个空行,避免过多空行) html = html.replace(/\n{3,}/g, "\n\n"); // 去除首尾空白 html = html.trim(); return html; }; const extractImages = (html) => { const images = []; // 正则匹配 img 标签,提取 src(url)和 data-aid const imgRegex = /]*src="([^"]+)"[^>]*data-aid="(\d+)"[^>]*>/gi; let match; // 循环匹配所有图片标签 while ((match = imgRegex.exec(html)) !== null) { images.push({ url: match[1], // 图片的 src 地址 aid: Number(match[2]), // 图片的 data-aid 属性值 }); } return images; }; let linkUrl = ref(""); let linkText = ref(""); let linkState = ref(false); const openLink = () => { console.log("打开链接"); linkState.value = true; }; const closeLink = () => { console.log("关闭链接"); linkState.value = false; linkText.value = ""; linkUrl.value = ""; }; const insertLink = () => { if (linkText.value == "" || linkUrl.value == "") { creationAlertBox("error", "请输入链接文字和链接地址"); return; } const a = document.createElement("a"); a.href = linkUrl.value; a.target = "_blank"; a.textContent = linkText.value; console.log("insertLink", lastSelection); // 先删除选中的内容,再插入链接 lastSelection.deleteContents(); lastSelection.insertNode(a); // 移动光标到元素后面并确保光标位置被正确设置和获取 const newRange = document.createRange(); newRange.setStartAfter(a); newRange.setEndAfter(a); // 更新选择范围 const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(newRange); lastSelection = newRange; closeLink(); }; return { insertLink, linkUrl, linkText, linkState, openLink, closeLink, userInfoWin, titleLength, submit, insertLabel, emojiState, openEmoji, closeEmoji, selectEmoji, optionEmoji, isPTitle, onEditorInput, onEditorFocus, onEditorBlur, paragraphTitle, info, tagList, token, cutAnonymity, editorRef, insertImage, judgeIsEmpty, isEmpty }; }, }); editApp.component("headTop", headTop); editApp.mount("#edit");