// 简单版本的论坛编辑器,确保图片插入功能正常
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");