Files
hldrCenter/management/src/views/news/QuillEditor.vue

268 lines
8.1 KiB
Vue
Raw Normal View History

2025-10-04 15:57:55 +08:00
<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>