feat: 新增发布主题页面及编辑器功能
refactor(css): 优化详情页样式并移除冗余代码 feat(js): 实现发布主题的编辑器功能及图片/视频上传 docs: 添加编辑器样式文件及发布页面HTML结构
This commit is contained in:
24
js/public.js
24
js/public.js
@@ -13,13 +13,16 @@ const getScriptParameter = (paramName) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 判断是否已经创建了v参数
|
||||
const vParam = getScriptParameter("v");
|
||||
|
||||
// 导出ajax函数
|
||||
const ajax = (url, data) => {
|
||||
axios.defaults.withCredentials = true;
|
||||
axios.defaults.emulateJSON = true;
|
||||
|
||||
url = url.indexOf("https://") > -1 ? url : forumBaseURL + url;
|
||||
if (data) data["v"] = getScriptParameter("v") || "v2";
|
||||
if (data) data["v"] = vParam || "v2";
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "n1pstcsmw6m6bcx49z705xhvduqviw29";
|
||||
|
||||
@@ -51,7 +54,7 @@ const ajaxdelete = (url, data) => {
|
||||
|
||||
url = url.indexOf("https://") > -1 ? url : forumBaseURL + url;
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (data) data["v"] = getScriptParameter("v") || "v2";
|
||||
if (data) data["v"] = vParam || "v2";
|
||||
axios
|
||||
.delete(url, {
|
||||
emulateJSON: true,
|
||||
@@ -83,7 +86,7 @@ const ajaxGet = (url) => {
|
||||
url = url.indexOf("https://") > -1 ? url : forumBaseURL + url;
|
||||
|
||||
const paramSymbol = url.includes("?") ? "&" : "?";
|
||||
url = `${url}${paramSymbol}v=${getScriptParameter("v") || "v2" }`;
|
||||
url = `${url}${paramSymbol}v=${vParam || "v2"}`;
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (location.hostname == "127.0.0.1") axios.defaults.headers.common["Authorization"] = "n1pstcsmw6m6bcx49z705xhvduqviw29";
|
||||
@@ -107,6 +110,7 @@ const ajaxGet = (url) => {
|
||||
resolve(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.status == 401) go_ajax_Login();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
@@ -419,17 +423,3 @@ const go_ajax_Login = () => {
|
||||
if (typeof ajax_login === "function") ajax_login();
|
||||
else window.open("https://passport.gter.net/?referer=" + escape(location.href), "_self");
|
||||
};
|
||||
|
||||
// const loadJsFile = (url) => {
|
||||
// var xhr = new XMLHttpRequest();
|
||||
// xhr.open("GET", url, true);
|
||||
// xhr.onreadystatechange = function () {
|
||||
// if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
// var scriptCode = xhr.responseText;
|
||||
// var script = document.createElement("script");
|
||||
// script.innerHTML = scriptCode;
|
||||
// document.head.appendChild(script);
|
||||
// }
|
||||
// };
|
||||
// xhr.send();
|
||||
// };
|
||||
|
||||
448
js/publish_admin.js
Normal file
448
js/publish_admin.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// 简单版本的论坛编辑器,确保图片插入功能正常
|
||||
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");
|
||||
2
js/textbus.min.js
vendored
Normal file
2
js/textbus.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24129
js/wangeditor.js
Normal file
24129
js/wangeditor.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user