Files
PC-Light-Forum/js/publish_admin.js
DESKTOP-RQ919RC\Pc 620d21dd5d feat: 新增发布主题页面及编辑器功能
refactor(css): 优化详情页样式并移除冗余代码
feat(js): 实现发布主题的编辑器功能及图片/视频上传
docs: 添加编辑器样式文件及发布页面HTML结构
2025-12-11 19:11:43 +08:00

449 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 简单版本的论坛编辑器,确保图片插入功能正常
const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue;
const { createEditor, createToolbar, SlateTransforms, Boot, SlateEditor } = window.wangEditor;
const editApp = createApp({
setup() {
const E = window.wangEditor;
const LANG = location.href.indexOf("lang=en") > 0 ? "en" : "zh-CN";
E.i18nChangeLanguage(LANG);
const title = ref("");
const saveStatus = ref("");
const uniqid = ref("");
const info = ref({});
const token = ref("");
let editor = null;
let toolbar = 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 } || {};
let content = editor.getHtml();
const editorDom = document.getElementById("editor-text-area");
const images = extractImages(editorDom);
const videos = extractVideos(editorDom);
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 = `<div style=\"text-align: center;\"><strong>2026年度研究生课程火热招生中!</strong></div><div>\n<strong>2026</strong>年度研究生课程火热招生中!</div>`
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 = () => {
const maxSize = 1 * 1024 * 1024; // 1M
const editorConfig = {
// 自定义 HTML 处理规则
htmlFilter: {
// 保留 img 标签(核心)
tags: {
img: true, // true 表示不过滤该标签,也可配置具体属性白名单
},
// 更精细控制:指定 img 允许的属性(避免过滤关键属性)
attrs: {
img: ["src", "alt", "width", "height", "data-id", "class"], // 按需添加自定义属性
},
},
// 关闭「粘贴/插入 HTML 时自动清理空标签」(若 img 暂未赋值 src 可能触发)
autoFormatAfterInit: false,
autoFormatOnEditable: false,
placeholder: "输入正文",
// scroll: true, // 禁止编辑器滚动
MENU_CONF: {
["emotion"]: {
emotions: ["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵‍💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"],
},
["insertImage"]: {
onInsertedImage(imageNode) {
const { src, alt, url, href } = imageNode;
},
async parseImageSrc(src) {
console.log("parseImageSrc");
// 如果图片链接中已经包含了 ?aid= ,则说明是本站图片,直接返回,无需处理
if (src.includes("?aid=")) return src;
console.log("parseImageSrc");
// 对于不含 ?aid= 的外部图片,执行上传转换
console.log("uConfigData", uConfigData);
if (!uConfigData || !uConfigData.url) return src;
console.log("parseImageSrc");
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);
return "";
}
},
checkImage: (src, alt, url) => {
if (src.indexOf("http") !== 0) {
return "图片网址必须以 http/https 开头";
}
return true;
}, // 也支持 async 函数
},
["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");
console.log("img", img);
insertFn(`${img.url}?aid=${img.aid}`);
} catch (err) {
console.error("上传出错:", err);
}
},
},
["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");
const coverFile = await getVideoFirstFrame(file);
const coverUploadRes = await uploading(coverFile, coverFile.name, "image");
insertFn(`${videoUploadRes.url}?aid=${videoUploadRes.aid}`, `${coverUploadRes.url}?aid=${coverUploadRes.aid}`);
} catch (err) {
console.error("上传出错:", err);
}
},
},
},
onChange(editor) {
// console.log(editor.getHtml());
saveStatus.value = "有未保存的更改";
},
hoverbarKeys: { text: { menuKeys: [] }, video: { menuKeys: [] } },
};
try {
editor = E.createEditor({
selector: "#editor-text-area",
// content: [],
html: info.value?.content || "",
// html: `<div style="text-align: center;"><img src="https://o.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_pstfX-G1NFX9ddrB_WbUGy8P79gQxdCR7TG75gV7NkzNDQyOQ~~?aid=1011800" /></div>`,
config: editorConfig,
});
} catch (error) {
console.log("error", error);
}
// 如果有远程数据,使用远程数据
// if (info.value && info.value.content) {
// editor.setHtml(info.value.content);
// } else {
// // 恢复草稿 (仅在没有远程数据时)
// const cache = localStorage.getItem(draftKey);
// if (cache) {
// try {
// const data = JSON.parse(cache);
// if (data && (data.title || data.content)) {
// if (data.title) title.value = data.title;
// if (data.content) editor.setHtml(data.content);
// if (data.updatedAt) {
// saveStatus.value = `已保存 ${formatTime(new Date(data.updatedAt))}`;
// }
// }
// } catch (_) {}
// }
// }
const toolbarConfig = {
excludeKeys: ["insertVideo", "fullScreen"],
};
toolbar = E.createToolbar({
editor,
selector: "#editor-toolbar",
config: toolbarConfig,
});
// 点击空白处 focus 编辑器
document.getElementById("editor-text-area").addEventListener("click", (e) => {
if (e.target.id === "editor-text-area") {
editor.blur();
editor.focus(true); // 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 == null) return;
editor.destroy();
editor = null;
});
return {
title,
saveStatus,
submit,
handleTitleInput,
cutAnonymity,
info,
};
},
});
editApp.mount("#edit");