<template>
|
<div>
|
<h3>前端 Word 编辑器(TinyMCE)</h3>
|
<div class="actions">
|
<input type="file" accept=".docx" @change="importWord" ref="fileInput" style="margin-right: 10px;" />
|
<button @click="exportToWord">导出 Word</button>
|
<button @click="exportToPDF">导出 PDF</button>
|
</div>
|
<editor v-model="content" :init="editorInit" :api-key="apiKey"></editor>
|
</div>
|
</template>
|
<script>
|
import Editor from '@tinymce/tinymce-vue';
|
import { Document, Packer, Paragraph, TextRun, ImageRun, Table as DocxTable, TableRow as DocxTableRow, TableCell as DocxTableCell } from 'docx';
|
import { saveAs } from 'file-saver';
|
import jsPDF from 'jspdf';
|
import html2canvas from 'html2canvas';
|
import mammoth from 'mammoth';
|
|
export default {
|
components: { Editor },
|
data() {
|
return {
|
apiKey: '3ceaxd5ckw4te35xj38vj3p5rmmeyv0x8pq2yrr92rwdiqzp',
|
content: '',
|
editorInit: {
|
height: 500,
|
menubar: true,
|
plugins: [
|
'advlist autolink lists link image charmap print preview anchor',
|
'searchreplace visualblocks code fullscreen',
|
'insertdatetime media table paste code help wordcount'
|
],
|
toolbar:
|
'undo redo | formatselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | image table | removeformat | help',
|
images_upload_handler: (blobInfo, success, failure) => {
|
const reader = new FileReader();
|
reader.onload = () => success(reader.result);
|
reader.onerror = () => failure('图片上传失败');
|
reader.readAsDataURL(blobInfo.blob());
|
},
|
content_style: 'body { font-family: SimSun, Arial; font-size: 14px; }',
|
table_default_attributes: { border: 1 },
|
readonly: false,
|
language: 'zh-CN',
|
},
|
};
|
},
|
methods: {
|
/** 导入 Word 文档 */
|
importWord(event) {
|
const file = event.target.files[0];
|
if (!file) {
|
this.$message.error('请选择一个 .docx 文件');
|
return;
|
}
|
if (!file.name.endsWith('.docx')) {
|
this.$message.error('请上传 .docx 格式的文件');
|
return;
|
}
|
|
const reader = new FileReader();
|
reader.onload = async (e) => {
|
try {
|
const arrayBuffer = e.target.result;
|
const result = await mammoth.convertToHtml({ arrayBuffer });
|
this.content = result.value || '<p>无内容</p>';
|
this.$message.success('Word 文档导入成功');
|
this.$refs.fileInput.value = '';
|
} catch (err) {
|
console.error('导入 Word 失败:', err);
|
this.$message.error('导入 Word 文档失败:' + err.message);
|
}
|
};
|
reader.onerror = () => {
|
this.$message.error('读取文件失败');
|
};
|
reader.readAsArrayBuffer(file);
|
},
|
|
/** 导出为 Word */
|
exportToWord() {
|
if (!this.content || this.content.trim() === '') {
|
this.$message.error('编辑器内容为空,无法导出');
|
return;
|
}
|
this.parseHtmlToDocx(this.content).then((elements) => {
|
const doc = new Document({
|
sections: [{ properties: {}, children: elements }],
|
});
|
|
Packer.toBlob(doc).then((blob) => {
|
saveAs(blob, '文档.docx');
|
this.$message.success('导出 Word 成功');
|
}).catch((err) => {
|
console.error('生成 Word 失败:', err);
|
this.$message.error('生成 Word 失败:' + err.message);
|
});
|
}).catch((err) => {
|
console.error('解析 HTML 失败:', err);
|
this.$message.error('解析 HTML 失败:' + err.message);
|
});
|
},
|
|
/** 解析 HTML 为 docx 元素 */
|
parseHtmlToDocx(html) {
|
return new Promise((resolve, reject) => {
|
if (!html || typeof html !== 'string') {
|
reject(new Error('无效的 HTML 内容'));
|
return;
|
}
|
|
console.log('Input HTML:', html); // 调试输入 HTML
|
|
const parser = new DOMParser();
|
const doc = parser.parseFromString(html, 'text/html');
|
if (!doc || !doc.body) {
|
reject(new Error('HTML 解析失败:文档结构无效'));
|
return;
|
}
|
|
const elements = [];
|
const imagePromises = [];
|
|
const processNode = (node) => {
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
return;
|
}
|
|
console.log('Processing node:', node.tagName); // 调试节点
|
|
if (node.tagName === 'P') {
|
const childNodes = node.childNodes ? Array.from(node.childNodes) : [];
|
const textRuns = childNodes.map((child) => {
|
if (child.nodeType === Node.TEXT_NODE) {
|
return new TextRun({ text: child.textContent || '' });
|
} else if (child.tagName === 'B') {
|
return new TextRun({ text: child.textContent || '', bold: true });
|
} else if (child.tagName === 'I') {
|
return new TextRun({ text: child.textContent || '', italics: true });
|
} else if (child.tagName === 'IMG') {
|
const promise = this.getImageBase64FromUrl(child.src).then(({ base64, width, height }) => {
|
return new ImageRun({
|
data: base64,
|
transformation: { width, height },
|
});
|
}).catch((err) => {
|
console.error('图片加载失败:', err, 'URL:', child.src);
|
return new TextRun(`[图片: ${child.alt || '图像'}]`);
|
});
|
imagePromises.push(promise);
|
return promise;
|
}
|
return new TextRun({ text: child.textContent || '' });
|
}).filter(Boolean);
|
if (textRuns.length) {
|
elements.push(new Paragraph({ children: textRuns }));
|
}
|
} else if (node.tagName === 'H1') {
|
elements.push(new Paragraph({ text: node.textContent || '', heading: 'Heading1' }));
|
} else if (node.tagName === 'UL' || node.tagName === 'OL') {
|
const children = node.children ? Array.from(node.children) : [];
|
children.forEach((li) => {
|
elements.push(
|
new Paragraph({
|
text: li.textContent || '',
|
bullet: node.tagName === 'UL' ? { level: 0 } : { level: 0, number: true },
|
})
|
);
|
});
|
} else if (node.tagName === 'TABLE') {
|
const rows = node.querySelectorAll('tr') ? Array.from(node.querySelectorAll('tr')) : [];
|
const tableRows = rows.map((tr) => {
|
const cells = tr.querySelectorAll('td, th') ? Array.from(tr.querySelectorAll('td, th')) : [];
|
const tableCells = cells.map((cell) => {
|
return new DocxTableCell({
|
children: [new Paragraph(cell.textContent || '')],
|
});
|
});
|
return new DocxTableRow({ children: tableCells });
|
});
|
if (tableRows.length) {
|
elements.push(new DocxTable({ rows: tableRows }));
|
}
|
}
|
const children = node.children ? Array.from(node.children) : [];
|
children.forEach(processNode);
|
};
|
|
const nodes = doc.body.childNodes ? Array.from(doc.body.childNodes) : [];
|
if (nodes.length === 0) {
|
console.warn('HTML 文档无有效节点');
|
resolve(elements);
|
return;
|
}
|
|
nodes.forEach(processNode);
|
|
Promise.all(imagePromises).then((imageRuns) => {
|
let imageIndex = 0;
|
elements.forEach((element, index) => {
|
if (!(element instanceof Paragraph)) {
|
console.log('Skipping non-Paragraph element at index', index, 'type:', element.constructor.name);
|
return; // 跳过非 Paragraph 元素
|
}
|
if (!Array.isArray(element.children)) {
|
console.warn('Element at index', index, 'has invalid children:', element.children);
|
element.children = []; // 初始化为空数组
|
return;
|
}
|
console.log('Processing element at index', index, 'children:', element.children);
|
element.children = element.children.map((child, childIndex) => {
|
if (child instanceof Promise) {
|
const imageRun = imageRuns[imageIndex++] || new TextRun('[图片加载失败]');
|
console.log('Replacing Promise at child index', childIndex, 'with:', imageRun);
|
return imageRun;
|
}
|
return child;
|
});
|
});
|
resolve(elements);
|
}).catch((err) => {
|
console.error('图片处理失败:', err);
|
reject(new Error('图片处理失败:' + err.message));
|
});
|
});
|
},
|
|
/** 获取图片的 Base64 数据和尺寸 */
|
getImageBase64FromUrl(url) {
|
return new Promise((resolve, reject) => {
|
if (!url) {
|
reject(new Error('图片 URL 为空'));
|
return;
|
}
|
|
console.log('Fetching image:', url);
|
|
if (url.startsWith('data:image/')) {
|
const base64 = url.split(',')[1];
|
const img = new Image();
|
img.onload = () => {
|
const maxWidth = 200;
|
const maxHeight = 200;
|
let { width, height } = img;
|
if (width > maxWidth || height > maxHeight) {
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
width = Math.round(width * ratio);
|
height = Math.round(height * ratio);
|
}
|
resolve({ base64, width, height });
|
};
|
img.onerror = () => reject(new Error('Base64 图片加载失败'));
|
img.src = url;
|
return;
|
}
|
|
fetch(url, { mode: 'cors' })
|
.then((response) => {
|
if (!response.ok) throw new Error('网络图片加载失败');
|
return response.blob();
|
})
|
.then((blob) => {
|
const reader = new FileReader();
|
reader.onloadend = () => {
|
const base64 = reader.result.split(',')[1];
|
const img = new Image();
|
img.onload = () => {
|
const maxWidth = 200;
|
const maxHeight = 200;
|
let { width, height } = img;
|
if (width > maxWidth || height > maxHeight) {
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
width = Math.round(width * ratio);
|
height = Math.round(height * ratio);
|
}
|
resolve({ base64, width, height });
|
};
|
img.onerror = () => reject(new Error('图片读取失败'));
|
img.src = reader.result;
|
};
|
reader.onerror = () => reject(new Error('图片读取失败'));
|
reader.readAsDataURL(blob);
|
})
|
.catch((err) => reject(err));
|
});
|
},
|
|
/** 导出为 PDF */
|
exportToPDF() {
|
if (!this.content || this.content.trim() === '') {
|
this.$message.error('编辑器内容为空,无法导出');
|
return;
|
}
|
const contentElement = document.createElement('div');
|
contentElement.innerHTML = this.content;
|
contentElement.style.padding = '10px';
|
document.body.appendChild(contentElement);
|
|
html2canvas(contentElement, { scale: 2 })
|
.then((canvas) => {
|
const doc = new jsPDF();
|
const imgData = canvas.toDataURL('image/png');
|
doc.addImage(imgData, 'PNG', 10, 10, 190, 0);
|
doc.save('文档.pdf');
|
this.$message.success('导出 PDF 成功');
|
document.body.removeChild(contentElement);
|
})
|
.catch((err) => {
|
console.error('生成 PDF 失败:', err);
|
this.$message.error('生成 PDF 失败:' + err.message);
|
document.body.removeChild(contentElement);
|
});
|
},
|
},
|
};
|
</script>
|
<style>
|
.actions {
|
margin-top: 10px;
|
margin-bottom: 10px;
|
}
|
.actions button {
|
padding: 8px 16px;
|
margin-right: 10px;
|
background-color: #409eff;
|
color: white;
|
border: none;
|
border-radius: 4px;
|
cursor: pointer;
|
}
|
.actions button:hover {
|
background-color: #66b1ff;
|
}
|
</style>
|