dsy 1 settimana fa
parent
commit
c284f7073f

+ 1 - 1
.env

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

+ 77 - 14
src/views/book/courseware/create/components/question/fill/Fill.vue

@@ -43,16 +43,23 @@
           />
         </el-divider>
         <div class="pinyin-wrapper">
-          <div v-for="(item, i) in data.model_essay" :key="i" class="pinyin-text-list">
-            <PinyinText
-              :key="`pinyin-${i}`"
-              ref="PinyinText"
-              :is-show-pinyin="isEnable(data.property.view_pinyin)"
-              :rich-text-list="item.rich_text_list"
-              :pinyin-position="data.property.pinyin_position"
-              :body-styles="pinyinBodyStyles"
-              @fillCorrectPinyin="fillCorrectPinyin($event, i, -1, 'model_essay')"
-            />
+          <div
+            v-for="(group, gi) in groupedModelEssay"
+            :key="`model-essay-group-${gi}`"
+            class="model-essay-group"
+            :style="groupStyle(group)"
+          >
+            <div v-for="(item, i) in group.items" :key="`item-${group.indexes[i]}`" class="pinyin-text-list">
+              <PinyinText
+                :key="`pinyin-${group.indexes[i]}`"
+                ref="PinyinText"
+                :is-show-pinyin="isEnable(data.property.view_pinyin)"
+                :rich-text-list="item.rich_text_list"
+                :pinyin-position="data.property.pinyin_position"
+                :body-styles="pinyinBodyStyles"
+                @fillCorrectPinyin="fillCorrectPinyin($event, group.indexes[i], -1, 'model_essay')"
+              />
+            </div>
           </div>
         </div>
         <div v-if="data.audio_file_id">
@@ -171,6 +178,25 @@ export default {
         color: unifiedAttrib.text_color || undefined,
       };
     },
+    /**
+     * 按段落对齐方式对 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;
+    },
   },
   watch: {
     'data.property.arrange_type': 'handleMindMap',
@@ -195,6 +221,18 @@ export default {
     },
   },
   methods: {
+    /**
+     * 分组容器样式:有对齐时应用块级与对齐,无对齐时保持行内流式。
+     * @param {Object} group 分组对象
+     * @returns {Object} 样式对象
+     */
+    groupStyle(group) {
+      if (!group || !group.align) return {};
+      return {
+        display: 'block',
+        textAlign: group.align,
+      };
+    },
     async ensureWordProofreadData() {
       this.wordData = { detail: [{ sentences: [], segList: [] }] };
       this.data.rich_text_list?.forEach((item) => {
@@ -291,8 +329,8 @@ export default {
       });
     },
     /**
-     * 判断最后一个文本块是否为块级(对齐)。
-     * 块级段落自身已产生换行,其后的段间 \n 是冗余的。
+     * 判断最后一个文本块是否为块级(居中对齐)。
+     * 居中的块级段落自身已产生换行,其后的段间 \n 是冗余的。
      * @param {Array} blocks 已生成的文本块数组
      * @returns {Boolean}
      */
@@ -302,11 +340,32 @@ export default {
         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 /^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(tagText) && /text-align\s*:\s*center/i.test(tagText);
         });
       }
       return false;
     },
+    /**
+     * 提取每个块的对齐方式并写入 align 字段,同时从块级标签中移除 text-align,
+     * 避免 PinyinText 为每个块设置 display:block,导致段内文字被拆成多行。
+     * 对齐效果改由上层按 align 分组后整体应用。
+     * @param {Array} blocks 文本块数组
+     * @returns {Array} 处理后的文本块数组
+     */
+    finalizeAlign(blocks) {
+      return (blocks || []).map((block) => {
+        let align = block.align || '';
+        const richTextList = (block.rich_text_list || []).map((item) => {
+          const tagText = String(item?.text || '');
+          if (!/^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(tagText)) return item;
+          const alignMatch = tagText.match(/text-align\s*:\s*([^;"']+)/i);
+          if (!alignMatch) return item;
+          if (!align) align = alignMatch[1].trim();
+          return { ...item, text: tagText.replace(/text-align\s*:\s*[^;"']+;?\s*/i, '') };
+        });
+        return { ...block, align, rich_text_list: richTextList };
+      });
+    },
     // 解析富文本,构建 model_essay 结构
     parseRichText() {
       let text_list = this.data.rich_text_list || [];
@@ -411,7 +470,7 @@ export default {
         this.appendTextBlock(arr, totalText, totalRichText);
       }
 
-      return arr;
+      return this.finalizeAlign(arr);
     },
     /**
      * 从标签文本中提取标签名称,支持开标签和闭标签
@@ -876,6 +935,10 @@ export default {
 }
 
 .pinyin-wrapper {
+  .model-essay-group {
+    display: inline;
+  }
+
   .pinyin-text-list {
     display: inline;
 

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

@@ -11,88 +11,101 @@
       />
       <div class="fill-wrapper">
         <div class="fill-content">
-          <template v-for="(li, i) in modelEssay">
-            <template v-if="li.type === 'text'">
-              <PinyinText
-                :key="`text-${i}`"
-                class="content"
-                :paragraph-list="li.paragraph_list"
-                :rich-text-list="li.rich_text_list"
-                :pinyin-position="data.property.pinyin_position"
-                :body-styles="pinyinBodyStyles"
-                :is-show-pinyin="isEnable(data.property.view_pinyin)"
-                :is-preview="true"
-              />
-            </template>
-            <template v-if="li.type === 'input'">
-              <!-- 输入填空 -->
-              <template v-if="data.property.fill_type === fillTypeList[0].value">
-                <el-input
-                  :key="`input-${i}`"
-                  :ref="`input-${li.mark}`"
-                  v-model="li.input"
-                  :disabled="disabled"
-                  :class="[...computedAnswerClass(li.mark)]"
-                  :style="[
-                    {
-                      '--fill-font-family': data.property.fill_font,
-                      width: (inputWidthMap[li.mark] || data.property.input_default_width) + 'px',
-                    },
-                  ]"
-                  @input="handleInput(li.input, li.mark)"
-                />
-              </template>
-
-              <!-- 选词填空 -->
-              <template v-else-if="data.property.fill_type === fillTypeList[1].value">
-                <el-popover :key="`popover-${i}`" placement="top" trigger="click">
-                  <div class="word-list">
+          <template v-for="(group, gi) in groupedModelEssay">
+            <span :key="`group-${gi}`" :style="groupStyle(group)">
+              <template v-for="(li, i) in group.items">
+                <template v-if="li.type === 'text'">
+                  <PinyinText
+                    :key="`text-${group.indexes[i]}`"
+                    class="content"
+                    :paragraph-list="li.paragraph_list"
+                    :rich-text-list="li.rich_text_list"
+                    :pinyin-position="data.property.pinyin_position"
+                    :body-styles="pinyinBodyStyles"
+                    :is-show-pinyin="isEnable(data.property.view_pinyin)"
+                    :is-preview="true"
+                  />
+                </template>
+                <template v-if="li.type === 'input'">
+                  <!-- 输入填空 -->
+                  <template v-if="data.property.fill_type === fillTypeList[0].value">
+                    <el-input
+                      :key="`input-${group.indexes[i]}`"
+                      :ref="`input-${li.mark}`"
+                      v-model="li.input"
+                      :disabled="disabled"
+                      :class="[...computedAnswerClass(li.mark)]"
+                      :style="[
+                        {
+                          '--fill-font-family': data.property.fill_font,
+                          width: (inputWidthMap[li.mark] || data.property.input_default_width) + 'px',
+                          marginBottom: resolveFillControlMargin(li),
+                        },
+                      ]"
+                      @input="handleInput(li.input, li.mark)"
+                    />
+                  </template>
+
+                  <!-- 选词填空 -->
+                  <template v-else-if="data.property.fill_type === fillTypeList[1].value">
+                    <el-popover :key="`popover-${group.indexes[i]}`" placement="top" trigger="click">
+                      <div class="word-list">
+                        <span
+                          v-for="{ content, mark } in data.word_list"
+                          :key="mark"
+                          class="word-item"
+                          @click="handleSelectWord(content, mark, li)"
+                          v-html="sanitizeHTML(content)"
+                        >
+                        </span>
+                      </div>
+
+                      <span
+                        slot="reference"
+                        class="select-content"
+                        :style="[
+                          { minWidth: data.property.input_default_width + 'px' },
+                          { marginBottom: resolveFillControlMargin(li) },
+                        ]"
+                        v-html="sanitizeHTML(li.input)"
+                      ></span>
+                    </el-popover>
+                  </template>
+
+                  <!-- 手写填空 -->
+                  <template v-else-if="data.property.fill_type === fillTypeList[2].value">
                     <span
-                      v-for="{ content, mark } in data.word_list"
-                      :key="mark"
-                      class="word-item"
-                      @click="handleSelectWord(content, mark, li)"
-                      v-html="sanitizeHTML(content)"
+                      :key="`write-${group.indexes[i]}`"
+                      class="write-click"
+                      :style="{ marginBottom: resolveFillControlMargin(li) }"
+                      @click="handleWriteClick(li.mark)"
                     >
+                      <img
+                        v-show="li.write_base64"
+                        style="background-color: #f4f4f4"
+                        :src="li.write_base64"
+                        alt="write-show"
+                      />
                     </span>
-                  </div>
-
-                  <span
-                    slot="reference"
-                    class="select-content"
-                    :style="[{ minWidth: data.property.input_default_width + 'px' }]"
-                    v-html="sanitizeHTML(li.input)"
-                  ></span>
-                </el-popover>
+                  </template>
+
+                  <!-- 语音填空 -->
+                  <template v-else-if="data.property.fill_type === fillTypeList[3].value">
+                    <SoundRecordBox
+                      ref="record"
+                      :key="`record-${group.indexes[i]}`"
+                      type="mini"
+                      :many-times="false"
+                      class="record-box fill"
+                      :attrib="data.unified_attrib"
+                      :answer-record-list="data.audio_answer_list"
+                      :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
+                      @handleWav="handleMiniWav($event, li.mark)"
+                    />
+                  </template>
+                </template>
               </template>
-
-              <!-- 手写填空 -->
-              <template v-else-if="data.property.fill_type === fillTypeList[2].value">
-                <span :key="`write-${i}`" class="write-click" @click="handleWriteClick(li.mark)">
-                  <img
-                    v-show="li.write_base64"
-                    style="background-color: #f4f4f4"
-                    :src="li.write_base64"
-                    alt="write-show"
-                  />
-                </span>
-              </template>
-
-              <!-- 语音填空 -->
-              <template v-else-if="data.property.fill_type === fillTypeList[3].value">
-                <SoundRecordBox
-                  ref="record"
-                  :key="`record-${i}`"
-                  type="mini"
-                  :many-times="false"
-                  class="record-box fill"
-                  :attrib="data.unified_attrib"
-                  :answer-record-list="data.audio_answer_list"
-                  :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
-                  @handleWav="handleMiniWav($event, li.mark)"
-                />
-              </template>
-            </template>
+            </span>
           </template>
         </div>
       </div>
@@ -133,24 +146,33 @@
     >
       <div slot="right-answer" class="fill-wrapper">
         <div class="fill-content">
-          <template v-for="(li, i) in modelEssay">
-            <template v-if="li.type === 'text'">
-              <PinyinText
-                :key="`answer-text-${i}`"
-                :is-show-pinyin="isEnable(data.property.view_pinyin)"
-                class="content"
-                :paragraph-list="li.paragraph_list"
-                :rich-text-list="li.rich_text_list"
-                :body-styles="pinyinBodyStyles"
-                :pinyin-position="data.property.pinyin_position"
-                :is-preview="true"
-              />
-            </template>
-            <template v-if="li.type === 'input'">
-              <span v-show="computedAnswerText(li.mark).length > 0" :key="`answer-${i}`" class="right-answer">
-                {{ computedAnswerText(li.mark) }}
-              </span>
-            </template>
+          <template v-for="(group, gi) in groupedModelEssay">
+            <span :key="`answer-group-${gi}`" :style="groupStyle(group)">
+              <template v-for="(li, i) in group.items">
+                <template v-if="li.type === 'text'">
+                  <PinyinText
+                    :key="`answer-text-${group.indexes[i]}`"
+                    :is-show-pinyin="isEnable(data.property.view_pinyin)"
+                    class="content"
+                    :paragraph-list="li.paragraph_list"
+                    :rich-text-list="li.rich_text_list"
+                    :body-styles="pinyinBodyStyles"
+                    :pinyin-position="data.property.pinyin_position"
+                    :is-preview="true"
+                  />
+                </template>
+                <template v-if="li.type === 'input'">
+                  <span
+                    v-show="computedAnswerText(li.mark).length > 0"
+                    :key="`answer-${group.indexes[i]}`"
+                    class="right-answer"
+                    :style="{ marginBottom: resolveFillControlMargin(li) }"
+                  >
+                    {{ computedAnswerText(li.mark) }}
+                  </span>
+                </template>
+              </template>
+            </span>
           </template>
         </div>
       </div>
@@ -214,6 +236,25 @@ export default {
     isShowAnswerFill() {
       return this.hasFixedAnswer || (this.data.answer_list || []).length > 0;
     },
+    /**
+     * 按段落对齐方式对 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;
+    },
   },
   watch: {
     'data.model_essay': {
@@ -258,6 +299,47 @@ export default {
     },
   },
   methods: {
+    /**
+     * 分组容器样式:有对齐时应用块级与对齐,无对齐时保持行内流式。
+     * @param {Object} group 分组对象
+     * @returns {Object} 样式对象
+     */
+    groupStyle(group) {
+      if (!group || !group.align) return {};
+      return {
+        display: 'block',
+        textAlign: group.align,
+      };
+    },
+    /**
+     * 计算填空控件需要补偿的 margin-bottom。
+     * 词级 margin-bottom 会撑高行盒,控件 vertical-align: bottom 会贴到被撑高的行盒底部,
+     * 因此给控件加上与段落一致的 margin-bottom,使其边框底部回到文本底部;
+     * 无 margin 时返回空字符串,保持原有显示不变。
+     * @param {Object} li 填空块对象
+     * @returns {String} 补偿值,如 '28pt',无则空字符串
+     */
+    resolveFillControlMargin(li) {
+      const tagItem = (li?.rich_text_list || []).find((item) =>
+        /^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(String(item?.text || '')),
+      );
+      const raw = String(tagItem?.text || '').match(/margin-bottom\s*:\s*([^;"']+)/i);
+      if (!raw) return '';
+      const match = raw[1].trim().match(/^([\d.]+)\s*(px|pt|em)?$/i);
+      if (!match) return '';
+      const amount = parseFloat(match[1]);
+      const unit = (match[2] || 'px').toLowerCase();
+      // px/pt 为绝对单位,可直接使用;em 相对词级字号,换算为绝对 pt 后再补偿
+      if (unit !== 'em') return raw[1].trim();
+      const fontSize = String(this.pinyinBodyStyles?.fontSize || '16pt');
+      const fsMatch = fontSize.match(/^([\d.]+)\s*(px|pt|em)?$/i);
+      if (!fsMatch) return raw[1].trim();
+      const fsAmount = parseFloat(fsMatch[1]);
+      const fsUnit = (fsMatch[2] || 'pt').toLowerCase();
+      if (fsUnit === 'em') return `${amount * fsAmount}em`;
+      if (fsUnit === 'pt') return `${amount * fsAmount}pt`;
+      return `${amount * fsAmount}px`;
+    },
     handleWav(data) {
       this.data.record_list = data;
     },