Kaynağa Gözat

离线包初版

dsy 1 ay önce
ebeveyn
işleme
0721bc29d3

+ 1 - 1
.env

@@ -11,4 +11,4 @@ VUE_APP_BookWebSI = '/GCLSBookWebSI/ServiceInterface'
 VUE_APP_EepServer = '/EEPServer/SI'
 
 #version
-VUE_APP_VERSION = '2025.12.02'
+VUE_APP_VERSION = '2025.12.07'

+ 151 - 1
main.js

@@ -1,8 +1,15 @@
-const { app, BrowserWindow, ipcMain, net, shell } = require('electron');
+const { app, BrowserWindow, ipcMain, net, shell, dialog } = require('electron');
 const path = require('path');
 const url = require('url');
 const fs = require('fs');
 const { spawn } = require('child_process'); // 用于启动子进程
+const sevenBin = require('7zip-bin');
+let iconv = null; // 用来在 Windows 上正确解码 7za 使用系统编码输出的中文信息
+try {
+  iconv = require('iconv-lite');
+} catch (e) {
+  iconv = null;
+}
 
 let win = null;
 
@@ -135,6 +142,149 @@ ipcMain.on('install-update', (event, filePath) => {
   }
 });
 
+/**
+ * 使用 7z 压缩文件/目录,支持密码和进度反馈
+ * @param {Object} opts 压缩选项
+ * @param {Array<string>} opts.sources 待压缩的文件或目录列表
+ * @param {string} opts.dest 目标压缩包路径
+ * @param {string} [opts.format] 压缩格式,'7z' 或 'zip',默认 'zip'
+ * @param {number} [opts.level] 压缩级别,0-9,默认 9
+ * @param {boolean} [opts.recurse] 是否递归子目录,默认 true
+ * @param {string} [opts.password] 压缩密码
+ * @returns {Promise<{success: boolean, dest: string}>} 压缩结果
+ */
+ipcMain.handle('compress-with-7z', async (evt, opts) => {
+  const sender = evt.sender; // 用于发送进度消息
+  const sources = Array.isArray(opts.sources) ? opts.sources : []; // 待压缩的文件或目录列表
+  if (!sources.length) throw new Error('no sources');
+
+  const dest = path.resolve(opts.dest); // 目标压缩包路径
+  const format = opts.format === 'zip' ? 'zip' : '7z'; // 压缩格式
+  const level = Number.isInteger(opts.level) ? Math.max(0, Math.min(9, opts.level)) : 9; // 压缩级别
+  const recurse = opts.recurse !== false; // 是否递归子目录
+
+  // 检查源文件/目录是否存在
+  for (const s of sources) {
+    if (!fs.existsSync(s)) throw new Error(`source not found: ${s}`);
+  }
+
+  const sevenPath = sevenBin.path7za; // 7za 可执行路径
+  const args = ['a', `-t${format}`, `-mx=${level}`, dest]; // 基本参数
+  if (opts.password) {
+    args.push(`-p${opts.password}`);
+    // 仅当使用 7z 格式时才启用头部加密(zip 不支持 -mhe)
+    if (format === '7z') args.push('-mhe=on'); // 加密文件名/头(仅 7z 支持)
+  }
+  if (recurse) args.push('-r'); // 递归
+  // append sources (支持通配或单个路径)
+  args.push(...sources);
+
+  // 确保目标目录存在(避免 7z 无法写入导致的失败)
+  try {
+    fs.mkdirSync(path.dirname(dest), { recursive: true });
+  } catch (e) {
+    // 如果无法创建目标目录,尽早报错
+    throw new Error(`failed to create dest directory: ${e.message}`);
+  }
+
+  return await new Promise((resolve, reject) => {
+    let stdoutAll = '';
+    let stderrAll = '';
+
+    const child = spawn(sevenPath, args, { windowsHide: true });
+
+    // 收集并转发 stdout/stderr,用作进度显示或日志
+    child.stdout.on('data', (chunk) => {
+      const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
+      let s = '';
+      // 7za 在 Windows 上输出常用 OEM/GBK 编码,优先用 iconv-lite 解码
+      if (process.platform === 'win32' && iconv) {
+        try {
+          s = iconv.decode(buf, 'cp936');
+        } catch (e) {
+          s = buf.toString('utf8');
+        }
+      } else {
+        s = buf.toString('utf8');
+      }
+      stdoutAll += s;
+      sender.send('compress-progress', s);
+    });
+    child.stderr.on('data', (chunk) => {
+      const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
+      let s = '';
+      if (process.platform === 'win32' && iconv) {
+        try {
+          s = iconv.decode(buf, 'cp936');
+        } catch (e) {
+          s = buf.toString('utf8');
+        }
+      } else {
+        s = buf.toString('utf8');
+      }
+      stderrAll += s;
+      sender.send('compress-stderr', s);
+    });
+
+    child.on('error', (err) => reject(err));
+
+    child.on('close', (code) => {
+      if (code === 0) {
+        resolve({ success: true, dest });
+      } else {
+        // 包含 stdout/stderr 与命令信息以便排查
+        const errMsg = `7z exited with code ${code}\ncommand: ${JSON.stringify({ sevenPath, args })}\nstdout:\n${stdoutAll}\nstderr:\n${stderrAll}`;
+        const e = new Error(errMsg);
+        e.code = code;
+        e.stdout = stdoutAll;
+        e.stderr = stderrAll;
+        e.cmd = { sevenPath, args };
+        return reject(e);
+      }
+    });
+  });
+});
+
+/**
+ * 下载文件
+ * @param {string} url 文件 URL
+ * @param {string} destPath 保存路径
+ */
+ipcMain.handle('download-file', async (evt, { url, destPath }) => {
+  return new Promise((resolve, reject) => {
+    const fileStream = fs.createWriteStream(destPath); // 创建写入流
+    const req = net.request(url);
+    req.on('response', (res) => {
+      res.on('data', (chunk) => {
+        fileStream.write(chunk);
+      });
+      res.on('end', () => {
+        fileStream.end();
+        resolve(destPath);
+      });
+    });
+    req.on('error', (err) => {
+      reject(err);
+    });
+    req.end();
+  });
+});
+
+/**
+ * 打开文件对话框
+ * @param {Object} opts 对话框选项
+ * @param {string} [opts.title] 对话框标题
+ * @param {Array<string>} [opts.properties] 对话框属性数组,默认 ['openDirectory']
+ * @return {Promise<{canceled: boolean, filePaths: string[]}>} 文件路径 canceled 表示是否取消选择 filePaths 表示选择的文件路径数组
+ */
+ipcMain.handle('dialog:openFiles', async (event, options = {}) => {
+  const result = await dialog.showOpenDialog({
+    title: options.title || '选择文件夹',
+    properties: options.properties || ['openDirectory'],
+  });
+  return { canceled: result.canceled, filePaths: result.filePaths };
+});
+
 // 当所有窗口都已关闭时退出
 app.on('window-all-closed', () => {
   if (process.platform !== 'darwin') {

+ 3 - 4
package-lock.json

@@ -10,6 +10,7 @@
       "hasInstallScript": true,
       "dependencies": {
         "@tinymce/tinymce-vue": "^3.2.8",
+        "7zip-bin": "^5.2.0",
         "axios": "^1.7.2",
         "cnchar": "^3.2.6",
         "core-js": "^3.47.0",
@@ -4649,8 +4650,7 @@
     "node_modules/7zip-bin": {
       "version": "5.2.0",
       "resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
-      "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
-      "dev": true
+      "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="
     },
     "node_modules/accepts": {
       "version": "1.3.8",
@@ -24363,8 +24363,7 @@
     "7zip-bin": {
       "version": "5.2.0",
       "resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz",
-      "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
-      "dev": true
+      "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="
     },
     "accepts": {
       "version": "1.3.8",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "eep_page",
-  "version": "2025.12.02",
+  "version": "2025.12.07",
   "private": true,
   "main": "main.js",
   "description": "智慧梧桐数字教材编辑器",
@@ -21,6 +21,7 @@
   },
   "dependencies": {
     "@tinymce/tinymce-vue": "^3.2.8",
+    "7zip-bin": "^5.2.0",
     "axios": "^1.7.2",
     "cnchar": "^3.2.6",
     "core-js": "^3.47.0",

+ 89 - 5
preload.js

@@ -1,7 +1,91 @@
-const { contextBridge } = require('electron');
+const { contextBridge, ipcRenderer } = require('electron');
+const path = require('path');
+const os = require('os');
+const fs = require('fs');
 
-// 公开给渲染进程的 API
-contextBridge.exposeInMainWorld('version', {
-  node: process.versions.node,
-  chrome: process.versions.chrome,
+/**
+ * 文件操作相关的预加载脚本
+ */
+contextBridge.exposeInMainWorld('fileAPI', {
+  /**
+   * 使用 7z 压缩文件
+   * @param {Object} opts 压缩选项
+   * @returns {Promise} 压缩结果
+   */
+  compress: (opts) => ipcRenderer.invoke('compress-with-7z', opts),
+
+  /**
+   * 监听压缩进度
+   * @param {Function} cb 回调函数,接收进度文本作为参数
+   * @returns {Function} 用于移除监听器的函数
+   */
+  onProgress: (cb) => {
+    const handler = (event, data) => cb(String(data));
+    ipcRenderer.on('compress-progress', handler);
+    return () => ipcRenderer.removeListener('compress-progress', handler);
+  },
+
+  /**
+   * 监听压缩错误输出
+   * @param {Function} cb 回调函数,接收错误文本作为参数
+   * @returns {Function} 用于移除监听器的函数
+   */
+  onStderr: (cb) => {
+    const handler = (event, data) => cb(String(data));
+    ipcRenderer.on('compress-stderr', handler);
+    return () => ipcRenderer.removeListener('compress-stderr', handler);
+  },
+
+  /**
+   * 删除临时解压目录
+   * @param {string} tmpDir 临时解压目录路径
+   */
+  deleteTempDir: (tmpDir) => {
+    return fs.rmSync(tmpDir, { recursive: true, force: true });
+  },
+
+  /**
+   * 创建临时文件夹
+   * @param {string} prefix 临时文件夹前缀
+   * @returns {string} 创建的临时文件夹路径
+   */
+  createTempDir: (prefix) => {
+    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'eep-'));
+    const dirResourcePath = path.join(tmpDir, 'resource');
+    if (!fs.existsSync(dirResourcePath)) {
+      fs.mkdirSync(dirResourcePath);
+    }
+    const dirCoursewarePath = path.join(tmpDir, 'courseware');
+    if (!fs.existsSync(dirCoursewarePath)) {
+      fs.mkdirSync(dirCoursewarePath);
+    }
+    return tmpDir;
+  },
+
+  /**
+   * 读取解压后的文件内容
+   * @param {string} tmpDir 临时解压目录路径
+   * @param {string} entryName 文件条目名称
+   * @returns {Buffer} 文件内容
+   */
+  readZipFileSync: (tmpDir, entryName) => {
+    const filePath = path.join(tmpDir, entryName);
+    return fs.readFileSync(filePath);
+  },
+
+  /**
+   * 下载文件
+   * @param {string} url 文件 URL
+   * @param {string} destPath 保存路径
+   */
+  downloadFile: (url, destPath) => {
+    return ipcRenderer.invoke('download-file', { url, destPath });
+  },
+
+  /**
+   * 打开文件对话框
+   * @param {Object} opts 对话框选项
+   * @returns {Promise} 文件路径
+   */
+  openFileDialog: (opts) => ipcRenderer.invoke('dialog:openFiles', opts),
 });

+ 22 - 0
src/api/offline.js

@@ -0,0 +1,22 @@
+import { http } from '@/utils/http';
+
+/**
+ * 创建教材章节结构文件
+ * @param {Object} data
+ * @param {string} data.book_id 教材ID
+ */
+export function CreateBookChapterStructFileList(data) {
+  return http.post(
+    `${process.env.VUE_APP_EepServer}?MethodName=offline_pack_manager-CreateBookChapterStructFileList`,
+    data,
+  );
+}
+
+/**
+ * 创建课件文件列表
+ * @param {Object} data
+ * @param {string} data.courseware_id 课节ID
+ */
+export function CreateCoursewareFileList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=offline_pack_manager-CreateCoursewareFileList`, data);
+}

+ 0 - 20
src/components/CommonPreview.vue

@@ -404,11 +404,6 @@ export default {
       isChildDataLoad: false,
       mindMapJsonData: {}, // 思维导图json数据
       drawerType: '', // 抽屉类型
-      drawerStyle: {
-        top: '0',
-        height: '0',
-        right: '0',
-      },
       page_capacity: 10,
       cur_page: 1,
       file_list: [],
@@ -492,9 +487,6 @@ export default {
       this.getProjectInfo();
     }
   },
-  mounted() {
-    this.calcDrawerPosition();
-  },
   methods: {
     getProjectBaseInfo() {
       GetProjectBaseInfo({ id: this.projectId }).then(({ project_info }) => {
@@ -715,18 +707,6 @@ export default {
       this.visibleMindMap = false;
     },
 
-    // 计算抽屉滑出位置
-    calcDrawerPosition() {
-      const menu = this.$refs.sidebarMenu;
-      if (menu) {
-        const rect = menu.getBoundingClientRect();
-        this.drawerStyle = {
-          top: `${rect.top}px`,
-          height: `${rect.height}px`,
-          left: `${rect.right - 240}px`,
-        };
-      }
-    },
     /**
      * 打开抽屉并初始化加载
      * @param {Object} param - 抽屉参数

+ 1 - 1
src/views/book/courseware/preview/components/fill/FillPreview.vue

@@ -299,7 +299,7 @@ export default {
       let style = {
         'grid-auto-flow': isRow ? 'column' : 'row',
         'column-gap': isRow && isHasAudio ? '16px' : undefined,
-        'row-gap': isRow && !isHasAudio ? undefined : '8px',
+        'row-gap': isRow || !isHasAudio ? undefined : '8px',
         'grid-template-areas': gridArea,
         'grid-template-rows': gridTemplateRows,
         'grid-template-columns': gridTemplateColumns,

+ 181 - 0
src/views/personal_workbench/edit_task/preview/index.vue

@@ -4,6 +4,7 @@
 
     <CommonPreview :id="id" ref="preview" :project-id="project_id">
       <template #operator="{ courseware }">
+        <span class="link" @click="selectDirectory">下载离线包</span>
         <span class="link" @click="saveCoursewareAsTemplate">保存为个人模板</span>
         <span v-if="isTrue(courseware.is_can_start_edit)" class="link" @click="editTask">开始编辑</span>
         <span v-if="isTrue(courseware.is_can_submit_audit)" class="link" @click="submitCoursewareToAuditFlow">
@@ -22,6 +23,7 @@ import CommonPreview from '@/components/CommonPreview.vue';
 import { SubmitBookCoursewareToAuditFlow } from '@/api/project';
 import { isTrue } from '@/utils/validate';
 import { SaveCoursewareAsTemplatePersonal } from '@/api/template';
+import { CreateBookChapterStructFileList, CreateCoursewareFileList } from '@/api/offline';
 
 export default {
   name: 'TaskPreviewPage',
@@ -34,8 +36,41 @@ export default {
       id: this.$route.params.id,
       project_id: this.$route.query.project_id,
       isTrue,
+      loadingInstance: null, // 加载中实例
+      tempDir: '', // 临时目录,用于存放下载的文件
+      savePath: '', // 保存离线包的路径
+      packageName: 'EEP 离线包', // 离线包名称
+      file_info_list: [], // 章节结构文件列表
+      downloadTotal: 0, // 下载总数
+      downloadCompleted: 0, // 已下载数量
+      downloadWatcher: null, // 下载进度监听器
     };
   },
+  computed: {
+    downloadProgress() {
+      if (this.downloadTotal === 0) return 0;
+      return (this.downloadCompleted / this.downloadTotal) * 100;
+    },
+  },
+  beforeDestroy() {
+    // 删除临时目录及其内容
+    if (this.tempDir && this.tempDir.length > 0) {
+      window.fileAPI.deleteTempDir(this.tempDir);
+      this.tempDir = '';
+    }
+
+    // 删除 watch 监听器
+    if (this.downloadWatcher) {
+      this.downloadWatcher();
+      this.downloadWatcher = null;
+    }
+
+    // 关闭加载中实例
+    if (this.loadingInstance) {
+      this.loadingInstance.close();
+      this.loadingInstance = null;
+    }
+  },
   methods: {
     goBackBookList() {
       this.$router.push({ path: '/personal_workbench/edit_task' });
@@ -71,6 +106,152 @@ export default {
         })
         .catch(() => {});
     },
+    /**
+     * 选择保存离线包的目录
+     */
+    async selectDirectory() {
+      const result = await window.fileAPI.openFileDialog({
+        title: '选择保存离线包的文件夹',
+        properties: ['openDirectory', 'createDirectory'],
+      });
+      if (result.canceled) {
+        this.$message.warning('未选择文件夹,操作已取消');
+        return;
+      }
+      this.savePath = result.filePaths[0];
+      this.downloadOfflinePackage();
+    },
+
+    /**
+     * 下载离线包
+     */
+    async downloadOfflinePackage() {
+      const { file_info_list, courseware_id_list } = await CreateBookChapterStructFileList({
+        book_id: this.project_id,
+      });
+
+      this.file_info_list = file_info_list;
+      if (file_info_list.length === 0) {
+        this.$message.error('章节结构文件生成失败,无法下载离线包');
+        return;
+      }
+
+      if (this.tempDir.length === 0) {
+        this.tempDir = window.fileAPI.createTempDir(); // 创建临时保存目录
+      }
+
+      this.loadingInstance = this.$loading({
+        lock: true,
+        text: '正在下载离线包,请稍候...',
+        spinner: 'el-icon-loading',
+        background: 'rgba(255, 255, 255, 0.7)',
+      });
+
+      for (const { dir_name, file_name, file_url } of this.file_info_list) {
+        const dirPath = dir_name.length > 0 ? `${this.tempDir}\\${dir_name}` : this.tempDir;
+        await window.fileAPI.downloadFile(file_url, `${dirPath}\\${file_name}`);
+      }
+
+      const struct = await this.readFileContent('struct.json'); // 读取章节结构文件内容
+
+      if (struct.node_list && struct.node_list.length > 0) {
+        this.packageName = struct.node_list[0].name; // 设置离线包名称为课件名称
+      }
+
+      if (courseware_id_list && courseware_id_list.length > 0) {
+        this.downloadWatcher = this.$watch(
+          'downloadProgress',
+          (newVal) => {
+            if (newVal >= 100) {
+              this.downloadWatcher();
+              this.downloadWatcher = null;
+              this.startCompress();
+            } else {
+              // 显示下载进度,预留 10% 的压缩进度
+              this.loadingInstance.text = `正在下载离线包,进度:${((this.downloadCompleted / (this.downloadTotal + this.downloadTotal * 0.1)) * 100).toFixed(2)}%`;
+            }
+          },
+          { immediate: true },
+        );
+
+        Promise.all(courseware_id_list.map((courseware_id) => this.createCoursewareFileList(courseware_id)));
+      }
+    },
+
+    /**
+     * 下载课件文件列表
+     * @param {Number} courseware_id 课件ID
+     * @return {Promise} Promise对象
+     */
+    async createCoursewareFileList(courseware_id) {
+      const { file_info_list } = await CreateCoursewareFileList({ courseware_id });
+      this.downloadTotal += file_info_list.length;
+
+      file_info_list.forEach((fileInfo) => {
+        const { file_name, file_url, dir_name } = fileInfo;
+        window.fileAPI.downloadFile(file_url, `${this.tempDir}\\${dir_name}\\${file_name}`).then(() => {
+          this.downloadCompleted += 1;
+        });
+      });
+    },
+
+    /**
+     * 开始压缩
+     */
+    async startCompress() {
+      const sources = [`${this.tempDir}\\resource`, `${this.tempDir}\\courseware`]; // 要压缩的文件或文件夹路径数组
+      this.file_info_list.forEach(({ dir_name, file_name }) => {
+        const dirPath = dir_name.length > 0 ? `${this.tempDir}\\${dir_name}` : this.tempDir;
+        sources.push(`${dirPath}\\${file_name}`);
+      });
+      const dest = `${this.savePath}\\${this.packageName}.eep`; // 压缩包保存路径
+      try {
+        const offErr = window.fileAPI.onStderr((text) => console.warn('7z stderr:', text));
+
+        await window.fileAPI.compress({
+          sources,
+          dest,
+          format: 'zip',
+          level: 5,
+          password: '1234567a',
+          recurse: true,
+        });
+        offErr();
+      } catch (e) {
+        console.error('压缩失败:', e);
+        this.$message.error('离线包下载失败,请重试!');
+        this.loadingInstance.close();
+        this.loadingInstance = null;
+        return;
+      }
+
+      this.loadingInstance.text = `正在下载离线包,进度:100%`;
+
+      // 删除临时目录及其内容
+      try {
+        await window.fileAPI.deleteTempDir(this.tempDir);
+        this.tempDir = '';
+      } catch (e) {
+        console.error('删除临时目录失败:', e);
+      }
+
+      this.loadingInstance.close();
+      this.loadingInstance = null;
+
+      this.$message.success('离线包下载并保存成功!');
+    },
+
+    /**
+     * 读取文件内容
+     * @param entryName 文件条目名称
+     * @param subdirectory 子目录
+     */
+    async readFileContent(entryName, subdirectory = '') {
+      const content = await window.fileAPI.readZipFileSync(`${this.tempDir}/${subdirectory}`, entryName);
+      const text = new TextDecoder().decode(content);
+      const obj = JSON.parse(text);
+      return obj;
+    },
   },
 };
 </script>