268 lines
8.1 KiB
Vue
268 lines
8.1 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="editor-wrapper">
|
|||
|
|
<!-- 富文本编辑器 -->
|
|||
|
|
<div ref="editor" class="quill-container"></div>
|
|||
|
|
<!-- 新增:打印内容按钮 -->
|
|||
|
|
<button
|
|||
|
|
class="print-content-btn"
|
|||
|
|
@click="printEditorContent"
|
|||
|
|
>
|
|||
|
|
打印当前编辑器内容到控制台
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { onMounted, ref } from 'vue';
|
|||
|
|
import Quill from 'quill';
|
|||
|
|
import 'quill/dist/quill.snow.css';
|
|||
|
|
|
|||
|
|
// 编辑器容器引用
|
|||
|
|
const editor = ref<HTMLDivElement | null>(null);
|
|||
|
|
let quillInstance: Quill | null = null;
|
|||
|
|
|
|||
|
|
// 组件挂载后初始化 Quill
|
|||
|
|
onMounted(() => {
|
|||
|
|
if (editor.value) {
|
|||
|
|
// 1. 初始化 Quill 编辑器
|
|||
|
|
quillInstance = new Quill(editor.value, {
|
|||
|
|
theme: 'snow', // 使用带工具栏的主题
|
|||
|
|
modules: {
|
|||
|
|
toolbar: {
|
|||
|
|
container: [
|
|||
|
|
['bold', 'italic', 'underline', 'strike'],
|
|||
|
|
['blockquote', 'code-block'],
|
|||
|
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
|||
|
|
[{ indent: '-1' }, { indent: '+1' }],
|
|||
|
|
['link', 'image', 'video'], // 图片、视频按钮
|
|||
|
|
['clean']
|
|||
|
|
],
|
|||
|
|
handlers: {
|
|||
|
|
image: handleClickUpload // 点击工具栏「图片」按钮时触发上传本地图片
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
placeholder: '请上传或粘贴图片...',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 2. 监听文本变化,用于处理粘贴图片(自动提取 base64 并上传)
|
|||
|
|
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
|||
|
|
if (source === 'user') {
|
|||
|
|
const pastedBase64 = getPastedBase64Image(delta);
|
|||
|
|
if (pastedBase64) {
|
|||
|
|
const selection = quillInstance?.getSelection();
|
|||
|
|
if (!selection) return;
|
|||
|
|
|
|||
|
|
// 🔍 打印关键调试信息
|
|||
|
|
console.log('🔍 text-change 原始 delta.ops:', delta.ops);
|
|||
|
|
console.log('🔍 source:', source);
|
|||
|
|
console.log('🔍 当前 selection:', selection);
|
|||
|
|
console.log('🔍 当前光标位置(selection.index):', selection.index);
|
|||
|
|
console.log('🔍 推测的 base64 图片位置(imageIndex = selection.index - 1):', selection.index - 1);
|
|||
|
|
|
|||
|
|
const imageIndex = selection.index ;
|
|||
|
|
|
|||
|
|
// 1. 删除 base64 图片
|
|||
|
|
quillInstance?.deleteText(imageIndex, 1);
|
|||
|
|
|
|||
|
|
// 2. 转 base64 为 File 并上传
|
|||
|
|
const blob = base64ToBlob(pastedBase64);
|
|||
|
|
if (blob) {
|
|||
|
|
const file = new File([blob], `pasted-${Date.now()}.png`, { type: blob.type });
|
|||
|
|
uploadImageToServer(file, imageIndex);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 点击工具栏「图片」按钮时触发:选择本地图片并上传
|
|||
|
|
*/
|
|||
|
|
function handleClickUpload() {
|
|||
|
|
const fileInput = document.createElement('input');
|
|||
|
|
fileInput.type = 'file';
|
|||
|
|
fileInput.accept = 'image/*';
|
|||
|
|
|
|||
|
|
fileInput.onchange = () => {
|
|||
|
|
if (!fileInput.files || !fileInput.files[0]) return;
|
|||
|
|
const selectedFile = fileInput.files[0];
|
|||
|
|
|
|||
|
|
const selection = quillInstance?.getSelection();
|
|||
|
|
const insertIndex = selection ? selection.index : quillInstance?.getLength() || 0;
|
|||
|
|
|
|||
|
|
// 调用统一上传函数
|
|||
|
|
uploadImageToServer(selectedFile, insertIndex);
|
|||
|
|
|
|||
|
|
// 清空 input,避免重复选择同一文件不触发 onchange
|
|||
|
|
fileInput.value = '';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fileInput.click();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 统一图片上传函数:上传 File 到你的后端接口,插入返回的真实图片 URL
|
|||
|
|
* @param file 用户上传的图片文件(本地选择 or 粘贴转成 File)
|
|||
|
|
* @param insertIndex 图片要插入到编辑器的位置
|
|||
|
|
*/
|
|||
|
|
async function uploadImageToServer(file: File, insertIndex: number) {
|
|||
|
|
if (!quillInstance) return;
|
|||
|
|
|
|||
|
|
// 1. 插入「上传中」占位文本
|
|||
|
|
quillInstance.insertText(insertIndex, '[图片上传中...]', { color: '#666', italic: true });
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 2. 构造 FormData,用于上传文件
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('image', file); // ⬅️ 'image' 字段名请根据你的后端接口调整!
|
|||
|
|
|
|||
|
|
// 3. 调用你的真实后端图片上传接口(请替换为你的真实地址!)
|
|||
|
|
const UPLOAD_API_URL = 'http://localhost:8080/api/upload/image'; // ⬅️ 一定要替换成你自己的上传接口!!
|
|||
|
|
|
|||
|
|
const response = await fetch(UPLOAD_API_URL, {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData,
|
|||
|
|
// 注意:不要手动设置 Content-Type,浏览器会自动生成 multipart/form-data + boundary
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(`图片上传失败,HTTP 状态码:${response.status}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 解析返回的 JSON,获取图片 URL(重要!根据你的后端返回字段名调整!!)
|
|||
|
|
const result = await response.json();
|
|||
|
|
|
|||
|
|
// ⬅️ 下面这行是关键!根据你后端返回的数据结构修改,比如:
|
|||
|
|
// - { url: 'https://xxx.com/img.png' } → 取 result.url
|
|||
|
|
// - { success: true, data: { imageUrl: '...' } } → 取 result.data.imageUrl
|
|||
|
|
// - { data: { url: '...' } } → 取 result.data.url
|
|||
|
|
const imageUrl = result.data?.url; // ✅ 请根据实际返回字段修改!!!
|
|||
|
|
|
|||
|
|
if (!imageUrl) {
|
|||
|
|
throw new Error('服务器未返回有效的图片 URL');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 删除「上传中」占位文本
|
|||
|
|
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
|||
|
|
|
|||
|
|
// 6. 插入真实的图片 URL(不是 base64!)
|
|||
|
|
quillInstance.insertEmbed(insertIndex, 'image', imageUrl);
|
|||
|
|
|
|||
|
|
// 7. 光标移动到图片后面
|
|||
|
|
quillInstance.setSelection(insertIndex + 1);
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('图片上传失败:', error);
|
|||
|
|
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
|||
|
|
quillInstance.insertText(insertIndex, '[图片上传失败,请重试]', { color: '#ff4444', italic: true });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 打印当前编辑器内容(HTML + 纯文本)到控制台(调试用)
|
|||
|
|
*/
|
|||
|
|
function printEditorContent() {
|
|||
|
|
if (!quillInstance) {
|
|||
|
|
console.warn('编辑器未初始化,无法获取内容');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const htmlContent = quillInstance.root.innerHTML;
|
|||
|
|
const plainText = quillInstance.getText();
|
|||
|
|
|
|||
|
|
console.log('=== 编辑器 HTML 内容 ===');
|
|||
|
|
console.log(htmlContent);
|
|||
|
|
console.log('\n=== 编辑器纯文本内容 ===');
|
|||
|
|
console.log(plainText);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从 Delta 操作中提取粘贴的 base64 图片
|
|||
|
|
*/
|
|||
|
|
function getPastedBase64Image(delta: any): string | null {
|
|||
|
|
for (const op of delta.ops) {
|
|||
|
|
if (op.insert && typeof op.insert === 'object' && op.insert.image && op.insert.image.startsWith('data:image')) {
|
|||
|
|
return op.insert.image;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 将 base64 图片字符串转为 Blob 对象
|
|||
|
|
*/
|
|||
|
|
function base64ToBlob(base64: string): Blob | null {
|
|||
|
|
try {
|
|||
|
|
const [prefix, data] = base64.split(',');
|
|||
|
|
if (!prefix || !data) throw new Error('base64 格式错误');
|
|||
|
|
|
|||
|
|
const mimeMatch = prefix.match(/data:(.*?);/);
|
|||
|
|
if (!mimeMatch) throw new Error('无法解析 MIME 类型');
|
|||
|
|
const mime = mimeMatch[1];
|
|||
|
|
|
|||
|
|
const binaryData = atob(data);
|
|||
|
|
const bytes = new Uint8Array(binaryData.length);
|
|||
|
|
for (let i = 0; i < binaryData.length; i++) {
|
|||
|
|
bytes[i] = binaryData.charCodeAt(i);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return new Blob([bytes], { type: mime });
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('base64 转 Blob 失败:', error);
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* 外层容器:控制编辑器和按钮的布局 */
|
|||
|
|
.editor-wrapper {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px; /* 编辑器和按钮之间的间距 */
|
|||
|
|
max-width: 800px; /* 限制最大宽度,避免编辑器过宽 */
|
|||
|
|
margin: 20px auto; /* 水平居中 */
|
|||
|
|
padding: 0 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.quill-container {
|
|||
|
|
width: 100%;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
overflow: hidden; /* 避免工具栏边框与容器重叠 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 调整编辑器最小高度和图片显示 */
|
|||
|
|
::v-deep .ql-editor {
|
|||
|
|
min-height: 300px;
|
|||
|
|
}
|
|||
|
|
::v-deep .ql-editor img {
|
|||
|
|
max-width: 100%;
|
|||
|
|
height: auto;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 打印按钮样式 */
|
|||
|
|
.print-content-btn {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
background-color: #4096ff;
|
|||
|
|
color: white;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 14px;
|
|||
|
|
width: fit-content; /* 按钮宽度适应内容 */
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.print-content-btn:disabled {
|
|||
|
|
background-color: #c9c9c9;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
color: #666;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.print-content-btn:hover:not(:disabled) {
|
|||
|
|
background-color: #3086e8;
|
|||
|
|
}
|
|||
|
|
</style>
|