فهرست منبع

填空题优化

dsy 3 هفته پیش
والد
کامیت
16390b9b74

+ 1 - 1
.env

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

+ 1 - 1
packages/package.json

@@ -1,6 +1,6 @@
 {
   "name": "eep-ui",
-  "version": "0.0.53",
+  "version": "0.0.54",
   "main": "eep/eep-ui.umd.js",
   "style": "eep/eep-ui.css",
   "exports": {

+ 6 - 0
src/components/CommonPreview.vue

@@ -972,6 +972,11 @@ export default {
           });
 
           if (content_group_row_list) this.content_group_row_list = JSON.parse(content_group_row_list) || [];
+
+          // 保留未缩放的原始组件数据,字号缩放时都从原始数据重新派生,避免多次缩放叠加
+          this.raw_component_list = this.component_list;
+          this.applyFontScale();
+
           this.getCoursewareAuditRemarkList(id, requestConfig);
         })
         .catch((error) => {
@@ -1475,6 +1480,7 @@ export default {
       this.selectNode(nodeId);
       this.isShowGroup = false;
       this.groupShowAll = true;
+      this.fontScalePercent = 100;
       this.$refs.courseware?.clearRowCheckList();
     },
     /**

+ 69 - 23
src/views/book/courseware/create/components/CreateCanvas.vue

@@ -646,6 +646,10 @@ export default {
           await Promise.all(comps.map((item) => item.saveCoursewareComponentContent()));
         }
 
+        // 组件内容已按组件自身 id 保存到当前课件,清理仅用于从模板课件取内容的临时标记 oldId,
+        // 否则该课件之后再次被当作模板使用时组件会按陈旧的 oldId 请求而取到空内容
+        this.clearGridOldId();
+
         // 再收集并保存页面结构
         let component_id_list = this.data.row_list.flatMap((row) =>
           row.col_list.flatMap((col) => col.grid_list.map((grid) => grid.id)),
@@ -1579,7 +1583,7 @@ export default {
 
     // 获取子组件
     findChildComponentByKey(key) {
-      return this.$refs.component.find((child) => child.$vnode.key === key);
+      return (this.$refs.component || []).find((child) => child.$vnode.key === key);
     },
 
     /**
@@ -1768,11 +1772,15 @@ export default {
      * @param {Array} param.backgroundList - 组件背景列表
      */
     async loadTemplateData_CreateCanvas({ data, content_group_row_list, backgroundList }) {
-      this.data = data || {
-        row_list: [],
-      };
-      this.content_group_row_list = content_group_row_list || [];
-      await this.insertComponentBackground(data.row_list, backgroundList, 'id');
+      // 深拷贝:避免与「选择模板」弹窗缓存的模板数据共享引用(否则画布改动会污染模板数据)
+      const templateData = _.cloneDeep(data) || { row_list: [] };
+      if (!Array.isArray(templateData.row_list)) templateData.row_list = [];
+      this.data = templateData;
+      // 分组行列表同样深拷贝:归一化时会直接改写其中的 is_pre_same_group
+      this.content_group_row_list = _.cloneDeep(content_group_row_list) || [];
+      // 清空模板数据中残留的 oldId,避免按陈旧的来源组件 id 取到错误内容
+      this.clearGridOldId(this.data.row_list);
+      await this.insertComponentBackground(this.data.row_list, backgroundList, 'id');
     },
 
     /**
@@ -1784,22 +1792,29 @@ export default {
      */
     async insertTemplateData_CreateCanvas({ row_list = [], content_group_row_list = [], backgroundList = [] }) {
       let contentGroupRowList = JSON.parse(JSON.stringify(content_group_row_list));
+      // 插入的内容保持独立分组,不并入插入位置上方的分组(模板数据异常时兜底)
+      if (contentGroupRowList.length > 0) contentGroupRowList[0].is_pre_same_group = false;
 
-      let rowList = row_list.map((row) => {
+      // 深拷贝后再改写 id:直接改写会污染「选择模板」弹窗缓存的模板数据,
+      // 导致同一模板连续插入两次时把同一批对象重复放进 row_list(重复 key / 重复组件 id / oldId 被覆盖)
+      let rowList = _.cloneDeep(row_list).map((row) => {
         const oldRowID = row.row_id;
         const newRowID = `R${getRandomNumber(6, true)}`;
         row.row_id = newRowID;
         // 更新内容分组行列表中的 row_id
-        for (let idx = 0; idx < contentGroupRowList.length; idx++) {
-          if (contentGroupRowList[idx].row_id === oldRowID) {
-            contentGroupRowList[idx] = { ...contentGroupRowList[idx], row_id: newRowID };
+        if (oldRowID) {
+          for (let idx = 0; idx < contentGroupRowList.length; idx++) {
+            if (contentGroupRowList[idx].row_id === oldRowID) {
+              contentGroupRowList[idx] = { ...contentGroupRowList[idx], row_id: newRowID };
+            }
           }
         }
 
-        row.col_list = row.col_list.map((col) => {
+        row.col_list = (row.col_list || []).map((col) => {
           col.col_id = `C${getRandomNumber(8, true)}`;
 
-          col.grid_list = col.grid_list.map((grid) => {
+          col.grid_list = (col.grid_list || []).map((grid) => {
+            // oldId 用于子组件从模板课件取组件内容(temporaryCoursewareID + oldId)
             grid.oldId = grid.id;
             grid.id = this.generateUniqueGridId();
             grid.grid_area = `L${getRandomNumber(6, true)}`;
@@ -1812,22 +1827,52 @@ export default {
       });
 
       const curId = this.getCurSettingId();
-      // 如果无选中组件,则插入到最后
-      if (!curId) {
+      if (curId) {
+        // 插入到当前选中组件后面
+        const attrs = this.findChildComponentByKey(`grid-${curId}`)?.$attrs;
+        if (!attrs) return;
+        const i = Number(attrs['data-row']);
+        this.data.row_list.splice(i + 1, 0, ...rowList);
+        this.content_group_row_list.splice(i + 1, 0, ...contentGroupRowList);
+        // 断开插入内容之后那一行的分组,避免模板内容被并入原有分组(与「插入行」的处理保持一致)
+        this.breakGroupFrom(i + 1 + contentGroupRowList.length);
+      } else {
+        // 无选中组件,插入到最后
         this.data.row_list.push(...rowList);
         this.content_group_row_list.push(...contentGroupRowList);
-        await this.insertComponentBackground(rowList, backgroundList);
-        return;
       }
-      // 插入到当前选中组件后面
-      const attrs = this.findChildComponentByKey(`grid-${curId}`)?.$attrs;
-      if (!attrs) return;
-      const i = Number(attrs['data-row']);
-      this.data.row_list.splice(i + 1, 0, ...rowList);
-      this.content_group_row_list.splice(i + 1, 0, ...contentGroupRowList);
+
       await this.insertComponentBackground(rowList, backgroundList);
     },
     /**
+     * 清除组件上仅用于「从模板课件取内容」的临时标记 oldId
+     * 保存时组件内容已按组件自身 id 写入当前课件,若继续保留 oldId,
+     * 该课件之后再次被当作模板使用时组件会按陈旧的 oldId 请求而取到空内容
+     * @param {Array} row_list - 行列表,默认当前画布的行列表
+     */
+    clearGridOldId(row_list = this.data.row_list) {
+      (row_list || []).forEach((row) => {
+        (row.col_list || []).forEach((col) => {
+          (col.grid_list || []).forEach((grid) => {
+            delete grid.oldId;
+          });
+        });
+      });
+    },
+    /**
+     * 断开指定下标行与其上一行的分组关系(分组线、边框颜色、保存时的分组都会随之更新)
+     * @param {number} startIndex - 起始下标
+     */
+    breakGroupFrom(startIndex) {
+      for (let i = startIndex; i >= 0 && i < this.content_group_row_list.length; i++) {
+        if (this.content_group_row_list[i].is_pre_same_group) {
+          this.content_group_row_list[i].is_pre_same_group = false;
+        } else {
+          break;
+        }
+      }
+    },
+    /**
      * 插入组件背景
      * @param {Array} row_list - 行列表
      * @param {Array} backgroundList - 组件背景列表
@@ -1843,7 +1888,8 @@ export default {
             const bg = background?.background;
 
             if (bg) {
-              this.findChildComponentByKey(`grid-${grid.id}`).background = bg;
+              const component = this.findChildComponentByKey(`grid-${grid.id}`);
+              if (component) component.background = bg;
             }
           });
         });

+ 6 - 15
src/views/book/courseware/create/components/question/fill/Fill.vue

@@ -131,7 +131,7 @@ import AddWord from './components/AddWord.vue';
 import WordProofread from '@/views/book/courseware/create/components/common/WordProofread.vue';
 import { buildWordProofreadData } from '@/views/book/courseware/create/components/common/wordProofread';
 
-import { getFillData, arrangeTypeList, fillTypeList } from '@/views/book/courseware/data/fill';
+import { getFillData, arrangeTypeList, fillTypeList, groupModelEssay } from '@/views/book/courseware/data/fill';
 import { addTone, handleToneValue, mergePunctuationToWords } from '@/views/book/courseware/data/common';
 import { getRandomNumber } from '@/utils';
 import { TextToAudioFile } from '@/api/app';
@@ -174,23 +174,14 @@ export default {
       };
     },
     /**
-     * 按段落对齐方式对 model_essay 分组,便于整体应用 text-align,
-     * 让同一段内的文字块与填空连续排列(可换行),而不是每块独占一行。
+     * 按「段落 + 对齐方式」对 model_essay 分组,便于整体应用 text-align,
+     * 让同一段内的文字块与填空连续排列(可换行),而不是每块独占一行;
+     * 段落之间必须断开,否则相邻的同对齐方式段落会共用同一个块级容器(同一个文本流),
+     * 被排成一行(例如连续的多个居中段落)。
      * @returns {Array} [{ align, items, indexes }]
      */
     groupedModelEssay() {
-      const groups = [];
-      (this.data.model_essay || []).forEach((item, index) => {
-        const align = item.align || '';
-        const last = groups[groups.length - 1];
-        if (last && last.align === align) {
-          last.items.push(item);
-          last.indexes.push(index);
-        } else {
-          groups.push({ align, items: [item], indexes: [index] });
-        }
-      });
-      return groups;
+      return groupModelEssay(this.data.model_essay);
     },
   },
   watch: {

+ 37 - 0
src/views/book/courseware/data/fill.js

@@ -65,6 +65,43 @@ export function getFillProperty() {
   };
 }
 
+// 块级开标签(标志着一个新段落的开始,如 <p>、<div>、<h1>)
+const PARAGRAPH_START_TAG_REGEX = /^\s*<(p|div|h[1-6]|li|blockquote)\b/i;
+
+/**
+ * 判断文本块是否为段落的起始块。
+ * 解析富文本时,段落起始的块级开标签(如 <p style="text-align: center;">)会被写在块的 content 头部,
+ * 因此可据此在渲染阶段还原段落边界。
+ * @param {Object} item model_essay 中的块
+ * @returns {Boolean} 是否为段落起始块
+ */
+export function isParagraphStartBlock(item = {}) {
+  return PARAGRAPH_START_TAG_REGEX.test(String(item.content || ''));
+}
+
+/**
+ * 按「段落 + 对齐方式」对 model_essay 分组。
+ * 同一段内的文字块与填空需连续排列(允许换行),因此合并到同一个容器;
+ * 段落之间必须断开:同对齐方式的相邻段落若合并,会共用同一个块级容器(同一个文本流),
+ * 从而被排成一行(例如连续的多个居中段落)。
+ * @param {Array} modelEssay model_essay 数组
+ * @returns {Array} [{ align, items, indexes }]
+ */
+export function groupModelEssay(modelEssay) {
+  const groups = [];
+  (modelEssay || []).forEach((item, index) => {
+    const align = item.align || '';
+    const last = groups[groups.length - 1];
+    if (last && last.align === align && !isParagraphStartBlock(item)) {
+      last.items.push(item);
+      last.indexes.push(index);
+      return;
+    }
+    groups.push({ align, items: [item], indexes: [index] });
+  });
+  return groups;
+}
+
 export function getFillData() {
   return {
     type: 'fill',

+ 1 - 1
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -740,7 +740,7 @@ export default {
           positionAtPoint ? this.menuPosition.endY - positionAtPoint.y : this.menuPosition.endY,
           this.componentId,
           text,
-          positionAtPoint ? true : false,
+          Boolean(positionAtPoint),
         );
       }, 10);
     },

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

@@ -187,7 +187,13 @@ import SoundRecord from '../../common/SoundRecord.vue';
 import SoundRecordBox from '@/views/book/courseware/preview/components/record_input/SoundRecord.vue';
 import WriteDialog from './components/WriteDialog.vue';
 
-import { getFillData, fillTypeList, arrangeTypeList, audioPositionList } from '@/views/book/courseware/data/fill';
+import {
+  getFillData,
+  fillTypeList,
+  arrangeTypeList,
+  audioPositionList,
+  groupModelEssay,
+} from '@/views/book/courseware/data/fill';
 
 export default {
   name: 'FillPreview',
@@ -237,23 +243,14 @@ export default {
       return this.hasFixedAnswer || (this.data.answer_list || []).length > 0;
     },
     /**
-     * 按段落对齐方式对 modelEssay 分组,便于整体应用 text-align,
-     * 让同一段内的文字块与填空连续排列(可换行),而不是每块独占一行。
+     * 按「段落 + 对齐方式」对 modelEssay 分组,便于整体应用 text-align,
+     * 让同一段内的文字块与填空连续排列(可换行),而不是每块独占一行;
+     * 段落之间必须断开,否则相邻的同对齐方式段落会共用同一个块级容器(同一个文本流),
+     * 被排成一行(例如连续的多个居中段落)。
      * @returns {Array} [{ align, items, indexes }]
      */
     groupedModelEssay() {
-      const groups = [];
-      (this.modelEssay || []).forEach((item, index) => {
-        const align = item.align || '';
-        const last = groups[groups.length - 1];
-        if (last && last.align === align) {
-          last.items.push(item);
-          last.indexes.push(index);
-        } else {
-          groups.push({ align, items: [item], indexes: [index] });
-        }
-      });
-      return groups;
+      return groupModelEssay(this.modelEssay);
     },
   },
   watch: {

+ 63 - 21
src/views/personal_workbench/edit_task/edit/UseTemplate.vue

@@ -92,6 +92,40 @@ import { isTrue } from '@/utils/validate';
 import PaginationPage from '@/components/PaginationPage.vue';
 import CoursewarePreview from '@/views/book/courseware/preview/CoursewarePreview.vue';
 
+/**
+ * 生成空的模板数据(用于初始化与清空选择)
+ * @returns {Object} 模板数据
+ */
+function createEmptyTemplateData() {
+  return {
+    background_image_url: '',
+    background_position: {
+      width: 100,
+      height: 100,
+      top: 0,
+      left: 0,
+    },
+    // 组件列表
+    row_list: [],
+    // 教材样式设置
+    unified_attrib,
+  };
+}
+
+/**
+ * 生成空的模板背景(用于初始化与清空选择)
+ * @returns {Object} 模板背景
+ */
+function createEmptyBackground() {
+  return {
+    background_image_url: '',
+    background_position: {
+      left: 0,
+      top: 0,
+    },
+  };
+}
+
 export default {
   name: 'UseTemplate',
   components: {
@@ -125,30 +159,12 @@ export default {
         { label: '机构库', value: 1 },
       ],
       coursewareData: { row_list: [] },
-      background: {
-        background_image_url: '',
-        background_position: {
-          left: 0,
-          top: 0,
-        },
-      },
+      background: createEmptyBackground(),
       component_list: [],
       content_group_row_list: [],
       curSelectId: '',
       curType: 0,
-      data: {
-        background_image_url: '',
-        background_position: {
-          width: 100,
-          height: 100,
-          top: 0,
-          left: 0,
-        },
-        // 组件列表
-        row_list: [],
-        // 教材样式设置
-        unified_attrib,
-      },
+      data: createEmptyTemplateData(),
       data_content_group_row_list: [],
       title_list: [],
       permissionControl: {
@@ -202,7 +218,18 @@ export default {
     dialogClose() {
       this.$emit('update:visible', false);
     },
-    selectCourseware({ type, courseware_id, id }) {
+    /**
+     * 选择模板(课件或章节)
+     * el-table 在列表数据刷新(搜索、翻页、切换库)后,会以 null 触发 current-change,
+     * 此时必须清空已选中的模板,否则界面已无高亮行却仍能插入/替换上一次的模板
+     * @param {Object|null} row - 模板行数据
+     */
+    selectCourseware(row) {
+      if (!row) {
+        this.resetSelectedTemplate();
+        return;
+      }
+      const { type, courseware_id, id } = row;
       this.curType = type;
       if (type === 0) {
         this.curSelectId = courseware_id;
@@ -214,6 +241,21 @@ export default {
         });
       }
     },
+    /**
+     * 清空已选中的模板及其预览、数据
+     */
+    resetSelectedTemplate() {
+      this.curSelectId = '';
+      this.curType = 0;
+      this.node_list = [];
+      this.coursewareData = { row_list: [] };
+      this.component_list = [];
+      this.content_group_row_list = [];
+      this.title_list = [];
+      this.background = createEmptyBackground();
+      this.data = createEmptyTemplateData();
+      this.data_content_group_row_list = [];
+    },
     searchTemplateList() {
       this.$refs.pagination.getList();
     },