// 简单版本的论坛编辑器,确保图片插入功能正常 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); let uniqid = ref(""); onMounted(() => { const params = getUrlParams(); uniqid.value = params.uniqid || ""; getUserInfoWin(); cUpload(); init(); checkWConfig(); // 添加selectionchange事件监听,当鼠标选中区域内容时更新lastSelection document.addEventListener("selectionchange", handleSelectionChange); }); // 组件卸载时移除事件监听 onUnmounted(() => { document.removeEventListener("selectionchange", handleSelectionChange); }); let imageLength = 10; let videoLength = 5; const checkWConfig = () => { const wConfig = JSON.parse(localStorage.getItem("wConfig")) || {}; console.log("wConfig", 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; }); }; let info = ref({}); let tagList = ref([]); let token = ref(""); let infoImages = []; 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 || {}; if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoTarget.attachments); info.value = infoTarget; token.value = data.token; nextTick(() => { judgeIsEmpty(); }); }) .catch((err) => { console.log("err", err); }); }; const restoreHtml = (formattedText, attachments) => { const imageList = attachments?.images || []; const filesList = attachments?.files || []; const videosList = attachments?.videos || []; 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\]([\s\S]*?)\[\/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) { imageList.splice(imageList.indexOf(image), 1); return `
`; } return match; // 未找到对应图片时保留原始标记 }); html = html.replace(/\[attach\](\d+)\[\/attach\]/gi, (match, aid) => { // 查找对应的图片信息 const image = imageList.find((img) => img.aid == aid); if (image) { imageList.splice(imageList.indexOf(image), 1); return `
`; } // 查找对应的视频信息 const video = videosList.find((v) => v.aid == aid); if (video) { console.log("video", video); videosList.splice(videosList.indexOf(video), 1); return ``; } return match; // 未找到对应图片时保留原始标记 }); // 6. 还原填充标签 html = html.replace(/([^<]+<\/span>)\s+/gi, '$1 '); // 7. 清理多余的
标签 html = html.replace(/

/g, "
"); imageList.forEach((element) => { html += `
`; }); // video 不要预加载 videosList.forEach((element) => { html += `
`; }); return html; }; onMounted(() => { setTimeout(() => focusLastNode(), 1000); // document.addEventListener("keydown", handleUndoKeydown); }); 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) => { const images = extractImages(editorRef.value); const count = imageLength - images.length || 0; if (count == 0) { creationAlertBox("error", `最多只能上传 ${imageLength} 张图片`); return; } const target = event.target.files[0]; if (!target) return; // 处理未选择文件的情况 if (target.size > maxSize) { creationAlertBox("error", "文件大小不能超过 20MB"); return; } loading.value = true; uploading(target, target.name, "image").then((data) => { const selection = window.getSelection(); editorRef.value.focus(); if (lastSelection) { selection.removeAllRanges(); selection.addRange(lastSelection); } const html = `
`; document.execCommand("insertHTML", false, html); judgeIsEmpty(); }); }; const insertVideo = async (event) => { const videos = extractVideos(editorRef.value); const count = videoLength - videos.length || 0; if (count == 0) { creationAlertBox("error", `最多只能上传 ${videoLength} 个视频`); return; } const videoFile = event.target.files[0]; if (!videoFile) return; // 处理未选择文件的情况 if (videoFile.size > maxSize) { creationAlertBox("error", "文件大小不能超过 20MB"); return; } loading.value = true; console.log("videoFile", videoFile); // 步骤1:提取视频第一帧(等待提取完成) const coverFile = await getVideoFirstFrame(videoFile); console.log("第一帧提取成功", coverFile); // 步骤2:先上传视频文件(type 传 'video',按后端要求调整) const videoUploadRes = await uploading(videoFile, videoFile.name, "video"); console.log("视频上传成功", videoUploadRes); // 步骤3:再上传第一帧封面(type 传 'cover',按后端要求调整) const coverUploadRes = await uploading(coverFile, coverFile.name, "image"); console.log("封面上传成功", coverUploadRes); console.log("最终", videoUploadRes, videoUploadRes); const selection = window.getSelection(); editorRef.value.focus(); if (lastSelection) { selection.removeAllRanges(); selection.addRange(lastSelection); } const html = `
`; document.execCommand("insertHTML", false, html); judgeIsEmpty(); }; let isEmpty = ref(true); const onEditorInput = (event) => { 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 = () => { if (!isLogin.value) { goLogin(); return; } const content = editorRef.value.innerText; ajax("/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") && !editorRef.value.querySelector("video"); }; // 处理选中文本变化的函数 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); 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 selection = window.getSelection(); editorRef.value.focus(); if (lastSelection) { selection.removeAllRanges(); selection.addRange(lastSelection); } document.execCommand("insertText", false, emoji); closeEmoji(); judgeIsEmpty(); }; let format = ref(""); const submit = (status) => { const infoTarget = { ...info.value } || {}; let content = editorRef.value.innerHTML; 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(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/" + data.uniqid, "_self"); else redirectToExternalWebsite("/", "_self"); }; setTimeout(() => back(), 1500); }); }; const formatContent = (html) => { // 1. 替换图片标签 html = html.replace(/]*data-aid="(\d+)"[^>]*>/gi, "[attachimg]$1[/attachimg]"); // 1.1 替换视频标签 html = html.replace(/]*aid="(\d+)"[^>]*>[\s\S]*?<\/video>/gi, "[attach]$1[/attach]"); // 2. 替换H2标签 html = html.replace(/]*>([\s\S]*?)<\/h2>/gi, "[b]$1[/b]"); // 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标签 a标签除外 html = html.replace(/<(?!(a\b|\/a\b))[^>]+>/gi, ""); // 8. 清理连续换行(最多保留两个空行,避免过多空行) html = html.replace(/\n{3,}/g, "\n\n"); // 去除首尾空白 html = html.trim(); return html; }; const extractImages = (dom) => { const images = []; // 直接查找页面中所有带 data-aid 的 img 标签 const imgElements = dom.querySelectorAll("img"); imgElements.forEach((imgEl) => { const url = imgEl.getAttribute("src")?.trim() || ""; const aid = imgEl.dataset.aid?.trim() || ""; // 用 dataset 简化自定义属性读取 images.push({ url, aid: Number(aid), }); }); return images; }; const extractVideos = (dom) => { // 1. 查找页面中所有