refactor(editor): 重构编辑器组件及样式,优化功能实现

重构编辑器工具栏样式及功能,使用wangEditor替换原有实现
优化图片和视频上传逻辑,增加自定义校验和上传处理
调整编辑器样式,修复对齐功能及段落标题样式
更新表情选择器位置逻辑,支持上下方向显示
统一组件导入方式,添加版本控制参数防止缓存
This commit is contained in:
DESKTOP-RQ919RC\Pc
2025-11-26 19:01:26 +08:00
parent 460450c339
commit 275b78b221
23 changed files with 678 additions and 320 deletions

View File

@@ -1,15 +1,17 @@
const { createApp, ref, onMounted, nextTick, onUnmounted, computed, watch, provide } = Vue;
import { itemForum } from "../component/item-forum/item-forum.js";
import { itemOffer } from "../component/item-offer/item-offer.js";
import { itemSummary } from "../component/item-summary/item-summary.js";
import { itemVote } from "../component/item-vote/item-vote.js";
import { itemMj } from "../component/item-mj/item-mj.js";
import { itemTenement } from "../component/item-tenement/item-tenement.js";
import { latestList } from "../component/latest-list/latest-list.js";
import { slideshowBox } from "../component/slideshow-box/slideshow-box.js";
import { like } from "../component/like/like.js";
import { report } from "../component/report/report.js";
import { headTop } from "../component/head-top/head-top.js";
const ASSET_VERSION = window.__ASSET_VERSION__ || "20251126";
const withVer = (p) => `${p}?v=${ASSET_VERSION}`;
const { itemForum } = await import(withVer("../component/item-forum/item-forum.js"));
const { itemOffer } = await import(withVer("../component/item-offer/item-offer.js"));
const { itemSummary } = await import(withVer("../component/item-summary/item-summary.js"));
const { itemVote } = await import(withVer("../component/item-vote/item-vote.js"));
const { itemMj } = await import(withVer("../component/item-mj/item-mj.js"));
const { itemTenement } = await import(withVer("../component/item-tenement/item-tenement.js"));
const { latestList } = await import(withVer("../component/latest-list/latest-list.js"));
const { slideshowBox } = await import(withVer("../component/slideshow-box/slideshow-box.js"));
const { like } = await import(withVer("../component/like/like.js"));
const { report } = await import(withVer("../component/report/report.js"));
const { headTop } = await import(withVer("../component/head-top/head-top.js"));
const appSectionIndex = createApp({
setup() {
@@ -563,10 +565,11 @@ const appSectionIndex = createApp({
let emojiState = ref(false);
let emojiMaskState = ref(false);
let emojiBottomDistance = ref(0);
let inputTextarea = ref("");
// 打开 Emoji
const openEmoji = (index, i) => {
const openEmoji = (event, index, i) => {
if (!isLogin.value) {
goLogin();
return;
@@ -1177,7 +1180,7 @@ const appSectionIndex = createApp({
ajax(`/v2/api/forum/postTopicShare`, { token });
};
return { uniqidRef, share, reportToken, isReplyBoxShow, matterHeight, sidebarHeight, deleteItem, maxPicture, sidebarFixed, matterRef, sidebarRef, pitchInputState, ismyself, edit, searchInput, defaultSearchText, goSearch, goPersonalHomepage, QRcode, alsoCommentsData, copyLinkClick, reportState, tokentoken, essence, recommend, hide, report, cutShow, ismanager, show, openDiscuss, commentDelete, handleInputPaste, autoResize, editCommentState, selectEditEmoji, closeEditEmoji, openEditEmoji, closeEdit, openEdit, closeEditFileUpload, postEditComment, submitAnswerComments, closePictureUpload, closeFileUpload, picture, editToken, editPicture, editInput, editEmojiState, handleFileUpload, inputTextarea, judgeLogin, handleEditFile, selectEmoji, emojiData, emojiMaskState, emojiState, closeEmoji, openEmoji, closeAnswerCommentsChild, openAnswerCommentsChild, handleAnswerText, sendMessage, TAHomePage, operateAnswerCommentsLike, closeUserInfo, openUserInfo, permissions, commentList, commentPage, commentTotalCount, picture, userInfoWin, relatedList, relatedTime, coinNubmer, coinList, coinAmount, coinSubmit, strategy, mybalance, coinsState, openCoinBox, closeCoinBox, isLikeGif, likeClick, collectClick, islike, iscollect, recentlyList, medal, count, sectionn, tags, authorInfo, info, timestamp, updatedTime };
return { emojiBottomDistance, uniqidRef, share, reportToken, isReplyBoxShow, matterHeight, sidebarHeight, deleteItem, maxPicture, sidebarFixed, matterRef, sidebarRef, pitchInputState, ismyself, edit, searchInput, defaultSearchText, goSearch, goPersonalHomepage, QRcode, alsoCommentsData, copyLinkClick, reportState, tokentoken, essence, recommend, hide, report, cutShow, ismanager, show, openDiscuss, commentDelete, handleInputPaste, autoResize, editCommentState, selectEditEmoji, closeEditEmoji, openEditEmoji, closeEdit, openEdit, closeEditFileUpload, postEditComment, submitAnswerComments, closePictureUpload, closeFileUpload, picture, editToken, editPicture, editInput, editEmojiState, handleFileUpload, inputTextarea, judgeLogin, handleEditFile, selectEmoji, emojiData, emojiMaskState, emojiState, closeEmoji, openEmoji, closeAnswerCommentsChild, openAnswerCommentsChild, handleAnswerText, sendMessage, TAHomePage, operateAnswerCommentsLike, closeUserInfo, openUserInfo, permissions, commentList, commentPage, commentTotalCount, picture, userInfoWin, relatedList, relatedTime, coinNubmer, coinList, coinAmount, coinSubmit, strategy, mybalance, coinsState, openCoinBox, closeCoinBox, isLikeGif, likeClick, collectClick, islike, iscollect, recentlyList, medal, count, sectionn, tags, authorInfo, info, timestamp, updatedTime };
},
});

View File

@@ -148,11 +148,85 @@ const editApp = createApp({
};
let editor = null;
let toolbarRef = ref(null);
const initEditor = () => {
let infoTarget = info.value || {};
console.log("infoTarget", infoTarget);
// 转换图片链接
async function customCheckImageFn(src, alt, url) {
// JS 语法
if (!src) {
return;
}
// let config = uConfigData;
// // 1. 构造 FormData包含你的接口所需字段
// const formData = new FormData();
// formData.append(config.requestName, file); // 文件数据
// formData.append("name", file.name); // 文件名
// formData.append("type", "image"); // 文件名
// formData.append("data", config.params.data); // 文件名
await setTimeout(() => {
console.log("1111");
return true;
}, 2000);
// setTimeout(() => {
// return "图片网址必须以 http/https 开头";
// }, 2000);
if (src.indexOf("http") !== 0) {
// return "图片网址必须以 http/https 开头";
}
// return true;
// 返回值有三种选择:
// 1. 返回 true ,说明检查通过,编辑器将正常插入图片
// 2. 返回一个字符串,说明检查未通过,编辑器会阻止插入。会 alert 出错误信息(即返回的字符串)
// 3. 返回 undefined即没有任何返回说明检查未通过编辑器会阻止插入。但不会提示任何信息
}
// 【新增】判断节点的对齐方式
const getNodeAlign = (node) => {
if (!node) return "left"; // 默认居左
// 获取节点的text-align样式优先内联样式再取CSS计算样式
const inlineAlign = node.style.textAlign;
if (inlineAlign) return inlineAlign;
const computedStyle = window.getComputedStyle(node);
return computedStyle.textAlign || "left";
};
// 【新增】切换对齐方式(居中 ↔ 居左)
const toggleAlign = () => {
const editorInst = editor.value;
if (!editorInst) return;
// 禁用编辑器默认的居中命令
editorInst.off("clickToolbar", "justifyCenter");
// 获取当前选中的节点(优先段落/块级节点)
const selectedNode = getSelectedNode(editorInst);
const blockNode = DomEditor.getClosestBlock(selectedNode); // 获取块级节点p/div等
if (!blockNode) return;
// 判断当前对齐方式
const currentAlign = getNodeAlign(blockNode);
// 切换对齐:居中 → 居左;其他 → 居中
const newAlign = currentAlign === "center" ? "left" : "center";
// 设置节点对齐样式
editorInst.restoreSelection(); // 恢复选区
blockNode.style.textAlign = newAlign;
// 触发编辑器更新
editorInst.change();
editorInst.focus(); // 保持焦点
};
const editorConfig = {
placeholder: "Type here...",
@@ -162,6 +236,19 @@ const editApp = createApp({
emotions: optionEmoji.value,
},
["insertImage"]: {
onInsertedImage(imageNode) {
console.log("imageNode", imageNode);
// TS 语法
// onInsertedImage(imageNode) { // JS 语法
if (imageNode == null) return;
const { src, alt, url, href } = imageNode;
console.log("inserted image", src, alt, url, href);
},
// checkImage: async (src, alt, url) => await customCheckImageFn(src, alt, url), // 也支持 async 函数
},
["uploadImage"]: {
server: uConfigData.url,
@@ -200,9 +287,12 @@ const editApp = createApp({
formData.append("name", file.name); // 文件名
formData.append("type", "image"); // 文件名
formData.append("data", config.params.data); // 文件名
// uploading(file, file.name, "image").then((data) => {
// insertFn(data.url); // 传入图片的可访问 URL
// });
ajax(config.url, formData).then((res) => {
const data = res.data;
console.log("上传成功:", data);
insertFn(data.url); // 传入图片的可访问 URL
});
} catch (err) {
@@ -210,23 +300,76 @@ const editApp = createApp({
}
},
},
},
// 4. 链接菜单:显式启用(默认启用,补充配置防止被过滤)
link: {
disabled: false, // 确保不禁用
showTarget: true, // 显示「是否新窗口打开」选项
showRel: true, // 显示「rel 属性」选项
},
// 5. 对齐菜单:显式启用(默认启用,兜底配置)
justify: {
disabled: false,
["uploadVideo"]: {
server: uConfigData.url,
// form-data fieldName ,默认值 'wangeditor-uploaded-video'
fieldName: uConfigData.requestName,
// 单个文件的最大体积限制,默认为 10M
maxFileSize: maxSize, // 1M
// 最多可上传几个文件,默认为 5
maxNumberOfFiles: videoLength,
// 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
allowedFileTypes: ["video/*"],
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
meta: { ...uConfigData.params },
// 将 meta 拼接到 url 参数中,默认 false
metaWithUrl: false,
// 自定义增加 http header
headers: { accept: "application/json, text/plain, */*", ...uConfigData.headers },
// 跨域是否传递 cookie ,默认为 false
withCredentials: true,
// 超时时间,默认为 30 秒
timeout: 15 * 1000, // 15 秒
// 视频不支持 base64 格式插入
async customUpload(file, insertFn) {
try {
const videoUploadRes = await uploading(file, file.name, "video");
const coverFile = await getVideoFirstFrame(file);
console.log("第一帧提取成功", coverFile);
// 步骤3再上传第一帧封面type 传 'cover',按后端要求调整)
const coverUploadRes = await uploading(coverFile, coverFile.name, "image");
console.log("封面上传成功", coverUploadRes);
insertFn(videoUploadRes.url, coverUploadRes.url);
} catch (err) {
console.error("上传出错:", err);
}
},
},
["justifyCenter"]: {
onClick: (editor) => {
console.log("editor", editor);
toggleAlign(); // 替换为自定义切换逻辑
},
// 【可选】自定义居中按钮的激活状态(选中时高亮)
isActive: (editor) => {
const selectedNode = getSelectedNode(editor);
const blockNode = DomEditor.getClosestBlock(selectedNode);
return blockNode && getNodeAlign(blockNode) === "center";
},
},
},
onChange(editor) {
const html = editor.getHtml();
// console.log('"editor', editor);
console.log("editor content", html);
// 也可以同步到 <textarea>
updateWHeadingStatus();
},
};
@@ -237,32 +380,31 @@ const editApp = createApp({
mode: "default",
});
setTimeout(() => {
console.log("editor", editor);
editor.addMark("bold", true); // 加粗
}, 1000);
// const toolbar = DomEditor.getToolbar(editor)
const toolbarConfig = {
// toolbarKeys: ["bold", "italic", "list"],
toolbarKeys: [
"headerSelect", // 标题
"bold", // 粗体
"italic", // 斜体
// "justify", // 对齐方式
"header2", // 标题
{
key: "justifyCenter",
title: "对齐",
iconSvg: '<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>',
menuKeys: ["justifyLeft", "justifyRight", "justifyCenter", "justifyJustify"],
key: "group-image",
title: "图片",
menuKeys: ["insertImage", "uploadImage"],
},
{
key: "group-video",
title: "视频",
menuKeys: ["insertVideo", "uploadVideo"],
},
// "insertVideo",
"emotion", // 表情
// "uploadImage", // 插入图片
// "uploadVideo", // 插入视频
"insertLink", // 插入链接
"uploadImage", // 插入图片
"uploadVideo", // 插入视频
"undo", // 撤销
"redo", // 重做
"fullScreen", // 全屏
"bold", // 粗体
"justifyCenter",
],
};
@@ -272,15 +414,44 @@ const editApp = createApp({
config: toolbarConfig,
mode: "default",
});
console.log("editor.commands", editor);
console.log("toolbar", toolbar);
nextTick(() => {
const h2 = toolbarRef.value.querySelector('[data-menu-key="header2"]');
const h2Item = h2.parentElement;
h2Item.classList.add("toolbar-item", "flexacenter");
h2.innerHTML = '<img class="icon" src="{@/img/t-icon.png}" alt="段落标题" /> <span>段落标题</span>';
// setTimeout(() => {
// const el = document.querySelector("#toolbar-container");
// if (el && el.children.length === 0) {
// createToolbar({ editor, selector: "#toolbar-container", config: toolbarConfig, mode: "default" });
// }
// }, 0);
const image = toolbarRef.value.querySelector('[data-menu-key="group-image"]');
const imageItem = image.parentElement;
imageItem.classList.add("toolbar-item", "flexacenter");
image.innerHTML = '<img class="icon" src="{@/img/img-icon.png}" alt="图片" /> <span>图片</span>';
const video = toolbarRef.value.querySelector('[data-menu-key="group-video"]');
const videoItem = video.parentElement;
videoItem.classList.add("toolbar-item", "flexacenter");
video.innerHTML = '<img class="icon" src="{@/img/video-icon.png}" alt="视频" /> <span>视频</span>';
const emotion = toolbarRef.value.querySelector('[data-menu-key="emotion"]');
const emotionItem = emotion.parentElement;
emotionItem.classList.add("toolbar-item", "flexacenter");
emotion.innerHTML = '<img class="icon" src="{@/img/emotion-icon.png}" alt="表情" /> <span>表情</span>';
const link = toolbarRef.value.querySelector('[data-menu-key="insertLink"]');
const linkItem = link.parentElement;
linkItem.classList.add("toolbar-item", "flexacenter");
link.innerHTML = '<img class="icon" src="{@/img/link-icon.png}" alt="链接" /> <span>链接</span>';
const bold = toolbarRef.value.querySelector('[data-menu-key="bold"]');
const boldItem = bold.parentElement;
boldItem.classList.add("toolbar-item", "flexacenter");
bold.innerHTML = '<img class="icon" src="{@/img/bold-icon.png}" alt="粗体" /> <span>粗体</span>';
const justifyCenter = toolbarRef.value.querySelector('[data-menu-key="justifyCenter"]');
const justifyCenterItem = justifyCenter.parentElement;
justifyCenterItem.classList.add("toolbar-item", "flexacenter");
justifyCenter.innerHTML = '<img class="icon" src="{@/img/justify-center-icon.png}" alt="居中" /> <span>居中</span>';
});
};
const restoreHtml = (formattedText, attachments) => {
@@ -370,6 +541,35 @@ const editApp = createApp({
};
let lastSelection = null;
let lastSelectionW = null;
const isH1 = ref(false);
const updateWHeadingStatus = () => {
const wRoot = document.querySelector("#editor-container");
let node = null;
try {
const DomEditor = window.wangEditor && window.wangEditor.DomEditor;
if (DomEditor && editor) node = DomEditor.getSelectionNode(editor);
} catch (e) {}
if (!node) {
const sel = window.getSelection();
if (sel && sel.rangeCount) node = sel.getRangeAt(0).commonAncestorContainer;
}
let el = node && node.nodeType === 3 ? node.parentElement : node;
while (el && el !== wRoot && el && el.nodeType === 1) {
if (window.getComputedStyle(el).getPropertyValue("text-align") === "center") {
console.log("居中");
const justifyCenter = toolbarRef.value.querySelector('[data-menu-key="justifyCenter"]');
if (justifyCenter) {
const justifyCenterItem = justifyCenter.parentElement;
justifyCenterItem.classList.add("active");
}
break;
}
el = el.parentElement;
}
};
let loading = ref(false);
@@ -553,36 +753,41 @@ const editApp = createApp({
const handleSelectionChange = () => {
return;
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 wRoot = document.querySelector("#editor-container");
if (wRoot && wRoot.contains(commonAncestor)) {
lastSelectionW = range;
updateWHeadingStatus();
}
}
};
const isPTitle = ref(false);
let isPTitle = ref(false);
const paragraphTitle = () => {
editorRef.value.focus();
if (!lastSelection) return;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(lastSelection);
console.log("editor", editor.addMark);
// editor.addMark("bold", true); // 加粗
// 使用try-catch确保即使命令执行失败也能恢复滚动位置
try {
document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2");
} catch (error) {
console.error("应用段落格式失败:", error);
}
// editorRef.value.focus();
// if (!lastSelection) return;
// const selection = window.getSelection();
// selection.removeAllRanges();
// selection.addRange(lastSelection);
// 更新状态
setTimeout(() => updatePTitleStatus(), 100);
// // 使用try-catch确保即使命令执行失败也能恢复滚动位置
// try {
// document.execCommand("formatBlock", false, isPTitle.value ? "P" : "H2");
// } catch (error) {
// console.error("应用段落格式失败:", error);
// }
// // 更新状态
// setTimeout(() => updatePTitleStatus(), 100);
};
const updatePTitleStatus = () => {
@@ -656,6 +861,8 @@ const editApp = createApp({
content = formatContent(content);
console.log(content);
return;
const data = {
...infoTarget,
content,
@@ -893,11 +1100,11 @@ const editApp = createApp({
console.log("加粗");
editor.addMark("bold", true); // 加粗
editor.addMark("color", "#999"); // 文本颜色
// editor.addMark("color", "#999"); // 文本颜色
console.log("editor", editor.addMark);
};
return { overstriking, 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 };
return { toolbarRef, overstriking, isH1, 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 };
},
});

View File

@@ -2,6 +2,8 @@ const forumBaseURL = "https://api.gter.net";
axios.defaults.withCredentials = true;
axios.defaults.emulateJSON = true;
const withVer = (p) => `${p}?v=${window.__ASSET_VERSION__}`;
// 导出ajax函数
const ajax = (url, data) => {
axios.defaults.withCredentials = true;
@@ -402,3 +404,17 @@ 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();
// };