// 简单版本的论坛编辑器,确保图片插入功能正常 const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue; const editApp = createApp({ setup() { const LANG = location.href.indexOf("lang=en") > 0 ? "en" : "zh-CN"; const title = ref(""); const saveStatus = ref(""); const uniqid = ref(""); const info = ref({}); const token = ref(""); let editor = null; const draftKey = "publish_admin_draft"; let uConfigData = {}; let imageLength = 10; let videoLength = 5; const formatTime = (d) => { const pad = (n) => String(n).padStart(2, "0"); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; const extractImages = (dom) => { const images = []; 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; if (Number(aid)) { images.push({ url: cleanUrl, aid: Number(aid), }); } }); return images; }; const extractVideos = (dom) => { const videoElements = dom.querySelectorAll("video"); const result = []; videoElements.forEach((videoEl) => { const posterurl = videoEl.getAttribute("poster")?.trim() || ""; // 视频地址 const urlObj = new URL(posterurl); const posterid = urlObj.searchParams.get("aid"); const sourceEl = videoEl.querySelector("source"); const url = sourceEl.getAttribute("src") || null; const obj = new URL(url); 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 cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1); // 提交 const submit = (status) => { const infoTarget = { ...info.value } || {}; // 获取 TextBus 内容 // TextBus 1.0: editor.getContents().content // Fallback to generic HTML retrieval if needed let content = ""; if (editor && typeof editor.getContents === 'function') { const contents = editor.getContents(); content = (typeof contents === 'string') ? contents : (contents.content || ""); } else if (editor && typeof editor.getHTML === 'function') { content = editor.getHTML(); } // 临时创建一个 div 来解析 content 中的图片和视频 const tempDiv = document.createElement('div'); tempDiv.innerHTML = content; const images = extractImages(tempDiv); const videos = extractVideos(tempDiv); 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; infoTarget.title = title.value; const data = { ...infoTarget, content, }; ajax("/v2/api/forum/postPublishTopic", { info: data, token: token.value, status, htmledit: 1, }).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 cUpload = () => { ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => { const data = res.data; uConfigData = data; }); }; const init = () => { ajax("/v2/api/forum/postPublishInit", { uniqid: uniqid.value, htmledit: 1, }) .then((res) => { const data = res.data; if (res.code != 200) { creationAlertBox("error", res.message || "操作失败"); return; } const infoTarget = data.info || {}; // if (infoTarget.content) infoTarget.content = `
2026年度研究生课程火热招生中!
\n2026年度研究生课程火热招生中!
` info.value = infoTarget; token.value = data.token; if (infoTarget.title) title.value = infoTarget.title; nextTick(() => { initEditor(); }); }) .catch((err) => { console.log("err", err); }); }; // 上传图片/视频 获取url const uploading = (file, name, type) => { return new Promise((resolve, reject) => { const upload = () => { let config = uConfigData; const formData = new FormData(); formData.append(config.requestName, file); // 文件数据 formData.append("name", name); // 文件名 formData.append("type", type); // 文件名 if (config.params && config.params.data) { formData.append("data", config.params.data); } const xhr = new XMLHttpRequest(); xhr.open("POST", config.url, true); xhr.withCredentials = true; // 允许携带 Cookie // 监听上传进度 xhr.upload.onprogress = function (event) { if (event.lengthComputable) { // const percentComplete = (event.loaded / event.total) * 100; // progress.value = Math.round(percentComplete); } }; xhr.onload = function () { if (xhr.status === 200) { const res = JSON.parse(xhr.responseText); if (res.code == 200) { const data = res.data; resolve(data); } else { creationAlertBox("error", res.message || "上传失败"); reject(res); } } else { creationAlertBox("error", "上传失败"); reject(new Error("Upload failed")); } }; xhr.onerror = function () { creationAlertBox("error", "网络错误,上传失败"); reject(new Error("Network error")); }; xhr.send(formData); }; if (!uConfigData || !uConfigData.url) { ajaxGet(`/v2/api/config/upload?type=topic`).then((res) => { const data = res.data; uConfigData = data; upload(); }); } else { upload(); } }); }; const initEditor = () => { if (!window.textbus) { console.error("TextBus is not loaded"); return; } const editorConfig = { content: info.value?.content || "", // 配置工具栏:去除音频、源码、组件库、插入段落 // 根据 TextBus 文档,配置 providers.provide 可以在一定程度上控制工具栏, // 但对于标准版 @textbus/editor,最直接的方式是使用 toolbar 配置项(如果支持) // 或者通过 CSS 隐藏不需要的按钮(作为兜底方案,因为 CDN 版本配置灵活性有限) // 尝试配置工具栏项,仅保留需要的 // 注意:TextBus 的工具栏配置键名可能需要根据具体版本调整 // 下面是一个常见的 TextBus 工具栏配置示例 toolbar: [ ['undo', 'redo'], ['bold', 'italic', 'underline', 'strikeThrough'], ['heading'], ['ol', 'ul'], ['fontSize', 'fontFamily', 'color', 'backgroundColor'], ['image'], // 先放图片 ['video'], // 再放视频 ['link', 'unlink'], ['textAlign', 'textIndent'], ['table'], // ['clean'], // 去掉清除格式 // ['source'], // 去掉源码 // ['audio'], // 去掉音频 (TextBus 默认可能有也可能没有,这里显式不加) // ['block'], // 去掉插入段落/组件库 ], uploader: function (type) { return new Promise((resolve, reject) => { // 1. 数量限制检查 let content = ""; if (editor && typeof editor.getContents === 'function') { const contents = editor.getContents(); content = (typeof contents === 'string') ? contents : (contents.content || ""); } else if (editor && typeof editor.getHTML === 'function') { content = editor.getHTML(); } const tempDiv = document.createElement('div'); tempDiv.innerHTML = content; if (type === 'image') { const currentImages = tempDiv.querySelectorAll('img').length; if (currentImages >= imageLength) { creationAlertBox("error", `最多只能上传 ${imageLength} 张图片`); return; } } else if (type === 'video') { const currentVideos = tempDiv.querySelectorAll('video').length; if (currentVideos >= videoLength) { creationAlertBox("error", `最多只能上传 ${videoLength} 个视频`); return; } } // 视频上传逻辑 if (type === 'video') { const fileInput = document.createElement("input"); fileInput.setAttribute("type", "file"); fileInput.setAttribute("accept", "video/*"); fileInput.style.cssText = "position: absolute; left: -9999px; top: -9999px; opacity: 0"; document.body.appendChild(fileInput); fileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; // 大小限制检查 (视频通常允许更大,这里暂时统一限制,如需单独限制请调整) const maxSize = 1 * 1024 * 1024; // 1M if (file.size > maxSize) { creationAlertBox("error", "文件大小不能超过 1MB"); document.body.removeChild(fileInput); return; } try { // 1. 上传视频文件 const res = await uploading(file, file.name, "video"); const videoUrl = res.url + (res.aid ? `?aid=${res.aid}` : ''); // 2. 尝试获取封面 (可选) // TextBus 插入视频通常只需要 URL,或者 { src, poster } // 由于 uploading 返回的是 URL,我们直接返回 resolve(videoUrl); } catch (err) { console.error(err); } finally { document.body.removeChild(fileInput); } }; fileInput.click(); return; } const fileInput = document.createElement("input"); fileInput.setAttribute("type", "file"); fileInput.setAttribute("accept", "image/png, image/jpeg, image/jpg, image/gif"); fileInput.style.cssText = "position: absolute; left: -9999px; top: -9999px; opacity: 0"; document.body.appendChild(fileInput); fileInput.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; // 2. 大小限制检查 const maxSize = 1 * 1024 * 1024; // 1M if (file.size > maxSize) { creationAlertBox("error", "文件大小不能超过 1MB"); document.body.removeChild(fileInput); return; } try { const res = await uploading(file, file.name, "image"); // TextBus 期望返回 URL resolve(res.url + (res.aid ? `?aid=${res.aid}` : '')); } catch (err) { console.error(err); // reject(err); // TextBus 可能会捕获错误并提示 } finally { document.body.removeChild(fileInput); } }; fileInput.click(); }); } }; try { // 查找 createEditor 函数 let createEditor = null; if (window.textbus && typeof window.textbus.createEditor === 'function') { createEditor = window.textbus.createEditor; } else if (window.textbus && window.textbus.editor && typeof window.textbus.editor.createEditor === 'function') { createEditor = window.textbus.editor.createEditor; } else if (window.textbus && window.textbus.default && typeof window.textbus.default.createEditor === 'function') { createEditor = window.textbus.default.createEditor; } if (!createEditor) { console.error("TextBus createEditor not found", window.textbus); creationAlertBox("error", "TextBus 初始化失败: createEditor 未找到"); return; } // 初始化编辑器 // 根据源码推测:createEditor(config) 返回 editor 实例,然后调用 mount(selector) editor = createEditor(editorConfig); if (editor && typeof editor.mount === 'function') { editor.mount("#editor-text-area"); // 手动隐藏不需要的工具栏按钮 (JS 兜底方案) setTimeout(() => { const hideButtons = () => { const buttons = document.querySelectorAll('.textbus-toolbar-btn, .textbus-btn, button'); const targets = ['音频', '插入音频', 'Audio', '源代码', '查看源码', 'Source', '组件库', 'Components', '段落', '插入段落', 'Paragraph', '清除格式', 'Clean', '格式化', 'Format', '格式刷', 'Brush']; let imageBtn = null; let videoBtn = null; buttons.forEach(btn => { const title = btn.getAttribute('title') || btn.getAttribute('aria-label') || ''; if (targets.some(t => title.includes(t))) { btn.style.display = 'none'; } // 备用:检查图标 class if (btn.querySelector('.textbus-icon-music') || btn.querySelector('.textbus-icon-code') || btn.querySelector('.textbus-icon-components') || btn.querySelector('.textbus-icon-paragraph') || btn.querySelector('.textbus-icon-clean') // 清除格式 ) { btn.style.display = 'none'; } // 单独处理格式刷图标(通常是刷子形状) if (btn.querySelector('.textbus-icon-brush')) { btn.style.display = 'none'; } // 查找图片和视频按钮 if (title.includes('图片') || title.includes('Image') || btn.querySelector('.textbus-icon-image')) { imageBtn = btn; } if (title.includes('视频') || title.includes('Video') || btn.querySelector('.textbus-icon-video')) { videoBtn = btn; } }); // 强制调整顺序:视频放在图片后面 if (imageBtn && videoBtn && imageBtn.parentNode === videoBtn.parentNode) { // 如果视频按钮不在图片按钮的紧邻后面,则移动 if (imageBtn.nextElementSibling !== videoBtn) { imageBtn.parentNode.insertBefore(videoBtn, imageBtn.nextElementSibling); } } }; hideButtons(); // 监听 DOM 变化以防重新渲染 const observer = new MutationObserver(hideButtons); const toolbar = document.querySelector('.textbus-toolbar') || document.querySelector('.textbus-ui-top'); if (toolbar) { observer.observe(toolbar, { childList: true, subtree: true }); } }, 100); } else { // 兼容旧版本或直接传入 selector 的情况 // 如果 createEditor 返回的不是带有 mount 的对象,可能是旧版本 console.warn("Editor instance does not have mount method, assuming auto-mount or different API"); } if (editor && editor.onChange) { editor.onChange.subscribe(() => { saveStatus.value = "有未保存的更改"; }); } } catch (error) { console.log("TextBus init error", error); creationAlertBox("error", "TextBus 初始化异常: " + error.message); } // 点击空白处 focus 编辑器 (TextBus 可能不需要这个,但保留逻辑以防万一) // document.getElementById("editor-text-area").addEventListener("click", (e) => { // if (e.target.id === "editor-text-area") { // // editor.focus(); // } // }); }; // 提取视频第一帧作为封面 (保留辅助函数) const getVideoFirstFrame = (file) => { return new Promise((resolve) => { const video = document.createElement("video"); video.src = URL.createObjectURL(file); video.currentTime = 1; // 截取第 1 秒 video.onloadeddata = () => { video.currentTime = 1; }; video.onseeked = () => { const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height); canvas.toBlob((blob) => { const coverFile = new File([blob], "cover.jpg", { type: "image/jpeg" }); resolve(coverFile); }, "image/jpeg"); }; }); }; const handleTitleInput = () => { saveStatus.value = "有未保存的更改"; }; onMounted(() => { const params = getUrlParams(); uniqid.value = params.uniqid || ""; cUpload(); nextTick(() => { init(); }); }); onUnmounted(() => { if (editor && typeof editor.destroy === 'function') { editor.destroy(); editor = null; } }); return { title, saveStatus, submit, handleTitleInput, cutAnonymity, info, }; }, }); editApp.mount("#edit");