fix(editor): 修复编辑器样式和功能问题
修复编辑器容器高度设置问题,统一h2标签为h1 调整图片和视频的显示样式,修复表格背景色 优化编辑器工具栏功能,修复链接插入逻辑
This commit is contained in:
@@ -173,18 +173,22 @@
|
||||
}
|
||||
#details .matter .matter-left .html img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
display: inline-block;
|
||||
}
|
||||
#details .matter .matter-left .html video {
|
||||
margin: 0 auto;
|
||||
}
|
||||
#details .matter .matter-left .html h2 {
|
||||
#details .matter .matter-left .html h1 {
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
}
|
||||
#details .matter .matter-left .html tr,
|
||||
#details .matter .matter-left .html td {
|
||||
background: transparent;
|
||||
}
|
||||
#details .matter .matter-left .last-time {
|
||||
color: #aaaaaa;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -200,20 +200,25 @@
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
video {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
h1 {
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
tr,
|
||||
td {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.last-time {
|
||||
|
||||
27
css/edit.css
27
css/edit.css
@@ -76,8 +76,10 @@
|
||||
#edit .edit-container #editor—wrapper {
|
||||
z-index: 100;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper .bold {
|
||||
font-weight: bold;
|
||||
#edit .edit-container #editor—wrapper .bold,
|
||||
#edit .edit-container #editor—wrapper .bold span {
|
||||
font-weight: bolder;
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper .editor-toolbar {
|
||||
height: 36px;
|
||||
@@ -262,14 +264,14 @@
|
||||
#edit .edit-container #editor—wrapper .editor-toolbar .toolbar-item.link .link-box .btn:hover {
|
||||
background-color: #23e0b6;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper .editor-toolbar .toolbar-item.h2.pitch {
|
||||
#edit .edit-container #editor—wrapper .editor-toolbar .toolbar-item.h1.pitch {
|
||||
background-color: #f6f6bd;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper .editor-toolbar .toolbar-item.active > button {
|
||||
background-color: #f6f6bd;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper #editor-container {
|
||||
min-height: 500px;
|
||||
height: 500px;
|
||||
max-height: 80vh;
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
@@ -280,6 +282,17 @@
|
||||
text-decoration: underline;
|
||||
color: #04b0d5;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper #editor-container h1,
|
||||
#edit .edit-container #editor—wrapper #editor-container h1 span {
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
}
|
||||
#edit .edit-container #editor—wrapper #editor-container video {
|
||||
max-width: 100%;
|
||||
}
|
||||
#edit .edit-container .content-input {
|
||||
min-height: 509px;
|
||||
font-family: "PingFangSC-Regular", "PingFang SC", sans-serif;
|
||||
@@ -306,12 +319,12 @@
|
||||
max-width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
#edit .edit-container .content-input h2 {
|
||||
#edit .edit-container .content-input h1 {
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
}
|
||||
#edit .edit-container .content-input .blue {
|
||||
color: #026277;
|
||||
|
||||
@@ -85,8 +85,10 @@
|
||||
#editor—wrapper {
|
||||
z-index: 100;
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
.bold,
|
||||
.bold span {
|
||||
font-weight: bolder;
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
@@ -305,7 +307,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
&.h1 {
|
||||
&.pitch {
|
||||
background-color: rgba(246, 246, 189, 1);
|
||||
}
|
||||
@@ -320,7 +322,7 @@
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
min-height: 500px;
|
||||
height: 500px;
|
||||
max-height: 80vh;
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
@@ -331,6 +333,19 @@
|
||||
text-decoration: underline;
|
||||
color: #04b0d5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h1 span {
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,13 +377,19 @@
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
h1 {
|
||||
// color: #000000;
|
||||
// font-size: 18px;
|
||||
// line-height: 30px;
|
||||
|
||||
// font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
// font-weight: 650;
|
||||
|
||||
font-family: "PingFangSC-Semibold", "PingFang SC Semibold", "PingFang SC", sans-serif;
|
||||
font-weight: 650;
|
||||
color: #000000;
|
||||
font-size: 18px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.blue {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</div>
|
||||
|
||||
<div id="editor—wrapper" class="editor—wrapper">
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div ref="toolbarRef" id="toolbar-container" class="editor-toolbar flexacenter">
|
||||
<!-- <div class="toolbar-item flexacenter h2" :class="{'pitch': isPTitle}" @click="paragraphTitle">
|
||||
@@ -92,9 +91,6 @@
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <div id="toolbar-container"></div> -->
|
||||
<div id="editor-container" ref="editorRef"><!-- 编辑器 --></div>
|
||||
</div>
|
||||
<!-- 内容编辑区 -->
|
||||
|
||||
@@ -130,6 +130,25 @@ const appSectionIndex = createApp({
|
||||
|
||||
if (!targetInfo.hidden) targetInfo.hidden = 0;
|
||||
|
||||
targetInfo.attachments = {
|
||||
images: [
|
||||
{
|
||||
aid: 708161,
|
||||
url: "https://o.x-php.com/Zvt57TuJSUvkyhw-xG7Y2l-S_pItc37qqsgFptxhXa6RWi26P-BuTQYWFOfCsdkb8LQ0NDI5",
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
videos: [
|
||||
{
|
||||
aid: 1009770,
|
||||
posterid: 1009849,
|
||||
posterurl: "https://o.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_polfXuP1NFX9ddrB_WbUGy8P79gQxdHR-HKts0V7NkzNDQyOQ~~",
|
||||
url: "https://o.x-php.com/Zvt57TuJSUvkyhw-xG_Y2l-U_polcniG1NFX9ddrB_WbUGy8P79gQxcSFbqQ78MV7NkzNDQyOQ~~",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// targetInfo.content = '<p style="text-align: center;">红红火火<strong>恍恍惚惚</strong></p>[b]红红火火恍恍惚惚有[/b]<p>\n</p><p>\n</p><p>[attach]1009770[/attach]</p><p>\n</p><p>\n</p><p style="text-align: center;">[img=96]708161[/img]</p><p style="text-align: center;">65456456456456465 <a href="11111" target="_blank" contenteditable="false">111</a> </p><p style="text-align: center;">\n</p>';
|
||||
targetInfo.content = '如果你热爱古典文献,又希望在现代职场大展身手——这个项目可能就是你的“本命”!作为香港最正统的中国语言文学项目,它既传承经典,又为你打跨境传播等全新赛道!\n\n<b>🌟 项目核心亮点</b>权威认证:中国语言文学专业认证,考公考编无障碍\n古今结合:深耕古典文献与理论,同时对接AI内容创作等新兴领域\n语言友好:全程中文授课(普通话+粤语),无语言适应压力\n规模可观:每年录取150+,机会相对较多\n\n点击前往 [港校项目库] 查看 \n<a href="https://program.gter.net/details/tf1yFYIBSda7Y5k7s9iHeLVSxDiuYTljNA~~" target="_blank" contenteditable="false">中国语言文学</a>\n手机扫码查看\n[attachimg]1008942[/attachimg]\n\n<b>🎯 谁最适合申请?</b>中文系、汉语言、古代文学等对口专业背景\n希望在教育、传媒、AI内容或国际中文教育领域发展\n看重学校声誉与专业正统性的同学\n<b>💼 毕业出路超多元</b>除了教师、公务员等传统路径,毕业生还活跃于:\n✔ 跨境文化传播\n✔ AI内容策划与生成\n✔ 国际中文教育\n✔ 出版与编辑行业\n<b>📌 申请指南</b>专业背景:严格限定中文相关专业,暂不接受跨专业申请\n成绩要求:985/211同学建议86+\n语言成绩:雅思7.0(小分5.5)即可\n面试体验:氛围轻松,专业问题较少\n<b>💡 内部消息参考</b>前几轮拿到面试邀请的同学基本都能录取\n985背景优势明显,建议尽早提交申请\n双非同学如背景特别匹配也可尝试\n<b>🤝 欢迎交流</b>你对中国文学在AI时代的发展有什么想法?或者对哪个就业方向,申请问题欢迎在评论区分享交流!\n欢迎加入寄托香港群交流\n\n[attachimg]969489[/attachimg]';
|
||||
|
||||
// 替换换行
|
||||
@@ -194,6 +213,25 @@ const appSectionIndex = createApp({
|
||||
// 4. 还原粗体标记为h2标签
|
||||
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<h2>$1</h2>");
|
||||
|
||||
// 5. 还原【新增图片格式】[img=width,height]aid[/img] 或 [img]aid[/img]
|
||||
html = html.replace(/\[img(?:=([0-9]+(?:\.[0-9]+)?)(?:,([0-9]+(?:\.[0-9]+)?))?)?\](\d+)\[\/img\]/gi, (match, width, height, aid) => {
|
||||
const image = imageList.find((img) => String(img.aid) === String(aid)); // 统一字符串比较,避免类型问题
|
||||
if (!image) return match;
|
||||
|
||||
// 从列表中移除已匹配的图片(避免重复使用)
|
||||
const index = imageList.findIndex((img) => String(img.aid) === String(aid));
|
||||
if (index > -1) imageList.splice(index, 1);
|
||||
|
||||
// 拼接img标签(带宽高样式,宽高为0则不设置)
|
||||
let style = "";
|
||||
const w = width ? Number(width) : 0;
|
||||
const h = height ? Number(height) : 0;
|
||||
if (w > 0 && h > 0) style = `style="width: ${w}px; height: ${h}px;"`;
|
||||
else if (w > 0) style = `style="width: ${w}px;"`;
|
||||
|
||||
return `<img src="${image.url}" ${style}>`;
|
||||
});
|
||||
|
||||
console.log(html);
|
||||
|
||||
// 5. 统一在单次遍历中按出现顺序替换 attach/attachimg
|
||||
|
||||
761
js/edit copy.js
Normal file
761
js/edit copy.js
Normal file
@@ -0,0 +1,761 @@
|
||||
// 简单版本的论坛编辑器,确保图片插入功能正常
|
||||
const { createApp, ref, computed, onMounted, nextTick, onUnmounted } = Vue;
|
||||
const { headTop } = await import(withVer("../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. 还原换行符为<br>标签
|
||||
html = html.replace(/\n/g, "<br>");
|
||||
|
||||
// 2. 还原块级标签的换行标记
|
||||
html = html.replace(/<br><div>/g, "<div>");
|
||||
html = html.replace(/<\/div><br>/g, "</div>");
|
||||
|
||||
// 3. 还原标签标记为span.blue
|
||||
html = html.replace(/\[tag\]([^[]+)\[\/tag\]/gi, '<span class="blue">#$1</span> <span class="fill"></span> ');
|
||||
|
||||
// 4. 还原粗体标记为h2标签
|
||||
html = html.replace(/\[b\]([\s\S]*?)\[\/b\]/gi, "<h2>$1</h2>");
|
||||
|
||||
// 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 `<img src="${image.url}" data-aid="${aid}"><br/>`;
|
||||
}
|
||||
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 `<img src="${image.url}" data-aid="${aid}"><br/>`;
|
||||
}
|
||||
|
||||
// 查找对应的视频信息
|
||||
const video = videosList.find((v) => v.aid == aid);
|
||||
if (video) {
|
||||
console.log("video", video);
|
||||
videosList.splice(videosList.indexOf(video), 1);
|
||||
return `<video contenteditable="false" src="${video.url}" width="400" height="400" preload="none" poster="${video.posterurl}" aid="${video.aid}" posterid="${video.posterid}" controls></video>`;
|
||||
}
|
||||
|
||||
return match; // 未找到对应图片时保留原始标记
|
||||
});
|
||||
|
||||
// 6. 还原填充标签
|
||||
html = html.replace(/(<span class="blue">[^<]+<\/span>)\s+/gi, '$1 <span class="fill"></span> ');
|
||||
|
||||
// 7. 清理多余的<br>标签
|
||||
// html = html.replace(/<br><br>/g, "<br>");
|
||||
|
||||
imageList.forEach((element) => {
|
||||
html += `<img src="${element.url}" data-aid="${element.aid}"><br/>`;
|
||||
});
|
||||
|
||||
// video 不要预加载
|
||||
videosList.forEach((element) => {
|
||||
html += `<video contenteditable="false" src="${element.url}" width="400" height="400" preload="none" poster="${element.posterurl}" aid="${element.aid}" posterid="${element.posterid}" controls></video><br/>`;
|
||||
});
|
||||
|
||||
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 = `<img src="${data.url}" data-aid="${data.aid}"><br/>`;
|
||||
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 = `<video width="400" height="400" controls preload="none" poster="${coverUploadRes.url}" src="${videoUploadRes.url}" contenteditable="false" posterid="${coverUploadRes.aid}" aid="${videoUploadRes.aid}"></video><br/>`;
|
||||
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) => {
|
||||
if (realname.value == 0 && userInfoWin.value?.uin > 0) {
|
||||
openAttest();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLogin.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
content = formatContent(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(/<img[^>]*data-aid="(\d+)"[^>]*>/gi, "[attachimg]$1[/attachimg]");
|
||||
|
||||
// 1.1 替换视频标签
|
||||
html = html.replace(/<video[^>]*aid="(\d+)"[^>]*>[\s\S]*?<\/video>/gi, "[attach]$1[/attach]");
|
||||
|
||||
// 2. 替换H2标签
|
||||
html = html.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "[b]$1[/b]");
|
||||
|
||||
// 5. 处理块级标签换行(仅<div>等块级标签前后换行,保持行内内容连续)
|
||||
// 块级标签:div、p、h1-h6等,这里以div为例
|
||||
html = html.replace(/<\/div>\s*/gi, "</div>\n"); // 闭合div后换行
|
||||
html = html.replace(/\s*<div[^>]*>/gi, "\n<div>"); // 开启div前换行
|
||||
|
||||
// 6. 处理<br>为换行
|
||||
html = html.replace(/<br\s*\/?>/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. 查找页面中所有 <video> 节点(返回 NodeList 集合)
|
||||
const videoElements = dom.querySelectorAll("video");
|
||||
const result = [];
|
||||
|
||||
// 2. 遍历每个 video 节点,直接获取属性
|
||||
videoElements.forEach((videoEl) => {
|
||||
const url = videoEl.getAttribute("src")?.trim() || ""; // 视频地址
|
||||
const posterurl = videoEl.getAttribute("poster")?.trim() || ""; // 封面图
|
||||
const aid = videoEl.getAttribute("aid")?.trim() || ""; // 视频 ID(自定义属性)
|
||||
const posterid = videoEl.getAttribute("posterid")?.trim() || ""; // 封面 ID(自定义属性)
|
||||
|
||||
result.push({
|
||||
aid: Number(aid),
|
||||
posterid: Number(posterid),
|
||||
url,
|
||||
posterurl,
|
||||
});
|
||||
});
|
||||
|
||||
console.log("提取完成的视频列表:", result);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
lastSelection = selection.getRangeAt(0);
|
||||
// console.log("更新选区");
|
||||
updatePTitleStatus();
|
||||
}
|
||||
};
|
||||
|
||||
let linkUrl = ref("");
|
||||
let linkText = ref("");
|
||||
|
||||
let linkState = ref(false);
|
||||
const openLink = () => {
|
||||
console.log("打开链接");
|
||||
const text = lastSelection ? lastSelection.toString().trim() : "";
|
||||
console.log("lastSelection", text);
|
||||
linkText.value = text;
|
||||
linkState.value = true;
|
||||
};
|
||||
|
||||
const closeLink = () => {
|
||||
console.log("关闭链接");
|
||||
linkState.value = false;
|
||||
linkText.value = "";
|
||||
linkUrl.value = "";
|
||||
};
|
||||
|
||||
const insertLink = () => {
|
||||
if (linkText.value == "" || linkUrl.value == "") {
|
||||
creationAlertBox("error", "请输入链接文字和链接地址");
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
editorRef.value.focus();
|
||||
if (lastSelection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(lastSelection);
|
||||
}
|
||||
const html = `<a href="${linkUrl.value}" target="_blank" contenteditable="false">${linkText.value}</a>`;
|
||||
document.execCommand("insertHTML", false, html);
|
||||
closeLink();
|
||||
judgeIsEmpty();
|
||||
};
|
||||
|
||||
const uploading = (target, name, type) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let config = uConfigData;
|
||||
const formData = new FormData();
|
||||
formData.append(config.requestName, target); // 文件数据
|
||||
formData.append("name", name); // 文件名
|
||||
formData.append("type", type); // 文件名
|
||||
formData.append("data", config.params.data); // 文件名
|
||||
|
||||
ajax(config.url, formData)
|
||||
.then((res) => {
|
||||
const data = res.data;
|
||||
try {
|
||||
resolve(data);
|
||||
} catch (error) {
|
||||
console.error("插入图片出错:", error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 封装:提取视频第一帧(返回 Promise,resolve 第一帧 Blob)
|
||||
const getVideoFirstFrame = (videoFile) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!(videoFile instanceof File) || !videoFile.type.startsWith("video/")) {
|
||||
reject(new Error("请传入合法的视频文件"));
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(videoFile);
|
||||
const video = document.createElement("video");
|
||||
video.src = objectUrl;
|
||||
video.preload = "auto";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
const cleanup = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
video.src = "";
|
||||
};
|
||||
|
||||
video.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
cleanup();
|
||||
reject(new Error("视频加载失败,请检查文件完整性"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
video.addEventListener(
|
||||
"loadeddata",
|
||||
() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const w = video.videoWidth || 320;
|
||||
const h = video.videoHeight || 240;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
try {
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
cleanup();
|
||||
if (!blob) {
|
||||
reject(new Error("第一帧提取失败,Blob 生成异常"));
|
||||
return;
|
||||
}
|
||||
const frameFile = new File([blob], `video_cover_${Date.now()}.png`, { type: "image/png" });
|
||||
resolve(frameFile);
|
||||
},
|
||||
"image/png",
|
||||
0.9
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const linkClick = () => {};
|
||||
|
||||
return { linkClick, insertVideo, insertLink, linkUrl, linkText, linkState, openLink, closeLink, handleClick, uniqid, userInfoWin, titleLength, submit, emojiState, openEmoji, closeEmoji, selectEmoji, optionEmoji, isPTitle, onEditorInput, onEditorFocus, onEditorBlur, paragraphTitle, info, tagList, token, cutAnonymity, editorRef, insertImage, judgeIsEmpty, isEmpty };
|
||||
},
|
||||
});
|
||||
|
||||
editApp.component("headTop", headTop);
|
||||
editApp.mount("#edit");
|
||||
816
js/edit.js
816
js/edit.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user