Files
orico-officialWebsite-ts-admin/src/components/Editor/index.vue
2025-09-18 11:58:42 +08:00

942 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 图片上传组件 -->
<el-upload
:id="uuid"
action="#"
:multiple="true"
:show-file-list="false"
:http-request="handleHttpUpload"
:before-upload="handleBeforeUpload"
class="editor-img-uploader"
accept=".jpeg,.jpg,.png,.gif"
>
<i ref="uploadRef" class="Plus editor-img-uploader"></i>
</el-upload>
<!-- 视频上传组件 -->
<input
type="file"
accept="video/*"
name="file"
ref="uploadFileVideo"
id="uploadFileVideo"
@change="handleVideoUpload"
style="width: 0; height: 0; cursor: pointer; opacity: 0"
/>
<!-- 主富文本编辑器 -->
<div class="editor">
<QuillEditor
id="mainEditor"
ref="myQuillEditor"
v-model:content="editorContent"
contentType="html"
@update:content="onContentChange"
:options="options"
/>
</div>
<!-- 标签页配置弹窗 -->
<div>
<el-dialog
v-model="outerVisible"
title="标签页配置"
style="width: 1200px; height: 900px"
close-on-click-modal
close-on-press-escape
:before-close="handleBeforeClose"
>
<el-tabs
v-model="activeName"
type="card"
class="demo-tabs"
editable
@edit="handleTabsEdit"
@tab-change="handleTabChange"
>
<el-tab-pane
:label="item.title"
:name="item.key"
v-for="(item, index) in tabsData"
:key="item.key"
@keydown.delete.stop
@keydown.backspace.stop
>
<template #label>
<div class="tab-title-edit">
<span v-if="!item.isEditing" @click="startEditTitle(index)" class="title-text">
{{ item.title }}
</span>
<el-input
@click.stop
@keydown.delete.stop
@keydown.backspace.stop
v-else
v-model="item.title"
max-length=""
:ref="el => (editInputRefs[index] = el)"
size="small"
class="title-input"
@blur="finishEditTitle(index)"
/>
</div>
</template>
<QuillEditor
:id="`tabEditor_${item.key}`"
:ref="
el => {
if (el) tabEditors[index] = el;
}
"
v-model:content="item.content"
contentType="html"
:options="options1"
/>
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleQX">取消</el-button>
<el-button type="primary" @click="handleQR"> 确认 </el-button>
</div>
</template>
</el-dialog>
</div>
<!-- 图片顺序调整弹窗 -->
<el-dialog v-model="showImageSortDialog" title="调整图片顺序" width="800px" :before-close="handleImageDialogClose">
<div class="image-sort-container">
<draggable
v-model="sortedImageList"
:animation="200"
class="image-grid"
ghost-class="ghost"
item-key="customUid"
@update:modelValue="updateSortOrder"
>
<template #item="{ element: img }">
<div class="image-item">
<div class="image-preview-container">
<div class="image-order-badge">{{ img.sortOrder + 1 }}</div>
<img :src="img.tempUrl" :alt="`图片 ${img.sortOrder + 1}`" class="preview-img" />
<div style="display: flex; justify-content: flex-end; padding: 6px">
<el-button size="small" type="default" @click.stop="removeImage(img.customUid)">删除</el-button>
</div>
</div>
<div class="image-info">
<span class="image-name">{{ img.name }}</span>
</div>
</div>
</template>
</draggable>
<div v-if="sortedImageList.length === 0" class="empty-state">暂无图片请先上传</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showImageSortDialog = false">取消</el-button>
<el-button type="primary" @click="confirmInsertImages" :disabled="sortedImageList.length === 0">
确认插入
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup name="Editor">
// import { Delete } from "@element-plus/icons-vue";
import { QuillEditor, Quill } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
import { getCurrentInstance, reactive, ref, toRaw, computed, onMounted, nextTick } from "vue";
import { generateUUID } from "@/utils";
import { routerObj } from "./utils.js";
import { titleConfig } from "./titleConfig.js";
import { uploadVideo, uploadImg } from "@/api/modules/upload";
import { ElNotification } from "element-plus";
import { useRouter } from "vue-router";
import { useMsg } from "@/hooks/useMsg";
import draggable from "vuedraggable";
// 字体配置
let fontSizeStyle = Quill.import("attributors/style/size");
fontSizeStyle.whitelist = [
"12px",
"14px",
"16px",
"18px",
"20px",
"22px",
"24px",
"26px",
"28px",
"30px",
"32px",
"34px",
"36px",
"38px",
"40px",
"42px"
];
Quill.register(fontSizeStyle, true);
// 自定义Blot
import ImageBlot from "./quill-image";
import Video from "./quill-video";
import TabsBlot from "./quill-tabs";
import DynamicDivBlot from "./quill-detail-div";
Quill.register(Video);
Quill.register(ImageBlot);
Quill.register(TabsBlot);
Quill.register(DynamicDivBlot);
// 基础变量
const { proxy } = getCurrentInstance();
const emit = defineEmits(["update:content", "handleRichTextContentChange"]);
const uuid = ref("id-" + generateUUID());
const $router = useRouter();
const routerValueName = $router.currentRoute.value.name;
const routerName = ref(routerObj[routerValueName]);
const uploadFileVideo = ref(null);
const outerVisible = ref(false);
const imageList = ref([]);
const imageListDb = ref([]);
const activeName = ref(null);
const activeEditor = ref("main");
// 标签页数据
const tabsData = ref([]);
const tabEditors = ref([]);
const editInputRefs = ref([]);
const currentEditingTabsRef = ref(null);
// 图片排序相关变量
const showImageSortDialog = ref(false);
const sortedImageList = ref([]);
const uploadingCount = ref(0);
// Props
const props = defineProps({
content: { type: String, default: "" },
readOnly: { type: Boolean, default: false },
fileSizeLimit: { type: Number, default: 5 }
});
// 主编辑器内容双向绑定
const editorContent = computed({
get: () => {
if (!props.content) return "";
return props.content;
},
set: val => {
emit("update:content", val);
}
});
const myQuillEditor = ref(null);
// 主编辑器配置
const options = reactive({
theme: "snow",
debug: "warn",
strict: false,
modules: {
toolbar: {
container: [
["bold", "italic", "underline", "strike"],
["blockquote", "code-block"],
[{ list: "ordered" }, { list: "bullet" }],
[{ indent: "-1" }, { indent: "+1" }],
[{ size: fontSizeStyle.whitelist }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
["clean"],
["link", "image", "video", "tabs"]
],
handlers: {
image: function (value) {
if (value) {
activeEditor.value = "main";
sortedImageList.value = [];
proxy.$refs.uploadRef.click();
} else Quill.format("customImage", true);
},
video: function (value) {
if (value) {
activeEditor.value = "main";
document.querySelector("#uploadFileVideo")?.click();
} else Quill.format("customVideo", true);
},
tabs: function (value) {
outerVisible.value = value;
}
}
}
},
placeholder: "请输入内容...",
readOnly: props.readOnly
});
// 标签页编辑器配置
const options1 = reactive({
theme: "snow",
debug: "warn",
strict: false,
modules: {
toolbar: {
container: [
["bold", "italic", "underline", "strike"],
["blockquote", "code-block"],
[{ list: "ordered" }, { list: "bullet" }],
[{ indent: "-1" }, { indent: "+1" }],
[{ size: fontSizeStyle.whitelist }],
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
["clean"],
["link", "image", "video"]
],
handlers: {
image: function (value) {
if (value) {
const currentIndex = tabsData.value.findIndex(item => item.key === activeName.value);
activeEditor.value = `tab-${currentIndex}`;
sortedImageList.value = [];
proxy.$refs.uploadRef.click();
} else Quill.format("customImage", true);
},
video: function (value) {
if (value) {
const currentIndex = tabsData.value.findIndex(item => item.key === activeName.value);
activeEditor.value = `tab-${currentIndex}`;
document.querySelector("#uploadFileVideo")?.click();
} else Quill.format("customVideo", true);
}
}
}
},
placeholder: "请输入内容...",
readOnly: props.readOnly
});
// 上传前校验
const handleBeforeUpload = file => {
const fileType = file.type;
file.customUid = generateUUID();
const validTypes = ["image/jpeg", "image/png", "image/gif", "image/jpg", "image/bmp", "image/webp"];
if (!validTypes.includes(fileType)) {
ElNotification({ title: "格式错误", message: "仅支持图片格式", type: "warning" });
return false;
}
const isLt = file.size / 1024 / 1024 < props.fileSizeLimit;
if (!isLt) {
ElNotification({ title: "大小超限", message: `不能超过 ${props.fileSizeLimit} MB`, type: "warning" });
return false;
}
// 生成临时URL用于预览
const tempUrl = URL.createObjectURL(file);
imageListDb.value.push({
...file,
tempUrl,
sortOrder: imageListDb.value.length, // 初始化排序索引
serverImgId: "",
path: ""
});
uploadingCount.value++;
return true;
};
// 图片上传处理
const handleHttpUpload = async options => {
let formData = new FormData();
formData.append("image", options.file);
try {
const result = await uploadImg(formData, routerName.value, options.file.customUid);
if (result?.data?.code === 0) {
const { data } = result.data;
const { imgId } = result;
const fileItem = imageListDb.value.find(item => item.customUid === options.file.customUid);
if (fileItem) {
fileItem.serverImgId = imgId;
fileItem.path = data.path;
}
uploadingCount.value--;
// 所有图片上传完成后显示排序弹窗
if (uploadingCount.value === 0) {
sortedImageList.value = [...imageListDb.value];
updateSortOrder(); // 确保排序索引正确
nextTick(() => {
showImageSortDialog.value = true;
});
}
}
} catch (error) {
console.error("图片上传失败:", error);
const failUid = options.file.customUid;
imageListDb.value = imageListDb.value.filter(item => item.customUid !== failUid);
uploadingCount.value = Math.max(0, uploadingCount.value - 1);
ElNotification({
title: "上传失败",
message: `图片 ${options.file.name} 上传失败`,
type: "error"
});
}
};
// 更新排序索引
const updateSortOrder = () => {
sortedImageList.value.forEach((item, index) => {
item.sortOrder = index;
});
};
// 从排序列表移除图片
const removeImage = customUid => {
// 释放临时URL
const removedImg = sortedImageList.value.find(img => img.customUid === customUid);
if (removedImg?.tempUrl) {
URL.revokeObjectURL(removedImg.tempUrl);
}
// 从列表中移除
sortedImageList.value = sortedImageList.value.filter(img => img.customUid !== customUid);
// 重新计算排序索引
updateSortOrder();
};
// 确认插入图片到编辑器
const confirmInsertImages = () => {
let quill;
if (activeEditor.value === "main") {
const rawQuillEditor = toRaw(myQuillEditor.value);
quill = rawQuillEditor.getQuill();
} else {
const tabIndex = parseInt(activeEditor.value.split("-")[1]);
const rawQuillEditor = toRaw(tabEditors.value[tabIndex]);
quill = rawQuillEditor.getQuill();
}
if (!quill) {
ElNotification({ title: "错误", message: "编辑器未加载完成", type: "error" });
return;
}
// 获取光标位置
const range = quill.getSelection() || { index: 0 };
let currentInsertIndex = range.index;
// 按排序索引升序插入
const sortedByOrder = [...sortedImageList.value].sort((a, b) => a.sortOrder - b.sortOrder);
//
sortedByOrder.forEach(item => {
quill.insertEmbed(currentInsertIndex, "customImage", {
url: item.path,
id: item.serverImgId || generateUUID()
});
currentInsertIndex++;
});
// 调整光标位置
quill.setSelection(currentInsertIndex);
// 清理资源
sortedImageList.value.forEach(img => {
if (img.tempUrl) {
URL.revokeObjectURL(img.tempUrl);
}
});
// 关闭弹窗并重置
showImageSortDialog.value = false;
imageList.value = [];
imageListDb.value = [];
sortedImageList.value = [];
};
// 关闭图片排序弹窗时清理资源
const handleImageDialogClose = () => {
// 释放所有临时URL
sortedImageList.value.forEach(img => {
if (img.tempUrl) {
URL.revokeObjectURL(img.tempUrl);
}
});
// 重置状态
showImageSortDialog.value = false;
imageList.value = [];
imageListDb.value = [];
sortedImageList.value = [];
};
// 视频上传
const handleVideoUpload = async evt => {
if (evt.target.files.length === 0) return;
const file = evt.target.files[0];
// 校验视频文件
const maxSize = 150 * 1024 * 1024;
if (file.size > maxSize) {
ElNotification({
title: "文件过大",
message: `视频大小不能超过 ${150}MB`,
type: "warning"
});
evt.target.value = "";
return;
}
// 生成视频本地URL
const localVideoUrl = URL.createObjectURL(file);
try {
// 上传视频
const videoFormData = new FormData();
videoFormData.append("video", file);
const videoRes = await uploadVideo(videoFormData);
if (videoRes?.code !== 0) {
throw new Error(`视频上传失败: ${videoRes?.message || "未知错误"}`);
}
const videoUrl = videoRes.data.path;
// 生成封面图并上传
const frameBlob = await Video.captureVideoFrame(localVideoUrl);
let coverUrl = "";
if (frameBlob) {
const coverFormData = new FormData();
const coverUid = generateUUID();
coverFormData.append("image", frameBlob, `cover-${coverUid}.jpg`);
const coverRes = await uploadImg(coverFormData, routerName.value, coverUid);
if (coverRes?.data?.code === 0) {
coverUrl = coverRes.data.data.path;
}
}
// 插入视频到编辑器
insertVideoToEditor(videoUrl, coverUrl);
} catch (error) {
console.log(error);
} finally {
URL.revokeObjectURL(localVideoUrl);
evt.target.value = "";
}
};
// 插入视频到编辑器
const insertVideoToEditor = (videoUrl, coverUrl) => {
let quill;
if (activeEditor.value === "main") {
quill = toRaw(myQuillEditor.value)?.getQuill();
} else {
const tabIndex = parseInt(activeEditor.value.split("-")[1]);
quill = toRaw(tabEditors.value[tabIndex])?.getQuill();
}
if (quill) {
const range = quill.getSelection() || { index: 0 };
quill.insertEmbed(range.index, "customVideo", {
url: videoUrl,
poster: coverUrl
});
quill.setSelection(range.index + 1);
}
};
// 标签页切换事件
const handleTabChange = key => {
const tabIndex = tabsData.value.findIndex(item => item.key === key);
activeName.value = key;
activeEditor.value = `tab-${tabIndex}`;
};
// 标签页增删事件
const handleTabsEdit = (targetKey, action) => {
if (action === "add") {
if (tabsData.value.length > 5) {
return useMsg("error", "标签页已达上限 !");
}
const newKey = `tab_${generateUUID()}`;
const newIndex = tabsData.value.length;
tabsData.value.push({
key: newKey,
title: `标签${newIndex + 1}`,
content: "",
isEditing: false
});
nextTick(() => {
activeName.value = newKey;
activeEditor.value = `tab-${newIndex}`;
setTimeout(() => {
startEditTitle(newIndex);
}, 100);
});
} else if (action === "remove") {
const index = tabsData.value.findIndex(item => item.key === targetKey);
tabsData.value.splice(index, 1);
tabEditors.value.splice(index, 1);
editInputRefs.value.splice(index, 1);
if (activeEditor.value.startsWith("tab-")) {
const currentTabIndex = parseInt(activeEditor.value.split("-")[1]);
if (currentTabIndex > index) {
activeEditor.value = `tab-${currentTabIndex - 1}`;
} else if (currentTabIndex === index) {
activeEditor.value = tabsData.value.length > 0 ? "tab-0" : "main";
activeName.value = tabsData.value.length > 0 ? tabsData.value[0].key : null;
}
}
}
};
// 开始编辑标签页标题
const startEditTitle = index => {
const tab = tabsData.value[index];
if (!tab) return;
tab.originalTitle = tab.title;
tab.isEditing = true;
nextTick(() => {
editInputRefs.value[index]?.focus();
});
};
// 完成编辑标签页标题
const finishEditTitle = index => {
const tab = tabsData.value[index];
if (!tab) return;
if (!tab.title.trim()) {
tab.title = tab.originalTitle || `标签${index + 1}`;
ElNotification({ title: "提示", message: "标签标题不能为空", type: "info" });
}
tab.isEditing = false;
if (tab.key === activeName.value) {
activeName.value = tab.key;
}
};
// 其他方法
const onContentChange = content => {
emit("handleRichTextContentChange", content);
emit("update:content", content);
};
const setTabsInfo = () => {
outerVisible.value = false;
tabsData.value = [];
activeName.value = null;
activeEditor.value = "main";
};
const handleBeforeClose = () => {
setTabsInfo();
};
const handleQR = () => {
const quill = toRaw(myQuillEditor.value)?.getQuill();
if (!quill) return;
if (!tabsData.value.length) {
return useMsg("error", "标签页内容为空 !");
}
const range = quill.getSelection(true);
if (currentEditingTabsRef.value) {
currentEditingTabsRef.value.updateContents(tabsData.value);
currentEditingTabsRef.value = null;
} else {
quill.insertEmbed(range.index, "tabs", tabsData.value);
quill.setSelection(range.index + 1);
quill.insertText(range.index, "\n");
}
setTabsInfo();
};
const handleQX = () => {
setTabsInfo();
};
const initTitle = () => {
const editor = document.querySelector(".ql-editor");
if (editor) editor.dataset.placeholder = "";
titleConfig.value.forEach(item => {
const tip = document.querySelector(`.ql-toolbar ${item.Choice}`);
if (tip) tip.setAttribute("title", item.title);
});
};
const loadTabsDataToEditor = tabs => {
tabsData.value = [];
tabs.forEach((tab, index) => {
tabsData.value.push({
key: `tab_${generateUUID()}`,
title: tab.title || `标签${index + 1}`,
content: tab.content || "",
isEditing: false
});
});
nextTick(() => {
if (tabsData.value.length > 0) {
activeName.value = tabsData.value[0].key;
activeEditor.value = "tab-0";
}
});
};
onMounted(() => {
initTitle();
const editorEl = document.querySelector(".ql-editor");
if (editorEl) {
editorEl.addEventListener("edit-tabs", e => {
const tabsData = TabsBlot.value(e.detail.blot.domNode);
if (tabsData.length > 0) {
currentEditingTabsRef.value = e.detail.blot;
loadTabsDataToEditor(tabsData);
outerVisible.value = true;
}
});
}
});
defineExpose({
clearEditor: () => {
const quill = toRaw(myQuillEditor.value)?.getQuill();
if (quill) {
quill.setText("");
editorContent.value = "";
}
}
});
</script>
<style lang="scss">
@import "./index.scss";
// 编辑器基础样式
.ql-editor {
min-height: 600px;
cursor: text !important;
user-select: text !important;
}
// 图片排序弹窗样式
.image-sort-container {
margin-top: 15px;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 15px;
padding: 10px;
}
.image-item {
width: 150px;
padding: 8px;
cursor: move;
border: 1px solid #e5e7eb;
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
}
}
// 图片预览容器
.image-preview-container {
position: relative;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}
.preview-img {
display: block;
width: 100%;
height: 120px;
object-fit: cover;
}
// 排序序号徽章
.image-order-badge {
position: absolute;
top: 5px;
left: 5px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 12px;
color: white;
background: rgb(0 0 0 / 50%);
border-radius: 50%;
}
// 圆形删除按钮
// .circle-delete-btn {
// position: absolute;
// top: -8px;
// right: -8px;
// z-index: 500;
// display: flex;
// align-items: center;
// justify-content: center;
// width: 28px;
// height: 28px;
// padding: 0;
// margin: 0;
// color: white;
// background-color: #ff4d4f;
// border: none;
// border-radius: 50%;
// box-shadow: 0 2px 4px rgb(0 0 0 / 20%);
// transition: all 0.2s;
// &:hover {
// color: white;
// background-color: #d93025;
// transform: scale(1.1);
// }
// .el-icon {
// font-size: 14px;
// }
// }
.image-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
}
.image-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-state {
padding: 30px 0;
font-size: 14px;
color: #666666;
text-align: center;
}
.ghost {
background-color: #e9ecef;
opacity: 0.5;
}
// 标签页样式
.quill-tabs {
margin: 15px 0;
overflow: hidden;
border-radius: 4px;
}
.ql-tabs::before {
font-size: 16px;
content: "T";
}
.title-input {
width: 100px;
margin: -2px 0;
}
// 图片样式
.ql-editor .quill-image {
max-width: 100%;
height: auto;
margin: 5px 0;
&:focus {
outline: 2px solid #4285f4;
outline-offset: 2px;
}
}
// 详情样式
.o_detail_all {
overflow: hidden;
text-align: center;
background-color: #ffffff;
}
.o_detail_title {
margin-top: 2vw;
margin-bottom: 1.25vw;
overflow: hidden;
font-size: 2.25em;
font-weight: 600;
line-height: 1.2em;
color: #101010;
text-align: center;
background-color: #ffffff;
}
.o_detail_small {
margin-bottom: 0.7vw;
font-size: 1.5em;
color: #333333;
}
.o_detail_text {
width: 80%;
margin-right: auto;
margin-bottom: 0.7vw;
margin-left: auto;
font-size: 1.125em;
line-height: 1.5em;
color: #737373;
}
.products_des {
width: 100%;
margin-bottom: 50px;
}
.products_des img {
width: 100%;
}
.de_t_n {
font-size: 1.5em;
color: #333333;
}
.detail_title {
padding: 2% 0;
text-align: center;
}
.detail_title p {
line-height: 2em;
}
.detail_con_a {
margin: auto;
overflow: hidden;
}
.lj_detail_text,
.lj_detail_texts {
font-size: 0.875em;
}
.lj_detail_text p {
padding: 0.5% 0;
line-height: 1.6em;
}
/* seo-pro */
.seo-pro h3 {
margin: 2% 0 1%;
font-size: 1.5em;
font-weight: 400;
line-height: 1.2;
color: #333333;
text-align: center;
}
.seo-pro p {
margin: 0 0 11px;
text-align: center;
}
.seo-pro a {
color: #333333;
text-decoration: none;
}
.sa_blue,
.sa_blue a,
.seo-pro a:hover {
color: #009fdf;
}
</style>