Bladeren bron

优化填空题

dsy 2 weken geleden
bovenliggende
commit
44b6ecfa6e

+ 1 - 1
.env

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

+ 1 - 1
packages/package.json

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

+ 6 - 2
src/common/data.js

@@ -1,3 +1,5 @@
+import { GetExtFontList } from '@/api/resource';
+
 /**
  * 字体列表(统一管理,供 el-select 下拉框和 TinyMCE font_formats 使用)
  * label: 显示名称
@@ -77,14 +79,16 @@ export function loadExtendedFonts(res) {
 }
 
 /**
- * 确保扩展字体已加载(惰性加载,多次调用只会请求一次)。
+ * 确保扩展字体已加载(多次调用只会请求一次)。
  * 同时支持网页端(HTTP 接口)和 Electron 端。
+ *
+ * 注意:这里使用静态导入而非动态 import(),避免库(lib)构建产物产生异步 chunk,
+ * 导致外部宿主页面按错误路径加载 eep-ui.umd.xxx.js 而报 ChunkLoadError。
  * @returns {Promise<void>}
  */
 export async function ensureExtendedFonts() {
   if (_extendedFontsLoaded) return;
   try {
-    const { GetExtFontList } = await import('@/api/resource');
     const res = await GetExtFontList();
     loadExtendedFonts(res);
     _extendedFontsLoaded = true;

+ 6 - 4
src/views/book/courseware/create/components/CreateCanvas.vue

@@ -798,7 +798,7 @@ export default {
 
       // 上下移动
       if (['top', 'bottom'].includes(type)) {
-        this.handleVerticalMove({ grid, offsetY, id, min_height, type });
+        this.handleVerticalMove({ grid, offsetY, id, min_height });
         return;
       }
 
@@ -827,7 +827,6 @@ export default {
       if (type === 'right' && j < row.col_list.length - 1) {
         this.handleRightMoveNotLastGrid(row, j, offsetX, min_width, row_width);
       }
-      this.$forceUpdate();
     },
 
     /**
@@ -838,7 +837,7 @@ export default {
      * @param {string} data.id 组件 id
      * @param {number} data.min_height 最小高度
      */
-    handleVerticalMove({ grid, offsetY, id, min_height = 0, type }) {
+    handleVerticalMove({ grid, offsetY, id, min_height = 0 }) {
       let height = 0;
       const _h = this.isEdit ? grid?.edit_height : grid.height;
 
@@ -854,7 +853,10 @@ export default {
         const gridHeight = Number(h?.replace('px', ''));
         height = gridHeight + offsetY;
       }
-      let minHeight = type === 'divider' ? 10 : min_height;
+      // 分隔线/间距等纯装饰组件自身没有 min_height,兜底为 10px,防止被拖拽到 0 而消失;
+      // 其余组件使用各自数据中定义的 min_height(未定义时保持原行为:可折叠到 0)
+      const thinComponentTypeList = ['divider', 'spacing'];
+      let minHeight = thinComponentTypeList.includes(grid?.type) ? 10 : min_height;
       // 当高度小于最小高度时,设置为最小高度
       height = Math.max(height, min_height, minHeight);
 

+ 151 - 50
src/views/book/courseware/create/components/PreviewEdit.vue

@@ -12,15 +12,17 @@
               v-for="(grid, k) in col.grid_list"
               :key="`grid-${i}-${j}-${k}`"
               :style="{ gridArea: grid.grid_area, height: grid.height }"
-              :class="[!noMoveComponent.includes(grid.type) ? 'grid' : 'no-grid']"
+              :class="[!noMoveComponent.includes(grid.type) ? 'grid' : 'no-grid', { active: curSelectId === grid.id }]"
+              @click="selectedComponent(grid.id)"
             >
+              <!-- 拖拽调整线:绝对定位覆盖在组件四边,作为纯覆盖层不占用布局空间,使组件内容盒尺寸与 CoursewarePreview 完全一致 -->
               <template v-for="{ type, cursor, lineClass } in moveLineList">
                 <span
                   v-if="!noMoveComponent.includes(grid.type)"
                   :key="`${type}-${i}-${j}-${k}`"
                   class="drag-line"
                   :class="[type, ...lineClass]"
-                  :style="{ gridArea: type, cursor }"
+                  :style="{ cursor }"
                   :data-type="type"
                   @mousedown="dragStart($event, { cursor, type, i, j, k, id: grid.id })"
                 ></span>
@@ -32,14 +34,8 @@
                 :key="`preview-${grid.id}`"
                 :courseware-id="coursewareId"
                 type="edit"
-                :class="[grid.id, { active: curSelectId === grid.id }]"
+                :class="['component', grid.id]"
                 :data-id="grid.id"
-                :style="{
-                  gridArea: 'preview',
-                  height: grid.height,
-                  overflow: 'auto',
-                }"
-                @click.native="selectedComponent(grid.id)"
                 @handleHeightChange="handleHeightChange"
               />
             </div>
@@ -148,6 +144,9 @@ export default {
         background: {},
       },
       curSelectId: '', // 当前选中组件id
+      previewLoading: null, // 预览加载层实例
+      loadingTimer: null, // 预览加载轮询定时器
+      loadingTimeout: null, // 预览加载兜底超时定时器
     };
   },
   created() {
@@ -167,27 +166,56 @@ export default {
   beforeDestroy() {
     document.removeEventListener('mousemove', this.dragMove);
     document.removeEventListener('mouseup', this.dragEnd);
+    // 清理预览加载的轮询/超时定时器与 loading 层,避免组件销毁后定时器仍持有实例引用
+    this.finishPreviewLoading();
     if (this.resizeObserver) {
       this.resizeObserver.disconnect();
     }
   },
   methods: {
     init() {
-      const loading = this.$loading({
+      this.previewLoading = this.$loading({
         lock: true,
         text: '预览组件加载中...',
         spinner: 'el-icon-loading',
       });
 
-      const timer = setInterval(() => {
-        let isLoader = this.rowList.length === 0 || this.$refs?.preview?.every((item) => item.loader);
-
-        if (isLoader) {
-          loading.close();
-          clearInterval(timer);
+      // 兜底超时:组件内容请求挂起时强制关闭加载层,避免永久停留
+      this.loadingTimeout = setTimeout(() => {
+        this.finishPreviewLoading();
+      }, 300000);
+
+      // 轮询检查所有预览组件是否加载完成。
+      // 之所以用轮询而非 computed/watch:$refs 在 Vue 2.6 中非响应式,无法直接监听子组件 loader 的变化;
+      // 该方式与编辑态 componentIsAllLoader 的轮询保持一致。
+      this.loadingTimer = setInterval(() => {
+        const previews = this.$refs?.preview;
+        // rowList 为空(空课件)时无需等待;previews 未就绪(尚未渲染)时继续等待
+        const isAllLoaded =
+          this.rowList.length === 0 || (Array.isArray(previews) ? previews.every((item) => item.loader) : false);
+
+        if (isAllLoaded) {
+          this.finishPreviewLoading();
         }
       }, 200);
     },
+    /**
+     * 结束预览加载:清理轮询定时器、兜底超时并关闭加载层(幂等)
+     */
+    finishPreviewLoading() {
+      if (this.loadingTimer) {
+        clearInterval(this.loadingTimer);
+        this.loadingTimer = null;
+      }
+      if (this.loadingTimeout) {
+        clearTimeout(this.loadingTimeout);
+        this.loadingTimeout = null;
+      }
+      if (this.previewLoading) {
+        this.previewLoading.close();
+        this.previewLoading = null;
+      }
+    },
     handleHeightChange(id, newHeight) {
       this.$emit('handleHeightChange', id, newHeight);
     },
@@ -277,15 +305,46 @@ export default {
       });
 
       // 计算 grid_template_rows
+      // - 单组件:auto 用 auto;px 用其值
+      // - 多组件:过滤 auto 后取最大 px,避免生成 max(auto, px) 非法 CSS
+      const toNumberHeight = (height) => {
+        const num = Number(String(height).replace('px', ''));
+        return Number.isFinite(num) ? num : NaN;
+      };
+
       let gridTemplateRows = '';
       sortedRows.forEach((row) => {
-        const heights = (rowGroups.get(row) || []).map((item) => item.height);
-        if (heights.length === 1) {
-          gridTemplateRows += `${heights[0]} `;
-        } else {
-          const isAllAuto = heights.every((item) => item === 'auto'); // 是否全是 auto
-          gridTemplateRows += isAllAuto ? 'auto ' : `max(${heights.join(', ')}) `;
+        const items = rowGroups.get(row) || [];
+        if (items.length === 1) {
+          const current = items[0];
+          if (current.height === 'auto') {
+            gridTemplateRows += 'auto ';
+            return;
+          }
+          const baseHeight = toNumberHeight(current.height);
+          if (Number.isNaN(baseHeight)) {
+            gridTemplateRows += `${current.height} `;
+            return;
+          }
+          gridTemplateRows += `${baseHeight}px `;
+          return;
         }
+
+        const nonAutoItems = items.filter((item) => item.height !== 'auto');
+        if (nonAutoItems.length === 0) {
+          gridTemplateRows += 'auto ';
+          return;
+        }
+
+        let maxHeight = 0;
+        nonAutoItems.forEach((item) => {
+          const current = toNumberHeight(item.height);
+          if (!Number.isNaN(current) && current > maxHeight) {
+            maxHeight = current;
+          }
+        });
+
+        gridTemplateRows += `${maxHeight}px `;
       });
 
       return {
@@ -309,6 +368,8 @@ export default {
       const dragElement = this.findChildComponentByKey(`preview-${id}`);
       if (!dragElement) return;
       if (cursor === 'default') return; // 无需拖动
+      // 阻止默认行为,避免拖拽过程中选中文本 / 触发图片原生拖拽
+      event.preventDefault();
       this.dragElement = dragElement;
 
       const { clientX, clientY } = event;
@@ -356,8 +417,6 @@ export default {
 
       this.drag.startX = clientX;
       this.drag.startY = clientY;
-
-      this.$forceUpdate();
     },
     /**
      * 拖拽结束
@@ -494,28 +553,57 @@ export default {
     .col {
       display: grid;
       gap: $component-spacing;
-      align-items: flex-start;
 
-      .active {
-        box-shadow: 0 0 6px 1px $main-hover-color;
-      }
+      // 防止列被行高拉伸时 auto 轨道膨胀(自动高度组件/分割线被推到底部)
+      align-content: start;
+      align-items: flex-start;
 
       .grid {
-        display: grid;
-        grid-template:
-          'top top top' 3px
-          'left preview right' 1fr
-          'bottom bottom bottom' 3px / 3px 1fr 3px;
-      }
+        position: relative;
+        display: block;
+        min-width: 0;
+        min-height: 0;
+
+        // 选中态:基于网格单元格(而非组件内容盒)绘制选中框
+        &.active {
+          box-shadow: 0 0 6px 1px $main-hover-color;
+        }
+
+        .component {
+          height: 100%;
+          overflow: hidden;
+        }
+
+        // 拖拽调整线:绝对定位覆盖在组件四边,不占用布局空间,
+        // 保证组件内容盒尺寸与 CoursewarePreview 一致
+        .drag-line,
+        .drag-vertical-line {
+          position: absolute;
+          z-index: 2;
+        }
 
-      .drag-line {
-        z-index: 2;
-        width: 100%;
-        height: 6px;
-        cursor: ns-resize;
-        background: linear-gradient(to bottom, transparent, transparent 40%, #e5e6eb 40%, #e5e6eb 60%, transparent 60%);
+        .drag-line.top {
+          top: -3px;
+          left: 0;
+          width: 100%;
+          height: 6px;
+          cursor: default;
+          background: linear-gradient(
+            to bottom,
+            transparent,
+            transparent 40%,
+            #e5e6eb 40%,
+            #e5e6eb 60%,
+            transparent 60%
+          );
+        }
 
-        &.bottom {
+        .drag-line.bottom {
+          bottom: -3px;
+          left: 0;
+          width: 100%;
+          height: 6px;
+          cursor: ns-resize;
           background: linear-gradient(
             to bottom,
             transparent,
@@ -525,18 +613,31 @@ export default {
             transparent 50%
           );
         }
-      }
-
-      .drag-vertical-line {
-        z-index: 2;
-        width: 6px;
-        height: 100%;
-        cursor: ew-resize;
-        background: linear-gradient(to right, transparent, transparent 40%, #e5e6eb 40%, #e5e6eb 60%, transparent 60%);
 
-        &.left {
+        .drag-vertical-line.left {
+          top: 0;
+          left: -3px;
+          width: 6px;
+          height: 100%;
+          cursor: ew-resize;
           background: linear-gradient(to left, transparent, transparent 60%, #e5e6eb 60%, #e5e6eb);
         }
+
+        .drag-vertical-line.right {
+          top: 0;
+          right: -3px;
+          width: 6px;
+          height: 100%;
+          cursor: ew-resize;
+          background: linear-gradient(
+            to right,
+            transparent,
+            transparent 40%,
+            #e5e6eb 40%,
+            #e5e6eb 60%,
+            transparent 60%
+          );
+        }
       }
     }
   }

+ 75 - 16
src/views/book/courseware/create/components/question/fill/Fill.vue

@@ -254,6 +254,59 @@ export default {
     async saveWord(saveArr) {
       await this.parsedContentPinyin(false, saveArr);
     },
+    /**
+     * 判断文本是否包含可见内容(去除 HTML 标签后是否仍有非空白字符)
+     * @param {String} content 文本内容
+     * @returns {Boolean} 是否包含可见内容
+     */
+    hasVisibleText(content = '') {
+      return (
+        String(content)
+          .replace(/<[^>]*>/g, '')
+          .trim().length > 0
+      );
+    },
+    /**
+     * 追加文本块;当文本块不含可见内容(仅闭合标签/空白)时,
+     * 并入前一个文本块,避免孤立闭合标签破坏 PinyinText 渲染。
+     * @param {Array} blocks 目标文本块数组
+     * @param {String} content 文本内容
+     * @param {Array} richTextList 富文本列表
+     */
+    appendTextBlock(blocks, content, richTextList = []) {
+      if (!this.hasVisibleText(content) && blocks.length > 0) {
+        for (let i = blocks.length - 1; i >= 0; i--) {
+          if (blocks[i] && blocks[i].type === 'text') {
+            blocks[i].content = `${blocks[i].content || ''}${content}`;
+            blocks[i].rich_text_list = [...(blocks[i].rich_text_list || []), ...richTextList];
+            return;
+          }
+        }
+      }
+
+      blocks.push({
+        content,
+        type: 'text',
+        rich_text_list: richTextList,
+      });
+    },
+    /**
+     * 判断最后一个文本块是否为块级(带对齐)。
+     * 块级段落自身已产生换行,其后的段间 \n 是冗余的。
+     * @param {Array} blocks 已生成的文本块数组
+     * @returns {Boolean}
+     */
+    isLastTextBlockAligned(blocks) {
+      for (let i = blocks.length - 1; i >= 0; i--) {
+        const block = blocks[i];
+        if (!block || block.type !== 'text') continue;
+        return (block.rich_text_list || []).some((item) => {
+          const tagText = String(item?.text || '');
+          return /^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(tagText) && /text-align\s*:/i.test(tagText);
+        });
+      }
+      return false;
+    },
     // 解析富文本,构建 model_essay 结构
     parseRichText() {
       let text_list = this.data.rich_text_list || [];
@@ -274,11 +327,7 @@ export default {
           const isRichFill = /class=\s*(\\?["'])rich-fill\1/.test(text);
           if (isRichFill) {
             if (totalText.length > 0) {
-              arr.push({
-                content: totalText,
-                type: 'text',
-                rich_text_list: totalRichText,
-              });
+              this.appendTextBlock(arr, totalText, totalRichText);
               totalText = '';
               totalRichText = [];
             }
@@ -306,6 +355,18 @@ export default {
             this.syncOpenStyleTagStack(openStyleTagStack, textItem);
           }
         } else {
+          // 段间空白(如 </p> 之后的 \n):前一段落为块级(带对齐)时冗余,丢弃;
+          // 否则保留作为段落换行分隔,避免破坏正常换行结构。
+          if (!String(text).trim() && this.isLastTextBlockAligned(arr)) {
+            // 先把累积的闭合标签并入前一个文本块,保持标签配对
+            if (totalText.length > 0) {
+              this.appendTextBlock(arr, totalText, totalRichText);
+              totalText = '';
+              totalRichText = [];
+            }
+            continue;
+          }
+
           const splitBlocks = this.splitTextItemByUnderline(textItem, preservedAnyOneAnswers, preservedAnyOneState);
 
           const currentOpenStyleTags = openStyleTagStack.map((tagItem) => ({ ...tagItem }));
@@ -331,25 +392,23 @@ export default {
             totalText = '';
             totalRichText = [];
           } else if (totalText.length > 0) {
-            arr.push({
-              content: totalText,
-              type: 'text',
-              rich_text_list: firstBlockPrefix,
-            });
+            this.appendTextBlock(arr, totalText, firstBlockPrefix);
             totalText = '';
             totalRichText = [];
           }
 
-          arr.push(...splitBlocks);
+          splitBlocks.forEach((block) => {
+            if (block.type === 'text') {
+              this.appendTextBlock(arr, block.content, block.rich_text_list);
+            } else {
+              arr.push(block);
+            }
+          });
         }
       }
 
       if (totalText.length > 0) {
-        arr.push({
-          content: totalText,
-          type: 'text',
-          rich_text_list: totalRichText,
-        });
+        this.appendTextBlock(arr, totalText, totalRichText);
       }
 
       return arr;

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

@@ -87,5 +87,6 @@ export function getFillData() {
     answer_list: [], // 答案列表
     analysis_list: [], // 解析列表
     rich_text_list: [],
+    min_height: 20,
   };
 }

+ 4 - 0
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -1192,6 +1192,10 @@ export default {
       display: grid;
       gap: $component-spacing;
 
+      // 与 PreviewEdit 保持一致:列被行高拉伸时,auto 行按内容紧凑排列,
+      // 避免富文本等自动高度组件被 align-content 默认的 stretch 撑高
+      align-content: start;
+
       .active {
         box-shadow: 0 0 6px 1px $main-hover-color;
       }