Procházet zdrojové kódy

序号显示修改

dsy před 3 dny
rodič
revize
587ca81b28

+ 1 - 1
.env

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

+ 60 - 3
main.js

@@ -260,6 +260,28 @@ ipcMain.handle('compress-with-7z', async (evt, opts) => {
 });
 
 /**
+ * 获取指定路径所在磁盘的剩余可用空间(字节)
+ * @param {string} targetPath 目标路径(文件或目录)
+ * @returns {number} 剩余可用字节数,获取失败返回 -1
+ */
+function getFreeSpace(targetPath) {
+  try {
+    let p = path.resolve(targetPath);
+    // 若路径尚不存在(如待创建的文件),向上回溯到已存在的目录或盘符根
+    while (!fs.existsSync(p)) {
+      const parent = path.dirname(p);
+      if (parent === p) break;
+      p = parent;
+    }
+    const stats = fs.statfsSync(p);
+    return Number(stats.bavail) * Number(stats.bsize);
+  } catch (e) {
+    console.error('getFreeSpace failed:', e);
+    return -1;
+  }
+}
+
+/**
  * 下载文件
  * @param {string} url 文件 URL
  * @param {string} destPath 保存路径
@@ -267,11 +289,37 @@ ipcMain.handle('compress-with-7z', async (evt, opts) => {
 ipcMain.handle('download-file', async (evt, { url, destPath }) => {
   return new Promise((resolve, reject) => {
     const fileStream = fs.createWriteStream(destPath); // 创建写入流
+    let rejected = false;
+    const rejectOnce = (err) => {
+      if (rejected) return;
+      rejected = true;
+      // 下载失败时清理可能残留的半截文件
+      try {
+        fileStream.destroy();
+        if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
+      } catch (e) {
+        // ignore
+      }
+      reject(err);
+    };
+
     const req = net.request(url);
     req.on('response', (res) => {
       const contentLength = Number(res.headers['content-length'] || '0');
       let bytesReceived = 0;
 
+      // 下载前预判:磁盘剩余空间不足以存放该文件时提前中止
+      if (contentLength > 0) {
+        const free = getFreeSpace(destPath);
+        if (free >= 0 && contentLength * 1.05 > free) {
+          const err = new Error('磁盘空间不足,无法下载');
+          err.code = 'ENOSPC';
+          req.abort();
+          rejectOnce(err);
+          return;
+        }
+      }
+
       res.on('data', (chunk) => {
         fileStream.write(chunk);
         bytesReceived += chunk.length;
@@ -300,16 +348,16 @@ ipcMain.handle('download-file', async (evt, { url, destPath }) => {
       });
 
       res.on('aborted', () => {
-        reject(new Error('response aborted'));
+        rejectOnce(new Error('response aborted'));
       });
     });
 
     req.on('error', (err) => {
-      reject(err);
+      rejectOnce(err);
     });
 
     fileStream.on('error', (err) => {
-      reject(err);
+      rejectOnce(err);
     });
 
     req.end();
@@ -317,6 +365,15 @@ ipcMain.handle('download-file', async (evt, { url, destPath }) => {
 });
 
 /**
+ * 获取指定路径所在磁盘的剩余可用空间(字节)
+ * @param {string} targetPath 目标路径(文件或目录)
+ * @returns {Promise<number>} 剩余可用字节数,获取失败返回 -1
+ */
+ipcMain.handle('get-disk-free-space', async (evt, { targetPath }) => {
+  return getFreeSpace(targetPath);
+});
+
+/**
  * 打开文件对话框
  * @param {Object} opts 对话框选项
  * @param {string} [opts.title] 对话框标题

+ 7 - 0
preload.js

@@ -116,6 +116,13 @@ contextBridge.exposeInMainWorld('fileAPI', {
       fs.mkdirSync(dirPath, { recursive: true });
     }
   },
+
+  /**
+   * 获取指定路径所在磁盘的剩余可用空间(字节)
+   * @param {string} targetPath 目标路径
+   * @returns {Promise<number>} 剩余可用字节数
+   */
+  getDiskFreeSpace: (targetPath) => ipcRenderer.invoke('get-disk-free-space', { targetPath }),
 });
 
 /**

+ 10 - 29
src/views/book/courseware/create/components/question/fill/Fill.vue

@@ -86,12 +86,7 @@
         <div>
           <el-button @click="parsedContentPinyin()">识别</el-button>
           <el-button @click="openMultilingual">多语言</el-button>
-          <WordProofread
-            v-show="isEnable(data.property.view_pinyin)"
-            button-style="margin-left: 10px"
-            :word-data="wordData"
-            @save="saveWord"
-          />
+          <WordProofread button-style="margin-left: 10px" :word-data="wordData" @save="saveWord" />
         </div>
 
         <div v-if="data.answer.answer_list.length > 0" class="title">答案:</div>
@@ -552,35 +547,21 @@ export default {
         .replace(/\s+/g, ' ');
     },
     /**
-     * 构建文本块的样式前缀,包含过渡样式标签和当前仍应处于打开状态的样式标签
+     * 构建文本块的样式前缀,包含当前仍应处于打开状态的样式标签(外层→内层)
+     * 以及过渡标签中的换行等不影响样式栈的特殊项。
+     * 开标签已由 currentOpenStyleTags 完整覆盖,闭标签在独立渲染的块中无法
+     * 匹配到上一块的开标签,需丢弃,避免误关外层同名标签。
      * @param {Array} transitionStyleTags 过渡样式标签列表,可能包含开标签和闭标签
      * @param {Array} currentOpenStyleTags 当前仍应处于打开状态的样式标签列表
      * @returns {Array} 构建好的样式前缀列表
      */
     buildFirstBlockStylePrefix(transitionStyleTags = [], currentOpenStyleTags = []) {
-      const transitionOpenTagCount = transitionStyleTags
-        .filter((tagItem) => this.isOpenStyleTag(tagItem))
-        .reduce((counter, tagItem) => {
-          const identity = this.getOpenStyleTagIdentity(tagItem);
-          if (!identity) return counter;
-          counter[identity] = (counter[identity] || 0) + 1;
-          return counter;
-        }, {});
-
-      const missingOpenTags = currentOpenStyleTags.filter((tagItem) => {
-        const identity = this.getOpenStyleTagIdentity(tagItem);
-        if (!identity) return false;
-
-        if (transitionOpenTagCount[identity]) {
-          transitionOpenTagCount[identity] -= 1;
-          return false;
-        }
-
-        return true;
-      });
+      // 仅保留换行等不影响样式栈的特殊项(br 不入栈,但需要渲染换行)
+      const lineBreakTags = transitionStyleTags.filter((tagItem) =>
+        /^<br\s*\/?\s*>$/i.test(String(tagItem?.text || '').trim()),
+      );
 
-      // 先回放过渡标签(含可能的关闭标签),再补齐当前仍应处于打开状态的标签。
-      return [...transitionStyleTags, ...missingOpenTags];
+      return [...currentOpenStyleTags, ...lineBreakTags];
     },
     /**
      * 根据文本中的连续下划线分割文本块,并将下划线部分转换为输入块

+ 3 - 7
src/views/book/courseware/create/components/question/judge/Judge.vue

@@ -8,7 +8,7 @@
               v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
               class="serial-number"
             >
-              {{ computedOptionNumber(i) }}.
+              {{ computeOptionNumber(i, data.property.option_serial_type) }}
             </span>
             <div class="option-content">
               <RichText
@@ -104,7 +104,7 @@ import WordProofread from '@/views/book/courseware/create/components/common/Word
 import { buildWordProofreadData } from '@/views/book/courseware/create/components/common/wordProofread';
 
 import { getJudgeData, getOption, option_type_list, isEnable } from '@/views/book/courseware/data/judge';
-import { serialNumberTypeList, computeOptionMethods } from '@/views/book/courseware/data/common';
+import { computeOptionNumber } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'JudgePage',
@@ -122,6 +122,7 @@ export default {
       optionWordDataSourceMap: {},
       loading: false,
       inited: false,
+      computeOptionNumber,
     };
   },
   computed: {
@@ -239,11 +240,6 @@ export default {
       this.$set(this.optionWordDataMap, option.mark, buildWordProofreadData(parsed_text?.paragraph_list || []));
       this.$set(this.optionWordDataSourceMap, option.mark, option.content || '');
     },
-    computedOptionNumber(number) {
-      const type = serialNumberTypeList.find((item) => item.value === this.data.property.option_serial_type)?.value;
-      if (!type) return number + 1;
-      return computeOptionMethods[type](number);
-    },
     selectOptionAnswer(option_type, mark) {
       const index = this.data.answer.answer_list.findIndex((item) => item.mark === mark);
       if (index === -1) {

+ 3 - 2
src/views/book/courseware/create/components/question/matching/Matching.vue

@@ -5,7 +5,7 @@
         <ul class="option-list">
           <li v-for="(li, i) in data.option_list" :key="i" class="option-item">
             <div v-for="(item, j) in li" :key="item.mark" class="option">
-              <span class="serial-number">{{ computeOptionMethods[data.property.serial_number_type_list[j]](i) }}</span>
+              <span class="serial-number">{{ computeOptionNumber(i, data.property.serial_number_type_list[j]) }}</span>
               <span class="option-content">
                 <RichText
                   v-if="property.isGetContent"
@@ -119,7 +119,7 @@ import WordProofread from '@/views/book/courseware/create/components/common/Word
 import { buildWordProofreadData } from '@/views/book/courseware/create/components/common/wordProofread';
 
 import { getMatchingData, getOption, getOptionItem } from '@/views/book/courseware/data/matching';
-import { computeOptionMethods, serialNumberTypeList } from '@/views/book/courseware/data/common';
+import { computeOptionMethods, serialNumberTypeList, computeOptionNumber } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'MatchingPage',
@@ -133,6 +133,7 @@ export default {
       data: getMatchingData(),
       computeOptionMethods,
       serialNumberTypeList,
+      computeOptionNumber,
       curSelectRow: -1,
       curSelectColumn: -1,
       optionWordDataMap: {},

+ 3 - 7
src/views/book/courseware/create/components/question/select/Select.vue

@@ -7,7 +7,7 @@
             <span
               v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
               class="serial-number"
-              >{{ computedOptionNumber(i) }}.</span
+              >{{ computeOptionNumber(i, data.property.option_serial_type) }}</span
             >
             <div class="option-contnet">
               <span
@@ -104,7 +104,7 @@ import WordProofread from '@/views/book/courseware/create/components/common/Word
 import { buildWordProofreadData } from '@/views/book/courseware/create/components/common/wordProofread';
 
 import { getSelectData, getOption, arrangeTypeList, selectTypeList } from '@/views/book/courseware/data/select';
-import { serialNumberTypeList, computeOptionMethods } from '@/views/book/courseware/data/common';
+import { computeOptionNumber } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'SelectPage',
@@ -121,6 +121,7 @@ export default {
       optionWordDataSourceMap: {},
       inited: false,
       loading: false,
+      computeOptionNumber,
     };
   },
   computed: {
@@ -230,11 +231,6 @@ export default {
       this.$set(this.optionWordDataMap, option.mark, buildWordProofreadData(parsed_text?.paragraph_list || []));
       this.$set(this.optionWordDataSourceMap, option.mark, option.content || '');
     },
-    computedOptionNumber(number) {
-      let type = serialNumberTypeList.find((item) => item.value === this.data.property.option_serial_type)?.value;
-      if (!type) return number + 1;
-      return computeOptionMethods[type](number);
-    },
     // 将数字转换为小写字母
     convertNumberToLetter(number) {
       return String.fromCharCode(97 + number);

+ 13 - 0
src/views/book/courseware/data/common.js

@@ -282,6 +282,19 @@ export const reversedComputeOptionMethods = {
   [serialNumberTypeList[3].value]: (i) => i.charCodeAt(0) - 65 + 1,
 };
 
+/**
+ * 计算选项的显示文本
+ * @param {number} number - 选项的索引
+ * @param {string} option_type - 序号类型
+ * @returns {string} - 计算后的选项文本
+ */
+export function computeOptionNumber(number, option_type) {
+  let type = serialNumberTypeList.find((item) => item.value === option_type)?.value;
+  if (!type) return number + 1;
+  const isBracket = type === serialNumberTypeList[1].value;
+  return `${computeOptionMethods[type](number)}${isBracket ? '' : '.'}`;
+}
+
 // 生成音频倍速
 export const speedRatioList = [
   { value: 0.5, label: '0.5' },

+ 4 - 9
src/views/book/courseware/preview/components/judge/JudgePreview.vue

@@ -18,7 +18,7 @@
               v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
               class="serial-number"
             >
-              {{ computedOptionNumber(i) }}.
+              {{ computeOptionNumber(i, data.property.option_serial_type) }}
             </span>
             <PinyinText
               v-if="isEnable(data.property.view_pinyin)"
@@ -91,7 +91,7 @@
               v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
               class="serial-number"
             >
-              {{ computedOptionNumber(i) }}.
+              {{ computeOptionNumber(i, data.property.option_serial_type) }}
             </span>
             <PinyinText
               v-if="isEnable(data.property.view_pinyin)"
@@ -137,7 +137,7 @@
 import PreviewMixin from '../common/PreviewMixin';
 
 import { getJudgeData, option_type_list, isEnable } from '@/views/book/courseware/data/judge';
-import { serialNumberTypeList, computeOptionMethods } from '@/views/book/courseware/data/common';
+import { computeOptionNumber } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'JudgePreview',
@@ -147,6 +147,7 @@ export default {
       data: getJudgeData(),
       option_type_list,
       isEnable,
+      computeOptionNumber,
     };
   },
   computed: {
@@ -180,12 +181,6 @@ export default {
     },
   },
   methods: {
-    computedOptionNumber(number) {
-      let type = serialNumberTypeList.find((item) => item.value === this.data.property.option_serial_type)?.value;
-      if (!type) return number + 1;
-      return computeOptionMethods[type](number);
-    },
-
     isAnswer(mark, option_type) {
       return this.answer.answer_list.some((li) => li.mark === mark && li.option_type === option_type);
     },

+ 8 - 13
src/views/book/courseware/preview/components/select/SelectPreview.vue

@@ -22,7 +22,7 @@
             v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
             class="serial-number"
           >
-            {{ computedOptionNumber(i) }}.
+            {{ computeOptionNumber(i, data.property.option_serial_type) }}
           </span>
           <PinyinText
             v-if="isEnable(data.property.view_pinyin)"
@@ -79,7 +79,7 @@
             v-if="!('enable_serial' in data.property) || isEnable(data.property.enable_serial)"
             class="serial-number"
           >
-            {{ computedOptionNumber(i) }}.
+            {{ computeOptionNumber(i, data.property.option_serial_type) }}
           </span>
           <PinyinText
             v-if="isEnable(data.property.view_pinyin)"
@@ -109,7 +109,7 @@
 import PreviewMixin from '../common/PreviewMixin';
 
 import { getSelectData, arrangeTypeList, selectTypeList } from '@/views/book/courseware/data/select';
-import { serialNumberTypeList, computeOptionMethods } from '@/views/book/courseware/data/common';
+import { computeOptionNumber } from '@/views/book/courseware/data/common';
 
 export default {
   name: 'SelectPreview',
@@ -118,6 +118,7 @@ export default {
     return {
       data: getSelectData(),
       arrangeTypeList,
+      computeOptionNumber,
     };
   },
   computed: {
@@ -142,11 +143,6 @@ export default {
     },
   },
   methods: {
-    computedOptionNumber(number) {
-      let type = serialNumberTypeList.find((item) => item.value === this.data.property.option_serial_type)?.value;
-      if (!type) return number + 1;
-      return computeOptionMethods[type](number);
-    },
     /**
      * 判断选项是否被选中
      * @param {string} mark - 选项的标识
@@ -316,20 +312,19 @@ export default {
           font-size: 14px;
           color: $right-color;
           white-space: nowrap;
-          content: '正确答案';
+          content: '正确';
         }
       }
 
       &.wrong {
         margin: 1px 0;
-        background-color: $content-color;
-        box-shadow: 0 0 0 1px $error-color;
+        background-color: #ffd4d9;
 
         &::after {
           font-size: 14px;
-          color: #a09fa6;
+          color: #ff455e;
           white-space: nowrap;
-          content: '已选';
+          content: '错误';
         }
       }
     }

+ 2 - 2
src/views/book/courseware/preview/components/voice_matrix/VoiceMatrixPreview.vue

@@ -38,13 +38,13 @@
       <!-- 语音矩阵 -->
       <div
         class="voice-matrix-container"
-        :style="{ height: isEnable(data.property.is_enable_record) ? 'calc(100% - 115px)' : 'auto' }"
+        :style="{ height: isEnable(data.property.is_enable_record) ? 'calc(100% - 135px)' : 'auto' }"
       >
         <div
           v-if="data.option_list.length > 0"
           class="matrix"
           :style="{
-            'grid-template': `36px repeat(${data.option_list.length}, auto) minmax(36px, 1fr) / 36px repeat(${data.option_list[0].length}, auto) minmax(36px, 1fr)`,
+            'grid-template': `36px repeat(${data.option_list.length}, auto) minmax(50px, 1fr) / 36px repeat(${data.option_list[0].length}, auto) minmax(36px, 1fr)`,
           }"
           @mouseleave="clearSelectCell"
         >

+ 107 - 12
src/views/project_manage/org/offlinepackauth/index.vue

@@ -173,10 +173,7 @@ export default {
       },
       rules: {
         book_name: [{ required: true, message: '请选择教材', trigger: 'blur' }],
-        effective_count: [
-          { required: true, message: '请填写有效次数', trigger: 'blur' },
-          { type: 'number', message: '有效次数必须为数字值' },
-        ],
+        effective_count: [{ type: 'number', message: '有效次数必须为数字值' }],
         effective_end_date: [{ required: true, message: '请选择有效截止日期', trigger: 'blur' }],
       },
       tempDir: '', // 临时目录,用于存放下载的文件
@@ -258,6 +255,9 @@ export default {
       this.$refs[formName].validate((valid) => {
         if (valid) {
           let data = this.editForm;
+          if (data.effective_count === undefined || data.effective_count === null) {
+            data.effective_count = 99999;
+          }
           this.dialogSearchBook = false;
           AddBookOfflinePackAuth(data).then((res) => {
             if (res && res.status === 1) {
@@ -330,15 +330,30 @@ export default {
         return;
       }
 
+      // 获取课件名称(用于命名保存目录)
+      await this.resolvePackageName(file_info_list);
+
+      // 在用户选择的保存目录下,新建以课件名称命名的目录作为工作目录
       if (this.tempDir.length === 0) {
-        this.tempDir = window.fileAPI.createTempDir(); // 创建临时保存目录
+        this.tempDir = `${this.savePath}\\${this.packageName}`;
+        // 若目录已存在(如上次导出失败残留),先清理再重建
+        if (window.fileAPI.existsSync(this.tempDir)) {
+          window.fileAPI.deleteTempDir(this.tempDir);
+        }
+        window.fileAPI.mkdirSync(`${this.tempDir}\\resource`);
+        window.fileAPI.mkdirSync(`${this.tempDir}\\courseware`);
       }
 
       this.visible = true;
 
       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}`);
+        try {
+          await window.fileAPI.downloadFile(file_url, `${dirPath}\\${file_name}`);
+        } catch (e) {
+          this.handleDownloadError(e);
+          return;
+        }
       }
 
       const struct = await this.readFileContent('struct.json'); // 读取章节结构文件内容
@@ -371,6 +386,75 @@ export default {
     },
 
     /**
+     * 解析并设置离线包课件名称
+     * 先下载 struct.json 到系统临时目录读取课件名称,再删除临时目录
+     * @param {Array} file_info_list 章节结构文件列表
+     */
+    async resolvePackageName(file_info_list) {
+      const structFile = file_info_list.find((fileInfo) => fileInfo.file_name === 'struct.json');
+      if (!structFile) return;
+
+      const tmpDir = window.fileAPI.createTempDir('eep-struct-');
+      try {
+        await window.fileAPI.downloadFile(structFile.file_url, `${tmpDir}\\${structFile.file_name}`);
+        const content = window.fileAPI.readZipFileSync(tmpDir, 'struct.json');
+        const text = new TextDecoder().decode(content);
+        const struct = JSON.parse(text);
+        if (struct.node_list && struct.node_list.length > 0) {
+          this.packageName = struct.node_list[0].name;
+        }
+      } catch (e) {
+        console.error('读取课件名称失败:', e);
+      } finally {
+        try {
+          window.fileAPI.deleteTempDir(tmpDir);
+        } catch (e) {
+          console.error('删除临时目录失败:', e);
+        }
+      }
+    },
+
+    /**
+     * 是否为磁盘空间不足错误
+     * @param {Error} e 错误对象
+     * @return {boolean}
+     */
+    isNoSpaceError(e) {
+      if (!e) return false;
+      if (e.code === 'ENOSPC') return true;
+      return /空间不足|ENOSPC|no space|disk full/i.test(e.message || '');
+    },
+
+    /**
+     * 处理下载失败(含磁盘空间不足)
+     * @param {Error} e 错误对象
+     */
+    handleDownloadError(e) {
+      if (this.isNoSpaceError(e)) {
+        this.$message.error('磁盘空间不足,请清理磁盘空间后重试!');
+      } else {
+        console.error('下载失败:', e);
+        this.$message.error('离线包下载失败,请重试!');
+      }
+      this.cleanupWorkDir();
+      this.visible = false;
+    },
+
+    /**
+     * 删除工作目录并置空 tempDir
+     */
+    cleanupWorkDir() {
+      if (this.tempDir && this.tempDir.length > 0) {
+        try {
+          window.fileAPI.deleteTempDir(this.tempDir);
+        } catch (e) {
+          console.error('删除工作目录失败:', e);
+        }
+        this.tempDir = '';
+      }
+    },
+
+    /**
      * 下载课件文件列表
      * @param {Number} courseware_id 课件ID
      * @return {Promise} Promise对象
@@ -388,9 +472,14 @@ export default {
           }
         }
 
-        window.fileAPI.downloadFile(file_url, `${this.tempDir}\\${dir_name}\\${file_name}`).then(() => {
-          this.downloadCompleted += 1;
-        });
+        window.fileAPI
+          .downloadFile(file_url, `${this.tempDir}\\${dir_name}\\${file_name}`)
+          .then(() => {
+            this.downloadCompleted += 1;
+          })
+          .catch((e) => {
+            this.handleDownloadError(e);
+          });
       });
     },
 
@@ -421,8 +510,13 @@ export default {
         offErr();
       } catch (e) {
         console.error('压缩失败:', e);
-        this.$message.error('离线包下载失败,请重试!');
-        this.tempDir = '';
+        if (this.isNoSpaceError(e)) {
+          this.$message.error('磁盘空间不足,请清理磁盘空间后重试!');
+        } else {
+          this.$message.error('离线包下载失败,请重试!');
+        }
+        // 删除工作目录,避免在保存目录下残留中间文件
+        this.cleanupWorkDir();
         this.visible = false;
         return;
       } finally {
@@ -432,7 +526,8 @@ export default {
 
       // 删除临时目录及其内容
       try {
-        await window.fileAPI.deleteTempDir(this.tempDir);
+        // 用于测试:压缩成功后保留工作目录
+        // await window.fileAPI.deleteTempDir(this.tempDir);
         this.tempDir = '';
       } catch (e) {
         console.error('删除临时目录失败:', e);