|
@@ -0,0 +1,470 @@
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ v-model="dialogVisible"
|
|
|
+ title="导出书籍"
|
|
|
+ width="500px"
|
|
|
+ :before-close="handleClose"
|
|
|
+ >
|
|
|
+ <div class="export-content">
|
|
|
+ <el-form :model="exportForm" label-width="100px">
|
|
|
+ <el-form-item label="书籍名称">
|
|
|
+ <el-input v-model="exportForm.title" placeholder="请输入书籍名称"></el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="作者">
|
|
|
+ <el-input v-model="exportForm.author" placeholder="请输入作者姓名"></el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="语言">
|
|
|
+ <el-select v-model="exportForm.language" placeholder="请选择语言">
|
|
|
+ <el-option label="中文" value="zh-CN"></el-option>
|
|
|
+ <el-option label="英文" value="en-US"></el-option>
|
|
|
+ <el-option label="日文" value="ja-JP"></el-option>
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="标识符">
|
|
|
+ <el-input v-model="exportForm.identifier" placeholder="请输入书籍标识符"></el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="出版社">
|
|
|
+ <el-input v-model="exportForm.publisher" placeholder="请输入出版社"></el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="版权">
|
|
|
+ <el-input v-model="exportForm.rights" placeholder="请输入版权信息"></el-input>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="简介">
|
|
|
+ <el-input
|
|
|
+ v-model="exportForm.description"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入书籍简介"
|
|
|
+ ></el-input>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <div class="export-info">
|
|
|
+ <h4>导出信息</h4>
|
|
|
+ <p>章节数量: {{ chapterCount }}</p>
|
|
|
+ <p>总字数: {{ totalWords }}</p>
|
|
|
+ <p>包含封面: {{ hasCover ? '是' : '否' }}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <span class="dialog-footer">
|
|
|
+ <el-button @click="handleClose">取消</el-button>
|
|
|
+ <el-button type="primary" @click="handleExport" :loading="exporting">
|
|
|
+ {{ exporting ? '导出中...' : '导出EPUB' }}
|
|
|
+ </el-button>
|
|
|
+ </span>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, watch, defineProps, defineEmits } from 'vue';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import JSZip from 'jszip';
|
|
|
+import { saveAs } from 'file-saver';
|
|
|
+
|
|
|
+// Props
|
|
|
+const props = defineProps({
|
|
|
+ visible: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ bookInfo: {
|
|
|
+ type: Object,
|
|
|
+ default: () => ({})
|
|
|
+ },
|
|
|
+ treeData: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// Emits
|
|
|
+const emit = defineEmits(['update:visible']);
|
|
|
+
|
|
|
+// 本地响应式变量
|
|
|
+const dialogVisible = computed({
|
|
|
+ get: () => props.visible,
|
|
|
+ set: (val) => emit('update:visible', val)
|
|
|
+});
|
|
|
+
|
|
|
+const exporting = ref(false);
|
|
|
+const exportForm = ref({
|
|
|
+ title: '',
|
|
|
+ author: '',
|
|
|
+ language: 'zh-CN',
|
|
|
+ identifier: '',
|
|
|
+ publisher: '',
|
|
|
+ rights: '',
|
|
|
+ description: ''
|
|
|
+});
|
|
|
+
|
|
|
+// 计算导出信息
|
|
|
+const chapterCount = computed(() => {
|
|
|
+ let count = 0;
|
|
|
+ function countChapters(nodes) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.type === 'chapter') {
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ if (node.children) {
|
|
|
+ countChapters(node.children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ countChapters(props.treeData);
|
|
|
+ return count;
|
|
|
+});
|
|
|
+
|
|
|
+const totalWords = computed(() => {
|
|
|
+ let words = 0;
|
|
|
+ function countWords(nodes) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.type === 'chapter' && node.content) {
|
|
|
+ // 简单的字数统计,移除HTML标签
|
|
|
+ const text = node.content.replace(/<[^>]*>/g, '');
|
|
|
+ words += text.length;
|
|
|
+ }
|
|
|
+ if (node.children) {
|
|
|
+ countWords(node.children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ countWords(props.treeData);
|
|
|
+ return words;
|
|
|
+});
|
|
|
+
|
|
|
+const hasCover = computed(() => {
|
|
|
+ return props.bookInfo.cover && props.bookInfo.cover.trim() !== '';
|
|
|
+});
|
|
|
+
|
|
|
+// 监听visible变化,初始化表单
|
|
|
+watch(() => props.visible, (newVisible) => {
|
|
|
+ if (newVisible) {
|
|
|
+ exportForm.value = {
|
|
|
+ title: props.bookInfo.title || '',
|
|
|
+ author: props.bookInfo.author || '',
|
|
|
+ language: props.bookInfo.language || 'zh-CN',
|
|
|
+ identifier: props.bookInfo.identifier || '',
|
|
|
+ publisher: props.bookInfo.publisher || '',
|
|
|
+ rights: props.bookInfo.rights || '',
|
|
|
+ description: props.bookInfo.description || ''
|
|
|
+ };
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 关闭对话框
|
|
|
+function handleClose() {
|
|
|
+ emit('update:visible', false);
|
|
|
+}
|
|
|
+
|
|
|
+// 生成EPUB内容
|
|
|
+async function generateEPUB() {
|
|
|
+ const zip = new JSZip();
|
|
|
+
|
|
|
+ // 添加mimetype文件(EPUB规范要求)
|
|
|
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' });
|
|
|
+
|
|
|
+ // 添加META-INF/container.xml
|
|
|
+ const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
|
|
+ <rootfiles>
|
|
|
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
|
|
+ </rootfiles>
|
|
|
+</container>`;
|
|
|
+
|
|
|
+ zip.file('META-INF/container.xml', containerXml);
|
|
|
+
|
|
|
+ // 生成content.opf
|
|
|
+ const contentOpf = generateContentOpf();
|
|
|
+ zip.file('OEBPS/content.opf', contentOpf);
|
|
|
+
|
|
|
+ // 生成toc.ncx
|
|
|
+ const tocNcx = generateTocNcx();
|
|
|
+ zip.file('OEBPS/toc.ncx', tocNcx);
|
|
|
+
|
|
|
+ // 添加章节文件
|
|
|
+ await addChapterFiles(zip);
|
|
|
+
|
|
|
+ // 添加封面
|
|
|
+ if (hasCover.value) {
|
|
|
+ await addCoverFile(zip);
|
|
|
+ }
|
|
|
+
|
|
|
+ return zip;
|
|
|
+}
|
|
|
+
|
|
|
+// 生成content.opf
|
|
|
+function generateContentOpf() {
|
|
|
+ const manifest = [];
|
|
|
+ const spine = [];
|
|
|
+ let chapterId = 1; // 用于章节的独立ID
|
|
|
+
|
|
|
+ // 添加封面
|
|
|
+ if (hasCover.value) {
|
|
|
+ manifest.push(` <item id="cover" href="cover.jpg" media-type="image/jpeg" properties="cover-image"/>`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加章节
|
|
|
+ function addChapters(nodes) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.type === 'chapter') {
|
|
|
+ const filename = `chapter-${chapterId}.xhtml`;
|
|
|
+ manifest.push(` <item id="chapter-${chapterId}" href="${filename}" media-type="application/xhtml+xml"/>`);
|
|
|
+ spine.push(` <itemref idref="chapter-${chapterId}"/>`);
|
|
|
+ chapterId++;
|
|
|
+ }
|
|
|
+ if (node.children) {
|
|
|
+ addChapters(node.children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ addChapters(props.treeData);
|
|
|
+
|
|
|
+ return `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
+<package version="3.0" xmlns="http://www.idpf.org/2007/opf">
|
|
|
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
|
|
+ <dc:title>${exportForm.value.title}</dc:title>
|
|
|
+ <dc:creator>${exportForm.value.author}</dc:creator>
|
|
|
+ <dc:language>${exportForm.value.language}</dc:language>
|
|
|
+ <dc:identifier id="BookId">${exportForm.value.identifier}</dc:identifier>
|
|
|
+ <dc:publisher>${exportForm.value.publisher}</dc:publisher>
|
|
|
+ <dc:rights>${exportForm.value.rights}</dc:rights>
|
|
|
+ <dc:description>${exportForm.value.description}</dc:description>
|
|
|
+ <meta property="dcterms:modified">${new Date().toISOString()}</meta>
|
|
|
+ </metadata>
|
|
|
+ <manifest>
|
|
|
+${manifest.join('\n')}
|
|
|
+ </manifest>
|
|
|
+ <spine>
|
|
|
+${spine.join('\n')}
|
|
|
+ </spine>
|
|
|
+</package>`;
|
|
|
+}
|
|
|
+
|
|
|
+// 生成toc.ncx
|
|
|
+function generateTocNcx() {
|
|
|
+ const navPoints = [];
|
|
|
+ let navId = 1;
|
|
|
+ let playOrder = 1;
|
|
|
+ let chapterIndex = 1; // 用于章节文件名的ID
|
|
|
+
|
|
|
+ function addNavPoints(nodes) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.type === 'volume') {
|
|
|
+ navPoints.push(` <navPoint id="nav-${navId}" playOrder="${playOrder}">
|
|
|
+ <navLabel>
|
|
|
+ <text>${node.label}</text>
|
|
|
+ </navLabel>
|
|
|
+ </navPoint>`);
|
|
|
+ navId++;
|
|
|
+ playOrder++;
|
|
|
+ } else if (node.type === 'chapter') {
|
|
|
+ navPoints.push(` <navPoint id="nav-${navId}" playOrder="${playOrder}">
|
|
|
+ <navLabel>
|
|
|
+ <text>${node.label}</text>
|
|
|
+ </navLabel>
|
|
|
+ <content src="chapter-${chapterIndex}.xhtml"/>
|
|
|
+ </navPoint>`);
|
|
|
+ navId++;
|
|
|
+ playOrder++;
|
|
|
+ chapterIndex++; // 只在章节时递增
|
|
|
+ }
|
|
|
+ if (node.children) {
|
|
|
+ addNavPoints(node.children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ addNavPoints(props.treeData);
|
|
|
+
|
|
|
+ return `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
+<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
|
|
|
+<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
|
|
|
+ <head>
|
|
|
+ <meta name="dtb:uid" content="${exportForm.value.identifier}"/>
|
|
|
+ <meta name="dtb:depth" content="1"/>
|
|
|
+ <meta name="dtb:totalPageCount" content="0"/>
|
|
|
+ <meta name="dtb:maxPageNumber" content="0"/>
|
|
|
+ </head>
|
|
|
+ <docTitle>
|
|
|
+ <text>${exportForm.value.title}</text>
|
|
|
+ </docTitle>
|
|
|
+ <navMap>
|
|
|
+${navPoints.join('\n')}
|
|
|
+ </navMap>
|
|
|
+</ncx>`;
|
|
|
+}
|
|
|
+
|
|
|
+// 添加章节文件
|
|
|
+async function addChapterFiles(zip) {
|
|
|
+ let fileId = 1;
|
|
|
+
|
|
|
+ function addChapters(nodes) {
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.type === 'chapter') {
|
|
|
+ const filename = `chapter-${fileId}.xhtml`;
|
|
|
+ const result = generateChapterContent(node, fileId);
|
|
|
+ zip.file(`OEBPS/${filename}`, result.content);
|
|
|
+
|
|
|
+ // 添加章节中的图片文件
|
|
|
+ for (const [imageName, base64Data] of result.images) {
|
|
|
+ const imageData = base64Data.split(',')[1];
|
|
|
+ zip.file(`OEBPS/${imageName}`, imageData, { base64: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ fileId++;
|
|
|
+ }
|
|
|
+ if (node.children) {
|
|
|
+ addChapters(node.children);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ addChapters(props.treeData);
|
|
|
+}
|
|
|
+
|
|
|
+// 生成章节内容
|
|
|
+function generateChapterContent(chapter, chapterId) {
|
|
|
+ // 处理章节内容中的base64图片
|
|
|
+ let processedContent = chapter.content || '<p>暂无内容</p>';
|
|
|
+ const imageMap = new Map(); // 存储图片文件名和base64数据的映射
|
|
|
+
|
|
|
+ // 查找并处理base64图片
|
|
|
+ processedContent = processedContent.replace(/<img[^>]*src="(data:image\/[^;]+;base64,[^"]+)"[^>]*>/g, (match, src) => {
|
|
|
+ const imageName = `image-${chapterId}-${imageMap.size + 1}.jpg`;
|
|
|
+ imageMap.set(imageName, src);
|
|
|
+ return match.replace(src, imageName);
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
+<!DOCTYPE html>
|
|
|
+<html xmlns="http://www.w3.org/1999/xhtml">
|
|
|
+<head>
|
|
|
+ <title>${chapter.label}</title>
|
|
|
+ <meta charset="utf-8"/>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <h1>${chapter.label}</h1>
|
|
|
+ <div class="content">
|
|
|
+ ${processedContent}
|
|
|
+ </div>
|
|
|
+</body>
|
|
|
+</html>`,
|
|
|
+ images: imageMap
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// 添加封面文件
|
|
|
+async function addCoverFile(zip) {
|
|
|
+ try {
|
|
|
+ // 从base64或blob URL获取图片数据
|
|
|
+ let imageData;
|
|
|
+ if (props.bookInfo.cover.startsWith('data:')) {
|
|
|
+ // base64格式
|
|
|
+ imageData = props.bookInfo.cover;
|
|
|
+ } else if (props.bookInfo.cover.startsWith('blob:')) {
|
|
|
+ // blob URL格式
|
|
|
+ const response = await fetch(props.bookInfo.cover);
|
|
|
+ const blob = await response.blob();
|
|
|
+ imageData = await new Promise((resolve) => {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = () => resolve(reader.result);
|
|
|
+ reader.readAsDataURL(blob);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (imageData) {
|
|
|
+ // 处理base64格式的图片数据
|
|
|
+ let base64Data;
|
|
|
+ if (imageData.startsWith('data:')) {
|
|
|
+ // 如果是完整的data URL,提取base64部分
|
|
|
+ base64Data = imageData.split(',')[1];
|
|
|
+ } else {
|
|
|
+ // 如果已经是base64数据,直接使用
|
|
|
+ base64Data = imageData;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 确保base64数据不为空
|
|
|
+ if (base64Data && base64Data.trim() !== '') {
|
|
|
+ zip.file('OEBPS/cover.jpg', base64Data, { base64: true });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('封面添加失败:', error);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 导出处理
|
|
|
+async function handleExport() {
|
|
|
+ if (!exportForm.value.title.trim()) {
|
|
|
+ ElMessage.error('请输入书籍名称');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!exportForm.value.author.trim()) {
|
|
|
+ ElMessage.error('请输入作者姓名');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ exporting.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const zip = await generateEPUB();
|
|
|
+ const blob = await zip.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' });
|
|
|
+
|
|
|
+ // 生成文件名
|
|
|
+ const filename = `${exportForm.value.title.replace(/[^\w\s]/gi, '')}.epub`;
|
|
|
+
|
|
|
+ // 下载文件
|
|
|
+ saveAs(blob, filename);
|
|
|
+
|
|
|
+ ElMessage.success('书籍导出成功!');
|
|
|
+ handleClose();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('导出失败:', error);
|
|
|
+ ElMessage.error('导出失败,请重试');
|
|
|
+ } finally {
|
|
|
+ exporting.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.export-content {
|
|
|
+ max-height: 60vh;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.export-info {
|
|
|
+ margin-top: 20px;
|
|
|
+ padding: 15px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.export-info h4 {
|
|
|
+ margin: 0 0 10px 0;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+
|
|
|
+.export-info p {
|
|
|
+ margin: 5px 0;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.dialog-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+</style>
|