feat(editor): 新增视频上传功能并优化编辑器体验

- 添加视频上传功能,支持提取视频第一帧作为封面
- 优化图片和视频上传的数量限制检查
- 修复编辑器内容为空判断逻辑,增加视频元素检测
- 改进链接插入功能,自动填充选中文本
- 调整表情选择插入方式,使用execCommand实现
- 优化附件提取逻辑,支持视频元素解析
- 添加编辑器点击事件处理,更新选区状态
- 修复样式问题,调整按钮悬停效果
This commit is contained in:
DESKTOP-RQ919RC\Pc
2025-11-18 19:20:31 +08:00
parent 16f45c4466
commit f49d937f19
12 changed files with 873 additions and 344 deletions

View File

@@ -6,12 +6,18 @@ 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);
});
@@ -21,19 +27,46 @@ const editApp = createApp({
document.removeEventListener("selectionchange", handleSelectionChange);
});
let isLogin = ref(true);
let realname = ref(1); // 是否已经实名
let userInfoWin = ref({
authority: ["comment.edit", "comment.delete", "offercollege.hide", "offersummary.hide", "mj.hide", "topic:manager", "topic:hide"],
avatar: "https://nas.gter.net:9008/avatar/97K4EWIMLrsbGTWXslC2WFVSEKWOikN42jDKLNjtax7HL4xtfMOJSdU9oWFhY2E~/middle?random=1761733169",
groupid: 3,
nickname: "肖荣豪",
realname: 1,
token: "01346a38444d71aaadb3adad52b52c39",
uid: 500144,
uin: 4238049,
});
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 = () => {
@@ -82,22 +115,21 @@ const editApp = createApp({
let token = ref("");
let infoImages = [];
const init = () => {
ajax("/v2/api/forum/postPublishInit")
ajax("/v2/api/forum/postPublishInit", {
uniqid: uniqid.value,
})
.then((res) => {
const data = res.data;
if (res.code != 200) {
creationAlertBox(res.message || "操作失败");
creationAlertBox("error", res.message || "操作失败");
return;
}
const infoTarget = data.info || {};
infoImages = infoTarget.attachments?.images || [];
if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoImages);
if (infoTarget.content) infoTarget.content = restoreHtml(infoTarget.content, infoTarget.attachments);
info.value = infoTarget;
tagList.value = data.tagList;
token.value = data.token;
nextTick(() => {
@@ -109,7 +141,12 @@ const editApp = createApp({
});
};
const restoreHtml = (formattedText, imageList) => {
const restoreHtml = (formattedText, attachments) => {
const imageList = attachments?.images || [];
const filesList = attachments?.files || [];
const videosList = attachments?.videos || [];
let html = formattedText;
// 1. 还原换行符为<br>标签
@@ -130,22 +167,52 @@ const editApp = createApp({
// 查找对应的图片信息
const image = imageList.find((img) => img.aid == aid);
if (image) {
return `<img src="${image.url}" data-aid="${aid}">`;
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);
@@ -166,51 +233,89 @@ const editApp = createApp({
const maxSize = 20 * 1024 * 1024; // 20MB
const insertImage = (event) => {
let config = uConfigData;
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("文件大小不能超过 20MB");
creationAlertBox("error", "文件大小不能超过 20MB");
return;
}
loading.value = true;
// 不要删除,后面会用
const formData = new FormData();
formData.append(config.requestName, target); // 文件数据
formData.append("name", target.name); // 文件名
formData.append("type", "image"); // 文件名
formData.append("data", config.params.data); // 文件名
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();
});
};
ajax(config.url, formData)
.then((res) => {
const data = res.data;
try {
const range = lastSelection;
const img = document.createElement("img");
const insertVideo = async (event) => {
const videos = extractVideos(editorRef.value);
img.src = data.url;
img.setAttribute("data-aid", data.aid);
range.insertNode(img);
const div = document.createElement("div");
range.insertNode(div);
judgeIsEmpty();
} catch (error) {
console.error("插入图片出错:", error);
}
})
.finally(() => {
loading.value = false;
});
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) => {
console.log("onEditorInput");
const selection = window.getSelection();
if (selection.rangeCount > 0) {
@@ -221,7 +326,7 @@ const editApp = createApp({
judgeIsEmpty();
debouncedGetTagList();
// debouncedGetTagList();
};
// 防抖函数
@@ -260,26 +365,28 @@ const editApp = createApp({
};
const getTagList = () => {
if (!isLogin.value) {
goLogin();
return;
}
const content = editorRef.value.innerText;
axios
.post("https://api.gter.net/v2/api/forum/postPublishTags", {
content,
})
.then((res) => {
res = res.data;
if (res.code != 200) return;
let data = res.data || [];
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(),
});
}
// 随机生成一下数据
for (let i = 0; i < 5; i++) {
data.push({
title: getRandomChinese() + getRandomChinese(),
tagId: generateRandomString(),
});
}
tagList.value = data;
});
tagList.value = data;
});
};
const debouncedGetTagList = debounce(getTagList, 500);
@@ -296,7 +403,7 @@ const editApp = createApp({
// 判断是否为空
const judgeIsEmpty = () => {
const text = editorRef.value.innerText;
isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img");
isEmpty.value = text.length == 0 && !editorRef.value.querySelector("img") && !editorRef.value.querySelector("video");
};
// 处理选中文本变化的函数
@@ -309,7 +416,6 @@ const editApp = createApp({
const commonAncestor = range.commonAncestorContainer;
if (editorRef.value.contains(commonAncestor)) {
console.log("选中区域在编辑器内", range);
lastSelection = range;
}
}
@@ -366,36 +472,6 @@ const editApp = createApp({
const cutAnonymity = () => (info.value.anonymous = info.value.anonymous ? 0 : 1);
const insertLabel = (id) => {
const index = tagList.value.findIndex((item) => item.tagId == id);
if (index == -1) return;
const label = tagList.value[index].title;
const span = document.createElement("span");
span.innerHTML = `<span class="blue">#${label}</span> <span class="fill"></span> `;
lastSelection.insertNode(span);
// 移动光标到元素后面并确保光标位置被正确设置和获取
const newRange = document.createRange();
newRange.setStartAfter(span);
newRange.setEndAfter(span);
// 更新选择范围
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
lastSelection = newRange;
// 手动触发selectionchange事件确保其他组件知道光标位置变化
const selectionChangeEvent = new Event("selectionchange", { bubbles: true });
document.dispatchEvent(selectionChangeEvent);
judgeIsEmpty();
// 删除 tagList 中当前标签
tagList.value.splice(index, 1);
};
let emojiState = ref(false);
const optionEmoji = ref(["😀", "😁", "😆", "😅", "😂", "😉", "😍", "🥰", "😘", "🤥", "😪", "😵‍💫", "🤓", "🥺", "😋", "😜", "🤪", "😎", "🤩", "🥳", "😔", "🙁", "😭", "😡", "😳", "🤗", "🤔", "🤭", "🤫", "😯", "😵", "🙄", "🥴", "🤢", "🤑", "🤠", "👌", "✌️", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👏", "🤝", "🙏", "💪", "❎️", "✳️", "✴️", "❇️", "#️⃣", "*️⃣", "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟", "🆗", "🈶", "🉐", "🉑", "🌹", "🥀", "🌸", "🌺", "🌷", "🌲", "☘️", "🍀", "🍁", "🌙", "⭐", "🌍", "☀️", "⭐️", "🌟", "☁️", "🌈", "☂️", "❄️", "☃️", "☄️", "🔥", "💧", "🍎", "🍐", "🍊", "🍉", "🍓", "🍑", "🍔", "🍟", "🍕", "🥪", "🍜", "🍡", "🍨", "🍦", "🎂", "🍰", "🍭", "🍿", "🍩", "🧃", "🍹", "🍒", "🥝", "🥒", "🥦", "🥨", "🌭", "🥘", "🍱", "🍢", "🥮", "🍩", "🍪", "🧁", "🍵", "🍶", "🍻", "🥂", "🧋", "🎉", "🎁", "🧧", "🎃", "🎄", "🧨", "✨️", "🎈", "🎊", "🎋", "🎍", "🎀", "🎖️", "🏆️", "🏅", "💌", "📬", "🚗", "🚕", "🚲", "🛵", "🚀", "🚁", "⛵", "🚢", "🔮", "🧸", "🀄️"]);
@@ -405,22 +481,13 @@ const editApp = createApp({
const closeEmoji = () => (emojiState.value = false);
const selectEmoji = (emoji) => {
const textNode = document.createTextNode(emoji);
lastSelection.insertNode(textNode);
// 移动光标到emoji后面并确保光标位置被正确设置和获取
const newRange = document.createRange();
newRange.setStartAfter(textNode);
newRange.setEndAfter(textNode);
// 更新选择范围
const selection = window.getSelection();
selection.removeAllRanges();
lastSelection = newRange;
// 手动触发selectionchange事件确保其他组件知道光标位置变化
const selectionChangeEvent = new Event("selectionchange", { bubbles: true });
document.dispatchEvent(selectionChangeEvent);
editorRef.value.focus();
if (lastSelection) {
selection.removeAllRanges();
selection.addRange(lastSelection);
}
document.execCommand("insertText", false, emoji);
closeEmoji();
judgeIsEmpty();
};
@@ -430,14 +497,21 @@ const editApp = createApp({
const infoTarget = { ...info.value } || {};
let content = editorRef.value.innerHTML;
const images = extractImages(content);
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;
console.log("转换前:", content);
info.value["attachments"]["videos"] = videos;
console.log(content);
content = formatContent(content);
console.log("转换后:", content);
console.log(content);
const data = {
...infoTarget,
content,
@@ -456,8 +530,8 @@ const editApp = createApp({
creationAlertBox("success", res.message || "操作成功");
const back = () => {
if (status == 1) redirectToExternalWebsite("./details.html?uniqid=" + data.uniqid);
else redirectToExternalWebsite("./index.html");
if (status == 1) redirectToExternalWebsite("/details/" + data.uniqid, "_self");
else redirectToExternalWebsite("/", "_self");
};
setTimeout(() => back(), 1500);
@@ -468,15 +542,12 @@ const editApp = createApp({
// 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]");
// 3. 替换标签(保留与前后内容的连续性)
html = html.replace(/<span\s+class="blue">#([^<]+)<\/span>/gi, "[tag]$1[/tag]");
// 4. 移除无关标签(如空的<span class="fill"></span>
html = html.replace(/<span\s+class="fill">[^<]*<\/span>/gi, "");
// 5. 处理块级标签换行(仅<div>等块级标签前后换行,保持行内内容连续)
// 块级标签div、p、h1-h6等这里以div为例
html = html.replace(/<\/div>\s*/gi, "</div>\n"); // 闭合div后换行
@@ -485,8 +556,8 @@ const editApp = createApp({
// 6. 处理<br>为换行
html = html.replace(/<br\s*\/?>/gi, "\n");
// 7. 移除所有剩余HTML标签
html = html.replace(/<[^>]+>/gi, "");
// 7. 移除所有剩余HTML标签 a标签除外
html = html.replace(/<(?!(a\b|\/a\b))[^>]+>/gi, "");
// 8. 清理连续换行(最多保留两个空行,避免过多空行)
html = html.replace(/\n{3,}/g, "\n\n");
@@ -496,29 +567,67 @@ const editApp = createApp({
return html;
};
const extractImages = (html) => {
const extractImages = (dom) => {
const images = [];
// 正则匹配 img 标签,提取 srcurl和 data-aid
const imgRegex = /<img[^>]*src="([^"]+)"[^>]*data-aid="(\d+)"[^>]*>/gi;
let match;
// 循环匹配所有图片标签
while ((match = imgRegex.exec(html)) !== null) {
// 直接查找页面中所有带 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: match[1], // 图片的 src 地址
aid: Number(match[2]), // 图片的 data-aid 属性值
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;
};
@@ -534,31 +643,98 @@ const editApp = createApp({
creationAlertBox("error", "请输入链接文字和链接地址");
return;
}
const a = document.createElement("a");
a.href = linkUrl.value;
a.target = "_blank";
a.textContent = linkText.value;
console.log("insertLink", lastSelection);
// 先删除选中的内容,再插入链接
lastSelection.deleteContents();
lastSelection.insertNode(a);
// 移动光标到元素后面并确保光标位置被正确设置和获取
const newRange = document.createRange();
newRange.setStartAfter(a);
newRange.setEndAfter(a);
// 更新选择范围
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
lastSelection = newRange;
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();
};
return { insertLink, linkUrl, linkText, linkState, openLink, closeLink, userInfoWin, titleLength, submit, insertLabel, emojiState, openEmoji, closeEmoji, selectEmoji, optionEmoji, isPTitle, onEditorInput, onEditorFocus, onEditorBlur, paragraphTitle, info, tagList, token, cutAnonymity, editorRef, insertImage, judgeIsEmpty, isEmpty };
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;
});
});
};
// 封装:提取视频第一帧(返回 Promiseresolve 第一帧 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 };
},
});