瀏覽代碼

feat(导出功能): 添加EPUB导出功能及相关依赖

添加file-saver和jszip依赖用于实现EPUB导出功能
在App.vue中添加导出按钮和导出对话框组件
实现ExportDialog组件,包含表单验证、章节统计和EPUB生成逻辑
支持导出包含封面、章节内容和元数据的完整EPUB文件
YourName 2 周之前
父節點
當前提交
a0b740d167
共有 4 個文件被更改,包括 518 次插入1 次删除
  1. 8 0
      package-lock.json
  2. 2 0
      package.json
  3. 38 1
      src/App.vue
  4. 470 0
      src/components/ExportDialog.vue

+ 8 - 0
package-lock.json

@@ -12,6 +12,8 @@
         "core-js": "^3.8.3",
         "element-plus": "^2.10.4",
         "epubjs": "^0.3.93",
+        "file-saver": "^2.0.5",
+        "jszip": "^3.10.1",
         "quill": "^2.0.3",
         "stream-browserify": "^3.0.0",
         "util": "^0.12.5",
@@ -6511,6 +6513,12 @@
         "node": "^10.12.0 || >=12.0.0"
       }
     },
+    "node_modules/file-saver": {
+      "version": "2.0.5",
+      "resolved": "https://mirrors.huaweicloud.com/repository/npm/file-saver/-/file-saver-2.0.5.tgz",
+      "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
+      "license": "MIT"
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://mirrors.huaweicloud.com/repository/npm/fill-range/-/fill-range-7.1.1.tgz",

+ 2 - 0
package.json

@@ -12,6 +12,8 @@
     "core-js": "^3.8.3",
     "element-plus": "^2.10.4",
     "epubjs": "^0.3.93",
+    "file-saver": "^2.0.5",
+    "jszip": "^3.10.1",
     "quill": "^2.0.3",
     "stream-browserify": "^3.0.0",
     "util": "^0.12.5",

+ 38 - 1
src/App.vue

@@ -1,8 +1,9 @@
 <script setup>
 import { ref, computed } from 'vue';
-import { Document } from '@element-plus/icons-vue';
+import { Document, Download } from '@element-plus/icons-vue';
 import Editor from './components/Editor.vue';
 import ChapterPanel from './components/ChapterPanel.vue';
+import ExportDialog from './components/ExportDialog.vue';
 
 // 卷-章树数据,章节点有content字段
 const treeData = ref([]);
@@ -26,6 +27,9 @@ const bookInfo = ref({
   rights: ''
 });
 
+// 导出对话框显示状态
+const showExportDialog = ref(false);
+
 // 判断是否应该显示编辑器
 const shouldShowEditor = computed(() => {
   return selectedChapter.value !== null && 
@@ -94,6 +98,11 @@ function handleBookInfoUpdate(newBookInfo) {
   bookInfo.value = { ...newBookInfo };
 }
 
+// 打开导出对话框
+function openExportDialog() {
+  showExportDialog.value = true;
+}
+
 // 获取安全的HTML内容
 function getSafeContent(content) {
   if (!content) {
@@ -127,6 +136,20 @@ function getSafeContent(content) {
 <template>
   <div class="container">
     <el-card class="chapter-panel" shadow="never">
+      <template #header>
+        <div class="panel-header">
+          <span>书籍章节列表</span>
+          <el-button 
+            type="primary" 
+            size="small" 
+            @click="openExportDialog"
+            :disabled="treeData.length === 0"
+          >
+            <el-icon><Download /></el-icon>
+            导出EPUB
+          </el-button>
+        </div>
+      </template>
       <ChapterPanel 
         :tree-data="treeData" 
         :selected-chapter-id="selectedChapter?.id"
@@ -151,6 +174,13 @@ function getSafeContent(content) {
         @update:content="handleContentUpdate"
       />
     </el-card>
+    
+    <!-- 导出对话框 -->
+    <ExportDialog
+      v-model:visible="showExportDialog"
+      :book-info="bookInfo"
+      :tree-data="treeData"
+    />
   </div>
 </template>
 
@@ -171,6 +201,13 @@ function getSafeContent(content) {
   flex: 1;
 }
 
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 16px;
+}
+
 .panel-title {
   display: flex;
   align-items: center;

+ 470 - 0
src/components/ExportDialog.vue

@@ -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>