942 lines
26 KiB
Vue
942 lines
26 KiB
Vue
<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>
|