Browse Source

预览优化

dsy 2 weeks ago
parent
commit
a3209f5340

+ 2 - 2
src/api/app.js

@@ -97,9 +97,9 @@ export function LearnWebSI(MethodName, data) {
 }
 
 /**
- * 生成教材预览链接
+ * 检测教材预览身份识别码
  * @param {object} data 请求数据
- * @param {string} data.book_id 教材 ID
+ * @param {string} data.identity_code 教材预览身份识别码
  */
 export function CheckBookPreviewIdentityCode(data) {
   return http.post(

+ 36 - 12
src/components/PinyinText.vue

@@ -26,7 +26,7 @@
             v-for="(word, wIndex) in block.word_list"
             :key="wIndex"
             :class="['pinyin-text', { spacing: isShowPinyin }]"
-            :style="block.styleObj"
+            :style="[block.styleObj, getLineHeightSpacingStyle(block)]"
           >
             <span
               :title="isPreview ? '' : '点击校对'"
@@ -516,7 +516,7 @@ export default {
       const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
       const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
       const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       const containerStyleObj = {};
       tagStack.forEach((tagItem) => {
@@ -526,8 +526,8 @@ export default {
       });
 
       const imageStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(imageStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(imageStyleObj, styleValue);
       }
 
       return {
@@ -550,7 +550,7 @@ export default {
       const posterMatch = attrs.match(/poster=["']([^"']*)["']/i);
       const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
       const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       // 直接从完整的文本中匹配 source
       const sourceMatch = item.text.match(/<source\s+([^>]*)\/?\s*>/i);
@@ -574,8 +574,8 @@ export default {
       });
 
       const mediaStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(mediaStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(mediaStyleObj, styleValue);
       }
 
       return {
@@ -597,7 +597,7 @@ export default {
 
       const attrs = audioMatch[1];
       const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       const containerStyleObj = {};
       tagStack.forEach((tagItem) => {
@@ -607,8 +607,8 @@ export default {
       });
 
       const mediaStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(mediaStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(mediaStyleObj, styleValue);
       }
 
       return {
@@ -651,10 +651,26 @@ export default {
       }
     },
 
+    /**
+     * 提取标签属性中的 style 值。
+     * style 的属性值内部可能包含与包裹引号不同的引号,例如
+     * style="font-family: 'times new roman', times, serif;",
+     * 因此必须按包裹引号成对匹配;若使用 ["']([^"']*)["'] 这类写法,会在内层单引号处
+     * 提前截断(只得到 "font-family: "),导致字体等样式被丢弃、显示效果与编辑器设置不一致。
+     * @param {String} attrs 标签的属性字符串(不含标签名)
+     * @returns {String} style 属性值,未匹配到时返回空字符串
+     */
+    extractStyleAttr(attrs = '') {
+      const styleMatch = String(attrs).match(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
+      if (!styleMatch) return '';
+      const [, doubleQuotedStyle, singleQuotedStyle] = styleMatch;
+      if (doubleQuotedStyle === undefined) return singleQuotedStyle || '';
+      return doubleQuotedStyle;
+    },
+
     // 提取标签信息(样式和类名)
     extractTagInfo(attrs, tagName) {
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/);
-      let style = styleMatch ? styleMatch[1] : null;
+      let style = this.extractStyleAttr(attrs) || null;
 
       const classMatch = attrs.match(/class=["']([^"']*)["']/);
       const className = classMatch ? classMatch[1] : '';
@@ -728,6 +744,14 @@ export default {
       };
     },
 
+    getLineHeightSpacingStyle(block) {
+      const lineHeight = block?.sentenceStyle?.lineHeight;
+      if (lineHeight === undefined || !/^\d*\.?\d+$/.test(String(lineHeight).trim())) return {};
+
+      const spacing = Number(lineHeight) - 1.5;
+      return spacing ? { marginBottom: `${spacing}em` } : {};
+    },
+
     // 解析样式字符串为对象
     parseStyleToObject(styleStr) {
       const styleObj = {};

+ 17 - 2
src/enterPage.vue

@@ -5,6 +5,7 @@
 <script>
 import { CheckBookPreviewIdentityCode } from '@/api/app';
 import { setToken, setLocalStore } from '@/utils/auth';
+import { updateBaseURL } from '@/utils/http';
 
 export default {
   name: 'EnterPage',
@@ -19,9 +20,15 @@ export default {
     };
   },
   created() {
+    this.updateServerAddress();
     this.checkIdentityCode();
   },
   methods: {
+    updateServerAddress() {
+      if (process.env.NODE_ENV === 'development') {
+        return updateBaseURL('/eep');
+      }
+    },
     checkIdentityCode() {
       if (!this.identityCode) {
         this.$message.error('缺少身份码参数');
@@ -33,11 +40,15 @@ export default {
           setToken(response);
           setLocalStore('book_id', this.data.book_id);
           if (this.isMobile()) {
+            if (this.data.preview_type === 1) {
+              this.setCoursewareData(); // 课程预览需要 task_id 和 courseware_id
+              this.$router.replace({ name: 'MobileCoursewarePreview' });
+              return;
+            }
             this.$router.replace({ name: 'MobileBookPreview' });
           } else {
             if (this.data.preview_type === 1) {
-              setLocalStore('task_id', this.data.task_id); // 课程预览需要 task_id
-              setLocalStore('courseware_id', this.data.courseware_id);
+              this.setCoursewareData(); // 课程预览需要 task_id 和 courseware_id
               this.$router.replace({ name: 'CoursewarePreview' });
               return;
             }
@@ -46,6 +57,10 @@ export default {
         })
         .catch(() => {});
     },
+    setCoursewareData() {
+      setLocalStore('task_id', this.data.task_id); // 课程预览需要 task_id
+      setLocalStore('courseware_id', this.data.courseware_id);
+    },
     // 判断当前是web端还是手机端
     isMobile() {
       const userAgent = navigator.userAgent || navigator.vendor || window.opera;

+ 349 - 0
src/mobile_courseware_preview/index.vue

@@ -0,0 +1,349 @@
+<template>
+  <div class="mobile-courseware-preview">
+    <div class="mobile-courseware-preview__header">
+      {{ courseware_info.book_name }}
+    </div>
+
+    <div class="mobile-courseware-preview__name-path">
+      {{ courseware_info.name_path }}
+    </div>
+
+    <div ref="previewMain" class="main-container">
+      <main class="preview-main">
+        <MobileCoursewarePreview
+          v-if="courseware_info.book_name"
+          ref="courseware"
+          :is-show-group="isShowGroup"
+          :courseware-id="select_node"
+          :group-show-all="groupShowAll"
+          :group-row-list="content_group_row_list"
+          :data="data"
+          :component-list="component_list"
+          :background="background"
+          :can-remark="false"
+          :show-remark="false"
+          :component-remark-obj="remark_list_obj"
+        />
+      </main>
+    </div>
+  </div>
+</template>
+
+<script>
+import MobileCoursewarePreview from '@/views/book/courseware/preview/MobileCoursewarePreview.vue';
+
+import * as OpenCC from 'opencc-js';
+import { GetBookCoursewareInfo, GetCoursewareAuditRemarkList } from '@/api/project';
+import {
+  ContentGetCoursewareContent_View,
+  GetBookBaseInfo,
+  GetLanguageTypeList,
+  GetBookUnifiedAttrib,
+} from '@/api/book';
+import { getLocalStore } from '@/utils/auth';
+
+export default {
+  name: 'MobileCoursewarePreviewPage',
+  components: {
+    MobileCoursewarePreview,
+  },
+  provide() {
+    return {
+      getLang: () => this.lang,
+      getChinese: () => this.chinese,
+      getShowPinYin: () => this.showPinYin,
+      getLangList: () => this.langList,
+      convertText: this.convertText,
+      getProjectId: () => this.book_id,
+      getSelectId: () => this.select_node,
+      getTitleList: () => this.title_list,
+      getPermissionControl: () => this.permissionControl,
+    };
+  },
+  data() {
+    const courseware_id = getLocalStore('courseware_id') || '';
+    return {
+      book_id: getLocalStore('book_id') || '',
+      select_node: courseware_id,
+      courseware_info: {
+        book_name: '',
+        is_can_start_edit: 'false',
+        is_can_submit_audit: 'false',
+        is_can_audit_pass: 'false',
+        is_can_audit_reject: 'false',
+        is_can_add_audit_remark: 'false',
+        is_can_finish_audit: 'false',
+        is_can_request_shangjia_book: 'false',
+        is_can_request_rollback_project: 'false',
+        is_can_shangjia_book: 'false',
+        is_can_rollback_project: 'false',
+      },
+      background: {
+        background_image_url: '',
+        background_position: {
+          left: 0,
+          top: 0,
+        },
+      },
+      data: { row_list: [] },
+      component_list: [],
+      content_group_row_list: [],
+      remark_list: [],
+      remark_list_obj: {}, // 存放以组件为对象的数组
+      isShowGroup: false,
+      groupShowAll: true,
+      opencc: OpenCC.Converter({ from: 'cn', to: 'tw' }),
+      langList: [],
+      lang: 'ZH',
+      chinese: 'zh-Hans',
+      showPinYin: 'false',
+      unified_attrib: {},
+      title_list: [],
+      // 模拟答题权限控制
+      permissionControl: {
+        can_answer: true, // 可作答
+        can_judge_correct: true, // 可判断对错(客观题)
+        can_show_answer: true, // 可查看答案
+        can_correct: false, // 可批改
+        can_check_correct: false, // 可查看批改
+      },
+    };
+  },
+  created() {
+    this.getBookBaseInfo();
+    this.getBookUnifiedAttr();
+    if (this.select_node) {
+      this.selectNode(this.select_node);
+    }
+  },
+  methods: {
+    getBookBaseInfo() {
+      GetBookBaseInfo({ id: this.book_id }).then(({ book_info }) => {
+        this.courseware_info = { ...this.courseware_info, ...book_info, book_name: book_info.name };
+      });
+    },
+
+    /**
+     * 得到教材课件信息
+     * @param {string} id - 课件ID
+     */
+    getBookCoursewareInfo(id) {
+      GetBookCoursewareInfo({ id, is_contain_producer: 'true', is_contain_auditor: 'true' }).then(
+        ({ courseware_info }) => {
+          this.courseware_info = { ...this.courseware_info, ...courseware_info };
+          this.getLangList();
+        },
+      );
+    },
+
+    /**
+     * 得到课件内容(展示内容)
+     * @param {string} id - 课件ID
+     */
+    getCoursewareComponentContent_View(id) {
+      ContentGetCoursewareContent_View({ id }).then(
+        ({ content, component_list, content_group_row_list, title_list, background }) => {
+          if (title_list) this.title_list = title_list || [];
+          if (background) {
+            this.background = JSON.parse(background);
+          }
+          if (content) {
+            const _content = JSON.parse(content);
+            this.data = _content;
+          } else {
+            this.data = { row_list: [] };
+          }
+
+          if (component_list) this.component_list = component_list;
+          this.component_list.forEach((x) => {
+            if (x.component_type === 'audio') {
+              let _c = JSON.parse(x.content);
+              let p = _c.property || {};
+              if (!p.file_name_display_mode) p.file_name_display_mode = 'true';
+              if (p.view_method === 'independent' && !p.style_mode) {
+                p.style_mode = 'middle';
+              }
+              if (!p.style_mode) p.style_mode = 'big';
+              if (p.view_method === 'icon') {
+                p.file_name_display_mode = 'false';
+                p.view_method = 'independent';
+                p.style_mode = 'small';
+              }
+
+              x.content = JSON.stringify(_c);
+            } else if (x.component_type === 'richtext') {
+              let _c = JSON.parse(x.content);
+              let p = _c.property || {};
+              let lev = Number(p.title_style_level);
+              if (p.is_title !== 'true' || lev < 1 || !_c.content) return;
+
+              let style = title_list.find((y) => y.level === lev) || {};
+              if (style && style.style) {
+                style = JSON.parse(style.style);
+                let c_text = _c.content;
+
+                const parser = new DOMParser();
+                const doc = parser.parseFromString(c_text, 'text/html');
+                const body = doc.body;
+                const pElements = body.querySelectorAll('p');
+                this.processHtmlString(pElements, style);
+                _c.content = body.innerHTML;
+
+                x.content = JSON.stringify(_c);
+              }
+            }
+          });
+
+          if (content_group_row_list) this.content_group_row_list = JSON.parse(content_group_row_list) || [];
+        },
+      );
+    },
+
+    processHtmlString(pElements, styleConfig, isClear) {
+      pElements.forEach((pElement) => {
+        if (isClear) {
+          pElement.removeAttribute('style');
+        } else {
+          const style = pElement.style;
+
+          if (styleConfig.font) style.fontFamily = styleConfig.font;
+          if (styleConfig.font_size) style.fontSize = styleConfig.font_size;
+          if (styleConfig.text_color) style.color = styleConfig.text_color;
+
+          style.fontWeight = styleConfig.bold === 'true' ? 'bold' : 'normal';
+          style.fontStyle = styleConfig.italic === 'true' ? 'italic' : 'normal';
+          if (styleConfig.indent || styleConfig.indent === 0) style.marginLeft = `${styleConfig.indent}px`;
+        }
+      });
+    },
+
+    getLangList() {
+      GetLanguageTypeList({
+        book_id: this.courseware_info.book_id,
+        is_contain_zh: 'true',
+      }).then(({ language_type_list }) => {
+        this.langList = language_type_list;
+      });
+    },
+
+    getBookUnifiedAttr() {
+      GetBookUnifiedAttrib({ book_id: this.book_id }).then(({ content }) => {
+        if (content) {
+          this.unified_attrib = JSON.parse(content);
+        }
+      });
+    },
+
+    /**
+     * 选择节点
+     * @param {string} nodeId - 节点ID
+     */
+    selectNode(nodeId) {
+      this.getCoursewareComponentContent_View(nodeId);
+      this.getBookCoursewareInfo(nodeId);
+      this.getCoursewareAuditRemarkList(nodeId);
+      this.select_node = nodeId;
+    },
+
+    // 审校批注列表
+    getCoursewareAuditRemarkList(id) {
+      this.remark_list = [];
+      let remarkListObj = {};
+      GetCoursewareAuditRemarkList({
+        courseware_id: id,
+      }).then(({ remark_list }) => {
+        this.remark_list = remark_list;
+        if (!remark_list) return;
+        remarkListObj = remark_list.reduce((acc, item) => {
+          if (!acc[item.component_id]) {
+            acc[item.component_id] = [];
+          }
+          acc[item.component_id].push(item);
+          return acc;
+        }, {});
+
+        this.remark_list_obj = remarkListObj;
+      });
+    },
+
+    /**
+     * 文本转换
+     * @param {string} text - 要转换的文本
+     * @returns {string} - 转换后的文本
+     */
+    convertText(text) {
+      if (this.chinese === 'zh-Hant' && this.opencc) {
+        return this.opencc(text);
+      }
+      return text;
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+@use '@/styles/mixin.scss' as *;
+
+.mobile-courseware-preview {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100vh;
+
+  &__header {
+    position: sticky;
+    top: 0;
+    left: 0;
+    z-index: 9;
+    display: flex;
+    align-items: center;
+    width: 100%;
+    height: 40px;
+    padding: 6px 8px;
+    margin-bottom: 5px;
+    overflow: hidden;
+    font-weight: bold;
+    background-color: #fff;
+    border-top: $border;
+    border-bottom: $border;
+  }
+
+  &__name-path {
+    width: 100%;
+    padding: 0 8px 6px;
+    margin-bottom: 5px;
+    overflow: hidden;
+    font-size: 12px;
+    color: #909399;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    background-color: #fff;
+    border-bottom: $border;
+  }
+
+  .main-container {
+    flex: 1;
+    width: 100%;
+    padding: 5px 12px;
+    overflow: auto;
+
+    :deep img {
+      max-width: 100% !important;
+    }
+  }
+
+  main.preview-main {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    row-gap: 5px;
+    width: 100%;
+    min-height: 100%;
+    padding: 5px;
+    margin: 0 auto;
+    background-color: #fff;
+    border-radius: 4px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
+  }
+}
+</style>

+ 15 - 1
src/router/modules/basic.js

@@ -28,10 +28,24 @@ export const MobileBookPreview = {
   component: () => import('@/mobile_preview/index.vue'),
 };
 
+export const MobileCoursewarePreview = {
+  path: '/mobile_courseware_preview',
+  name: 'MobileCoursewarePreview',
+  component: () => import('@/mobile_courseware_preview/index.vue'),
+};
+
 export const NotFoundPage = {
   path: '*',
   name: '404',
   component: () => import('@/views/not_found/404.vue'),
 };
 
-export default [EnterPage, OBOCPage, BookPreview, CoursewarePreview, MobileBookPreview, NotFoundPage];
+export default [
+  EnterPage,
+  OBOCPage,
+  BookPreview,
+  CoursewarePreview,
+  MobileBookPreview,
+  MobileCoursewarePreview,
+  NotFoundPage,
+];

+ 48 - 10
src/views/book/courseware/data/fill.js

@@ -1,3 +1,4 @@
+import { fontList } from '@/common/data';
 import {
   displayList,
   serialNumberTypeList,
@@ -5,7 +6,7 @@ import {
   arrangeTypeList,
   switchOption,
   pinyinPositionList,
-  commonComponentProperty,
+  serialNumberStyleList,
 } from '@/views/book/courseware/data/common';
 
 export { arrangeTypeList, switchOption };
@@ -40,13 +41,6 @@ export const fillTypeList = [
   { value: 'voice', label: '语音' },
 ];
 
-// 填空字体
-export const fillFontList = [
-  { value: 'chinese', label: '中文', font: 'arial' },
-  { value: 'english', label: '英文', font: 'arial' },
-  { value: 'pinyin', label: '拼音', font: 'PINYIN-B' },
-];
-
 export function getFillProperty() {
   return {
     serial_number: 1,
@@ -54,20 +48,60 @@ export function getFillProperty() {
     fill_type: fillTypeList[0].value,
     sn_position: serialNumberPositionList[3].value,
     sn_display_mode: displayList[1].value,
+    sn_style: serialNumberStyleList[0].value,
+    sn_background_color: '#ea3232', // 序号背景色
     arrange_type: arrangeTypeList[1].value,
     audio_position: audioPositionList[0].value,
     audio_generation_method: audioGenerationMethodList[0].value,
-    fill_font: fillFontList[0].value,
+    fill_font: fontList[0].value,
     is_enable_voice_answer: switchOption[1].value,
     view_pinyin: 'false', // 显示拼音
     pinyin_position: pinyinPositionList[0].value,
     is_first_sentence_first_hz_pinyin_first_char_upper_case: displayList[0].value, // 句首大写
     voice_type: '', // 音色
     emotion: '', // 风格,情感
-    ...commonComponentProperty,
+    speed_ratio: '', // 语速
+    input_default_width: 160, // 输入框默认宽度
   };
 }
 
+// 块级开标签(标志着一个新段落的开始,如 <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',
@@ -87,5 +121,9 @@ export function getFillData() {
       node_list: [{ name: '横排中文填空组件' }],
     },
     multilingual: [], // 多语言
+    answer_list: [], // 答案列表
+    analysis_list: [], // 解析列表
+    rich_text_list: [],
+    min_height: 20,
   };
 }

+ 80 - 63
src/views/book/courseware/preview/CoursewarePreview.vue

@@ -1,7 +1,13 @@
 <template>
   <div ref="courseware" class="courseware preview" :style="computedCoursewareStyle" @mouseup="handleTextSelection">
     <template v-for="(row, i) in data.row_list">
-      <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
+      <div
+        v-if="hasRenderableComponents(row)"
+        v-show="computedRowVisibility(row.row_id)"
+        :key="i"
+        class="row"
+        :style="getMultipleColStyle(i)"
+      >
         <el-checkbox
           v-if="
             isShowGroup &&
@@ -210,6 +216,14 @@ export default {
   },
   methods: {
     /**
+     * 判断行内是否至少包含一个可渲染组件
+     * @param {Object} row 行数据
+     * @returns {boolean} 是否包含组件
+     */
+    hasRenderableComponents(row) {
+      return Boolean(row?.col_list?.some((col) => col?.grid_list?.length));
+    },
+    /**
      * 计算组件内容
      * @param {string} id 组件id
      * @returns {string} 组件内容
@@ -286,7 +300,7 @@ export default {
      */
     computedColStyle(col) {
       const grid = col.grid_list || [];
-      if (!grid.length) {
+      if (grid.length === 0) {
         return {
           width: col.width,
           gridTemplateAreas: '',
@@ -295,104 +309,107 @@ export default {
         };
       }
 
-      // 一次遍历完成按行分组,并记录最大列数所在行
-      let maxCol = 0;
-      let curMaxRow = 0;
+      // 先按 row 分组,避免后续重复 filter 扫描
       const rowMap = new Map();
       grid.forEach((item) => {
         if (!rowMap.has(item.row)) {
           rowMap.set(item.row, []);
         }
-        const rowItems = rowMap.get(item.row);
-        rowItems.push(item);
-        if (rowItems.length > maxCol) {
-          maxCol = rowItems.length;
-          curMaxRow = item.row;
+        rowMap.get(item.row).push(item);
+      });
+
+      let maxCol = 0;
+      let curMaxRow = 0;
+      rowMap.forEach((items, row) => {
+        if (items.length > maxCol) {
+          maxCol = items.length;
+          curMaxRow = row;
         }
       });
 
       // 计算 grid_template_areas
       let gridTemplateAreas = '';
-      const sortedRows = Array.from(rowMap.keys()).sort((a, b) => a - b);
-      sortedRows.forEach((row) => {
-        const rowItems = rowMap.get(row);
-        const rowAreas = [];
+      rowMap.forEach((items, row) => {
+        let rowAreas = [];
         if (row === curMaxRow) {
-          rowItems.forEach(({ grid_area }) => {
-            rowAreas.push(`${grid_area}`);
-          });
+          rowAreas = items.map((item) => item.grid_area);
         } else {
-          const needNum = maxCol - rowItems.length;
-          const splitList = rowItems.length > 1 ? this.splitInteger(needNum, rowItems.length) : [];
-          rowItems.forEach(({ grid_area }, index) => {
-            if (rowItems.length === 1) {
-              rowAreas.push(` ${grid_area} `.repeat(needNum + 1));
-              return;
-            }
-            const repeatNum = splitList[index] + 1;
-            rowAreas.push(splitList[index] === 0 ? ` ${grid_area} ` : ` ${grid_area} `.repeat(repeatNum));
-          });
+          const needNum = maxCol - items.length;
+          if (items.length === 1) {
+            rowAreas = Array(needNum + 1).fill(items[0].grid_area);
+          } else {
+            const splitArr = this.splitInteger(needNum, items.length);
+            rowAreas = items.flatMap((item, index) => Array(splitArr[index] + 1).fill(item.grid_area));
+          }
         }
         gridTemplateAreas += `'${rowAreas.join(' ')}' `;
       });
 
       // 计算 grid_template_columns
       const maxRowItems = rowMap.get(curMaxRow) || [];
-      const gridTemplateColumns = maxRowItems.map(({ width }) => width).join(' ');
+      const gridTemplateColumns = maxRowItems.length ? `${maxRowItems.map((item) => item.width).join(' ')} ` : '';
 
       // 计算 grid_template_rows
-      let gridTemplateRows = '';
-      const previewMap = new Map();
+      const previewById = new Map();
       (this.$refs.preview || []).forEach((child) => {
-        const childId = child?.$el?.dataset?.id;
-        if (childId) {
-          previewMap.set(childId, child);
+        const id = child?.$el?.dataset?.id;
+        if (id) {
+          previewById.set(id, child);
         }
       });
+      const hasOperationById = (id) => {
+        const component = previewById.get(id);
+        if (!component || !component.$el) return false;
+        const operation = component.$el.querySelector('.operation');
+        // 存在 .operation 且真实占位(有高度)时才需要额外预留高度,
+        // 避免 operation 被隐藏/未渲染时多加 48px
+        return Boolean(operation && operation.offsetHeight > 0);
+      };
+      const toNumberHeight = (height) => {
+        const num = Number(String(height).replace('px', ''));
+        return Number.isFinite(num) ? num : NaN;
+      };
 
-      rowMap.forEach((rowItems) => {
-        if (rowItems.length === 1) {
-          const onlyItem = rowItems[0];
-          const component = previewMap.get(onlyItem.id);
-          const hasOperation = component && component.$el.querySelector('.operation') !== null;
-          const height = hasOperation ? `${Number(onlyItem.height.replace('px', '')) + 48}px` : onlyItem.height;
-          gridTemplateRows += `${height} `;
+      let gridTemplateRows = '';
+      rowMap.forEach((items) => {
+        if (items.length === 1) {
+          const current = items[0];
+          if (current.height === 'auto') {
+            gridTemplateRows += 'auto ';
+            return;
+          }
+          let baseHeight = toNumberHeight(current.height);
+          if (Number.isNaN(baseHeight)) {
+            gridTemplateRows += `${current.height} `;
+            return;
+          }
+          if (hasOperationById(current.id)) {
+            baseHeight += 48;
+          }
+          gridTemplateRows += `${baseHeight}px `;
           return;
         }
 
-        const heightItems = rowItems.map(({ id, height }) => ({ id, height }));
-        const isAllAuto = heightItems.every((item) => item.height === 'auto');
-        if (isAllAuto) {
+        const nonAutoItems = items.filter((item) => item.height !== 'auto');
+        if (nonAutoItems.length === 0) {
           gridTemplateRows += 'auto ';
           return;
         }
 
+        let maxItem = null;
         let maxHeight = 0;
-        let maxId = '';
-        heightItems.forEach((item) => {
-          if (item.height !== 'auto') {
-            const heightNum = Number(item.height.replace('px', ''));
-            if (heightNum > maxHeight) {
-              maxHeight = heightNum;
-              maxId = item.id;
-            }
+        nonAutoItems.forEach((item) => {
+          const current = toNumberHeight(item.height);
+          if (!Number.isNaN(current) && current > maxHeight) {
+            maxHeight = current;
+            maxItem = item;
           }
         });
 
-        const component = previewMap.get(maxId);
-        const hasOperation = component && component.$el.querySelector('.operation') !== null;
-        if (hasOperation) {
+        if (maxItem && hasOperationById(maxItem.id)) {
           maxHeight += 48;
         }
-
-        const finalHeight = Math.max(
-          ...heightItems.map((item) => {
-            if (item.height === 'auto') return 0;
-            if (item.id === maxId) return maxHeight;
-            return Number(item.height.replace('px', ''));
-          }),
-        );
-        gridTemplateRows += `${finalHeight}px `;
+        gridTemplateRows += `${maxHeight}px `;
       });
 
       return {

+ 128 - 0
src/views/book/courseware/preview/common/utils/fontSizeScale.js

@@ -0,0 +1,128 @@
+/**
+ * 预览字号等比缩放工具
+ *
+ * 编辑时设置的字号以 pt/px 绝对单位写入课件数据,预览时无法通过父级 font-size 继承放大,
+ * 因此在数据层按比例等比缩放,覆盖以下来源:
+ * 1. 统一属性字段:font_size / pinyin_size / fontSize(如 '14pt'、'20px'、18)
+ * 2. HTML 片段中的内联样式:style="font-size: 14pt"
+ *
+ * 说明:sizeRatio 为 1 时原样返回,保证默认(100%)行为完全不变。
+ */
+
+// 需要等比缩放的字号字段名
+const FONT_SIZE_KEYS = ['font_size', 'pinyin_size', 'fontSize'];
+
+// 可缩放的字号值:纯数字(如组件属性 font_size: 18)或 数字 + pt/px 绝对单位
+const FONT_SIZE_VALUE_RE = /^(\d+(?:\.\d+)?)(pt|px)?$/i;
+
+// HTML 内联样式中的字号:font-size: 14pt / font-size:14px
+const INLINE_FONT_SIZE_RE = /(font-size\s*:\s*)(\d+(?:\.\d+)?)(pt|px)/gi;
+
+/**
+ * 保留两位小数,避免出现 16.799999999999997 这类浮点误差
+ * @param {number} value 数值
+ * @returns {number} 保留两位小数后的数值
+ */
+function roundSize(value) {
+  return Math.round(value * 100) / 100;
+}
+
+/**
+ * 缩放单个字号值
+ * @param {string|number} value 字号值,如 '14pt'、'20px'、18
+ * @param {number} sizeRatio 缩放比例,如 1.2
+ * @returns {string|number} 缩放后的字号值;非字号值时原样返回
+ */
+export function scaleFontSizeValue(value, sizeRatio) {
+  if (sizeRatio === 1) return value;
+
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? roundSize(value * sizeRatio) : value;
+  }
+  if (typeof value !== 'string') return value;
+
+  const match = value.match(FONT_SIZE_VALUE_RE);
+  if (!match) return value;
+
+  const amount = parseFloat(match[1]);
+  if (!Number.isFinite(amount)) return value;
+
+  return `${roundSize(amount * sizeRatio)}${match[2] || ''}`;
+}
+
+/**
+ * 缩放 HTML 片段中内联样式的字号
+ * 仅处理 pt/px 绝对单位,em/百分比等相对单位会随父级一起变化,不重复缩放
+ * @param {string} html HTML 片段
+ * @param {number} sizeRatio 缩放比例
+ * @returns {string} 缩放后的 HTML 片段
+ */
+export function scaleHtmlFontSize(html, sizeRatio) {
+  if (sizeRatio === 1 || typeof html !== 'string' || !html) return html;
+  if (!/font-size/i.test(html)) return html;
+
+  return html.replace(INLINE_FONT_SIZE_RE, (match, prefix, amount, unit) => {
+    return `${prefix}${roundSize(parseFloat(amount) * sizeRatio)}${unit}`;
+  });
+}
+
+/**
+ * 递归缩放数据中的字号(不修改入参)
+ * @param {any} node 待处理数据
+ * @param {number} sizeRatio 缩放比例
+ * @returns {any} 缩放后的数据
+ */
+function scaleNodeFontSize(node, sizeRatio) {
+  if (Array.isArray(node)) {
+    return node.map((item) => scaleNodeFontSize(item, sizeRatio));
+  }
+
+  if (node && typeof node === 'object') {
+    return Object.keys(node).reduce((acc, key) => {
+      const value = node[key];
+      if (FONT_SIZE_KEYS.includes(key)) {
+        acc[key] = scaleFontSizeValue(value, sizeRatio);
+      } else if (typeof value === 'string') {
+        acc[key] = scaleHtmlFontSize(value, sizeRatio);
+      } else {
+        acc[key] = scaleNodeFontSize(value, sizeRatio);
+      }
+      return acc;
+    }, {});
+  }
+
+  return node;
+}
+
+/**
+ * 缩放组件内容 JSON 字符串中的字号
+ * @param {string} content 组件内容 JSON 字符串
+ * @param {number} sizeRatio 缩放比例
+ * @returns {string} 缩放后的组件内容 JSON 字符串
+ */
+export function scaleContentFontSize(content, sizeRatio) {
+  if (sizeRatio === 1 || typeof content !== 'string' || !content) return content;
+
+  try {
+    const data = JSON.parse(content);
+    return JSON.stringify(scaleNodeFontSize(data, sizeRatio));
+  } catch (e) {
+    return content;
+  }
+}
+
+/**
+ * 缩放组件列表中的字号
+ * 仅 content 字段参与缩放(浅拷贝),其它字段保持原引用,便于背景等修改同步
+ * @param {Array} componentList 组件列表
+ * @param {number} sizeRatio 缩放比例
+ * @returns {Array} 缩放后的组件列表
+ */
+export function scaleComponentListFontSize(componentList, sizeRatio) {
+  if (sizeRatio === 1 || !Array.isArray(componentList)) return componentList;
+
+  return componentList.map((item) => {
+    if (!item || !item.content) return item;
+    return { ...item, content: scaleContentFontSize(item.content, sizeRatio) };
+  });
+}

+ 0 - 1
src/views/book/courseware/preview/components/audio/AudioPreview.vue

@@ -96,7 +96,6 @@ import { getAudioData } from '@/views/book/courseware/data/audio';
 
 import PreviewMixin from '../common/PreviewMixin';
 import AudioPlay from '../common/AudioPlay.vue';
-import { getRandomNumber } from '@/utils';
 
 export default {
   name: 'AudioPreview',

+ 2 - 1
src/views/book/courseware/preview/components/common/AudioPlay.vue

@@ -323,7 +323,8 @@ export default {
   .audio-big {
     display: flex;
     flex-direction: column;
-    padding: 12px 0;
+    padding: 0;
+    overflow: hidden;
     text-align: center;
 
     .audio-name {

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

@@ -40,6 +40,7 @@
                           '--fill-font-family': data.property.fill_font,
                           width: (inputWidthMap[li.mark] || data.property.input_default_width) + 'px',
                           marginBottom: resolveFillControlMargin(li),
+                          ...getFillControlAlignmentStyle(li),
                         },
                       ]"
                       @input="handleInput(li.input, li.mark)"
@@ -144,7 +145,7 @@
       :analysis-list="data.analysis_list"
       @closeAnswerAnalysis="closeAnswerAnalysis"
     >
-      <div slot="right-answer" class="fill-wrapper">
+      <div slot="right-answer" class="fill-wrapper" :style="{ '--fill-font-family': data.property.fill_font }">
         <div class="fill-content">
           <template v-for="(group, gi) in groupedModelEssay">
             <span :key="`answer-group-${gi}`" :style="groupStyle(group)">
@@ -187,7 +188,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 +244,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: {
@@ -340,6 +338,21 @@ export default {
       if (fsUnit === 'pt') return `${amount * fsAmount}pt`;
       return `${amount * fsAmount}px`;
     },
+    getFillControlAlignmentStyle(li) {
+      const tagItem = (li?.rich_text_list || []).find((item) =>
+        /^<(p|div|h[1-6]|li|blockquote)\b[^>]*>/i.test(String(item?.text || '')),
+      );
+      const lineHeight = String(tagItem?.text || '').match(/line-height\s*:\s*([^;"']+)/i);
+      if (!lineHeight || !/^\d*\.?\d+$/.test(lineHeight[1].trim())) return {};
+
+      const ratio = Number(lineHeight[1].trim());
+      if (ratio < 0 || ratio >= 1.5) return {};
+      return {
+        marginBottom: '',
+        position: 'relative',
+        top: `${1.5 - ratio}em`,
+      };
+    },
     handleWav(data) {
       this.data.record_list = data;
     },
@@ -614,6 +627,7 @@ export default {
     }
 
     .fill-content {
+      width: calc(100% - 82px);
       margin: 0;
 
       :deep .el-input__inner {
@@ -653,7 +667,9 @@ export default {
     }
 
     .select-content {
-      display: inline-block;
+      // 选中的词贴到控件底部:文字底部与正文文字底部对齐(下边框即填空横线)
+      display: inline-flex;
+      align-items: flex-end;
       height: 32px;
       margin: 0 10px;
       vertical-align: bottom;
@@ -670,6 +686,7 @@ export default {
       align-items: center;
       width: 120px;
       margin: 0 10px;
+      margin-bottom: -2px;
       vertical-align: bottom;
 
       &.pinyin :deep input.el-input__inner {
@@ -697,9 +714,12 @@ export default {
       }
 
       :deep .el-input__inner {
+        // 1em 行盒:输入文字基线与正文文字对齐,下边框即填空横线
+        height: 1em;
         padding: 0;
         font-family: var(--fill-font-family, 'arial'), sans-serif;
         font-size: 16pt;
+        line-height: 1em;
         color: $font-color;
         text-align: center;
         background-color: #fff;
@@ -710,11 +730,15 @@ export default {
     }
 
     .right-answer {
+      // 盒模型/行高与填空输入框一致:文字底部贴正文文字底部,下边框即填空横线
+      // 字体与学生输入保持一致(--fill-font-family 由模板传入)
       position: relative;
+      box-sizing: border-box;
       display: inline-block;
-      height: 32px;
+      height: 1em;
       margin: 0 4px;
-      line-height: 32px;
+      font-family: var(--fill-font-family, 'arial'), sans-serif;
+      line-height: 1em;
       vertical-align: bottom;
       border-bottom: 1px solid $font-color;
     }

+ 42 - 20
src/views/book/courseware/preview/components/new_word/NewWordPreview.vue

@@ -1648,9 +1648,28 @@ export default {
             this.initData();
             this.$nextTick(() => {
               setTimeout(() => {
-                let totalWidth = document.querySelector('.newWord-preview').offsetWidth;
+                let totalWidth = document.querySelector('.newWord-preview').clientWidth;
                 this.width = `${totalWidth - (this.data.property.sn_display_mode === 'true' ? 15 : 0) - 1}`;
-              }, 200);
+                this.rowWidth = 0;
+                this.data.col_width.forEach((item, index) => {
+                  if (
+                    this.data.property.pinyin_position &&
+                    (this.data.property.pinyin_position == 'top' || this.data.property.pinyin_position == 'bottom')
+                  ) {
+                    if (index < 2) {
+                      this.rowWidth = this.rowWidth > Number(item.value) ? this.rowWidth : Number(item.value);
+                    } else {
+                      this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
+                    }
+                  } else {
+                    this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
+                  }
+                });
+                this.rowWidth += 132;
+                if (this.rowWidth < this.width - 2) {
+                  this.rowWidth = this.width - 2;
+                }
+              }, 50);
             });
 
             this.dataHeight = this.$refs.previewContainer ? this.$refs.previewContainer.style.height : 0;
@@ -1682,9 +1701,28 @@ export default {
   mounted() {
     this.$nextTick(() => {
       setTimeout(() => {
-        let totalWidth = document.querySelector('.newWord-preview').offsetWidth;
+        let totalWidth = document.querySelector('.newWord-preview').clientWidth;
         this.width = `${totalWidth - (this.data.property.sn_display_mode === 'true' ? 15 : 0) - 1}`;
-      }, 200);
+        this.rowWidth = 0;
+        this.data.col_width.forEach((item, index) => {
+          if (
+            this.data.property.pinyin_position &&
+            (this.data.property.pinyin_position == 'top' || this.data.property.pinyin_position == 'bottom')
+          ) {
+            if (index < 2) {
+              this.rowWidth = this.rowWidth > Number(item.value) ? this.rowWidth : Number(item.value);
+            } else {
+              this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
+            }
+          } else {
+            this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
+          }
+        });
+        this.rowWidth += 132;
+        if (this.rowWidth < this.width - 2) {
+          this.rowWidth = this.width - 2;
+        }
+      }, 50);
     });
 
     this.dataHeight = this.$refs.previewContainer ? this.$refs.previewContainer.style.height : 0;
@@ -1820,7 +1858,6 @@ export default {
         this.is_list = true;
       }
       this.hasVoice = false;
-      this.rowWidth = 0;
       this.colHasContent = [];
       this.showObj = {
         pinyin: true,
@@ -1856,21 +1893,6 @@ export default {
           this.colHasContent.push(Boolean(str_col));
         });
       }
-      this.data.col_width.forEach((item, index) => {
-        if (
-          this.data.property.pinyin_position &&
-          (this.data.property.pinyin_position == 'top' || this.data.property.pinyin_position == 'bottom')
-        ) {
-          if (index < 2) {
-            this.rowWidth = this.rowWidth > Number(item.value) ? this.rowWidth : Number(item.value);
-          } else {
-            this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
-          }
-        } else {
-          this.rowWidth += this.colHasContent[index] ? Number(item.value) : 0;
-        }
-      });
-      this.rowWidth += 132;
 
       this.is_change = true;
       setTimeout(() => {

+ 7 - 3
src/views/book/courseware/preview/components/record_input/RecordInputPreview.vue

@@ -1,6 +1,6 @@
 <!-- eslint-disable vue/no-v-html -->
 <template>
-  <div class="select-preview" :style="[getAreaStyle(), getComponentStyle()]">
+  <div class="record-input-preview" :style="[getAreaStyle(), getComponentStyle()]">
     <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
 
     <div class="main">
@@ -9,10 +9,10 @@
         v-model="data.answer.answer_list.input"
         :class="['textarea']"
         type="textarea"
+        :autosize="{ minRows: 5 }"
         placeholder=""
         maxlength="1000"
         :readonly="isJudgingRightWrong"
-        :rows="5"
         show-word-limit
         @blur="data.answer.answer_list.input = data.answer.answer_list.input.trim()"
       />
@@ -135,7 +135,7 @@ export default {
 <style lang="scss" scoped>
 @use '@/styles/mixin.scss' as *;
 
-.select-preview {
+.record-input-preview {
   @include preview-base;
 
   .voice-luyin {
@@ -154,6 +154,10 @@ export default {
     background-color: #fff;
   }
 
+  :deep .el-textarea {
+    font-size: 14pt;
+  }
+
   :deep .el-textarea .el-input__count {
     background: transparent;
   }

+ 388 - 108
src/views/book/courseware/preview/components/table/TablePreview.vue

@@ -96,7 +96,12 @@
                                 </span>
                               </span>
                               <template v-else-if="word.type === 'input'">
-                                <span v-if="data.property.pinyin_position === 'top'" class="pinyin">&nbsp;</span>
+                                <span
+                                  v-if="data.property.pinyin_position === 'top'"
+                                  class="pinyin"
+                                  :style="getPinyinStyle(word)"
+                                  >&nbsp;</span
+                                >
                                 <template v-if="data.property.fill_type === fillTypeList[0].value">
                                   <el-input
                                     :key="index"
@@ -153,7 +158,12 @@
                                     @handleWav="handleMiniWav($event, word)"
                                   />
                                 </template>
-                                <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">&nbsp;</span>
+                                <span
+                                  v-if="data.property.pinyin_position === 'bottom'"
+                                  class="pinyin"
+                                  :style="getPinyinStyle(word)"
+                                  >&nbsp;</span
+                                >
                               </template>
                             </span>
                           </span>
@@ -222,86 +232,239 @@
                       </template>
                     </template>
                     <template v-else>
-                      <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
-                        <span
-                          v-if="item.type === 'text'"
+                      <template v-if="col.rich_text_list && col.rich_text_list.length !== 0">
+                        <div
+                          v-for="(item, index) in col.rich_text_list"
                           :key="index"
-                          :style="{
-                            fontSize:
-                              data.unified_attrib && data.unified_attrib.font_size ? data.unified_attrib.font_size : '',
-                            fontFamily: data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
-                          }"
-                          v-html="convertText(sanitizeHTML(item.value))"
-                        ></span>
-                        <template v-if="item.type === 'input'">
-                          <template v-if="data.property.fill_type === fillTypeList[0].value">
-                            <el-input
-                              :key="index"
-                              v-model="item.value"
-                              :disabled="disabled"
-                              :class="[...computedAnswerClass(item, i, j)]"
-                              :style="[
-                                {
-                                  width: Math.max(40, item.value.length * 21.3) + 'px',
-                                  fontSize:
-                                    data.unified_attrib && data.unified_attrib.font_size
-                                      ? data.unified_attrib.font_size
-                                      : '',
-                                  fontFamily:
-                                    data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
-                                },
-                              ]"
-                            />
-                          </template>
-                          <template v-else-if="data.property.fill_type === fillTypeList[1].value">
-                            <el-popover :key="index" placement="top" trigger="click">
-                              <div class="word-list">
+                          class="pinyin-text pinyin-text-sentence"
+                          :class="[
+                            index === 0 ? 'pinyin-text-left' : '',
+                            item.type === 'audio' ? 'pinyin-text-audio' : '',
+                          ]"
+                        >
+                          <!-- 文字块:包含 word_list -->
+                          <span
+                            v-if="item.type === 'text'"
+                            :key="'text-' + index"
+                            class="pinyin-sentence"
+                            :style="[
+                              { 'align-items': data.property.pinyin_position === 'top' ? 'flex-end' : 'flex-start' },
+                            ]"
+                          >
+                            <span
+                              v-for="(word, wIndex) in item.word_list"
+                              :key="wIndex"
+                              class="pinyin-text"
+                              :style="item.styleObj"
+                            >
+                              <span v-if="word.type === 'text'">
                                 <span
-                                  v-for="{ content, mark } in data.word_list"
-                                  :key="mark"
-                                  class="word-item"
-                                  @click="handleSelectWord(content, mark, item)"
+                                  :style="{
+                                    flexDirection:
+                                      data.property.pinyin_position === 'top' ? 'column' : 'column-reverse',
+                                    'align-items': getWordAlignItems(word, item),
+                                  }"
                                 >
-                                  {{ convertText(content) }}
+                                  <span class="py-char" :style="getCharStyle(word, item)">{{
+                                    convertText(word.text)
+                                  }}</span>
                                 </span>
-                              </div>
+                              </span>
+                              <template v-else-if="word.type === 'input'">
+                                <template v-if="data.property.fill_type === fillTypeList[0].value">
+                                  <el-input
+                                    :key="index"
+                                    v-model="word.value"
+                                    :disabled="disabled"
+                                    :class="[...computedAnswerClass(word, i, j)]"
+                                    :style="getInputStyle(item.styleObj, word)"
+                                  />
+                                </template>
+                                <template v-else-if="data.property.fill_type === fillTypeList[1].value">
+                                  <el-popover :key="index" 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, word)"
+                                      >
+                                        {{ convertText(content) }}
+                                      </span>
+                                    </div>
+
+                                    <el-input
+                                      slot="reference"
+                                      v-model="word.value"
+                                      :readonly="true"
+                                      :class="[...computedAnswerClass(word, i, j)]"
+                                      :style="getInputStyle(item.styleObj, word)"
+                                    />
+                                  </el-popover>
+                                </template>
 
+                                <template v-else-if="data.property.fill_type === fillTypeList[2].value">
+                                  <span :key="j" class="write-click" @click="handleWriteClick(word)">
+                                    <img
+                                      v-show="word.write_base64"
+                                      style="background-color: #f4f4f4"
+                                      :src="word.write_base64"
+                                      alt="write-show"
+                                    />
+                                  </span>
+                                </template>
+
+                                <template v-else-if="data.property.fill_type === fillTypeList[3].value">
+                                  <SoundRecordBox
+                                    ref="record"
+                                    :key="j"
+                                    type="mini"
+                                    :many-times="false"
+                                    class="record-box"
+                                    :answer-record-list="data.audio_answer_list"
+                                    :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
+                                    :attrib="data.unified_attrib"
+                                    @handleWav="handleMiniWav($event, word)"
+                                  />
+                                </template>
+                              </template>
+                            </span>
+                          </span>
+                          <!-- 图片块 -->
+                          <div
+                            v-else-if="item.type === 'image'"
+                            :key="'image-' + index"
+                            :style="item.containerStyle"
+                            class="image-container"
+                          >
+                            <img
+                              :src="item.src"
+                              :alt="item.alt"
+                              :width="item.width"
+                              :height="item.height"
+                              :style="item.imageStyle"
+                              class="inline-image"
+                            />
+                          </div>
+                          <!-- 视频块 -->
+                          <div
+                            v-else-if="item.type === 'video'"
+                            :key="'video-' + index"
+                            :style="item.containerStyle"
+                            class="video-container"
+                          >
+                            <video
+                              :poster="item.poster"
+                              :width="item.width"
+                              :height="item.height"
+                              :style="item.mediaStyle"
+                              controls
+                              class="inline-video"
+                            >
+                              <source :src="item.src" :type="item.videoType" />
+                              您的浏览器不支持视频播放
+                            </video>
+                          </div>
+                          <!-- 音频块 -->
+                          <div
+                            v-else-if="item.type === 'audio'"
+                            :key="'audio-' + index"
+                            :style="item.containerStyle"
+                            class="audio-container"
+                          >
+                            <audio :src="item.src" :style="item.mediaStyle" controls class="inline-audio">
+                              您的浏览器不支持音频播放
+                            </audio>
+                          </div>
+                          <!-- 换行符 -->
+                          <br v-else-if="item.type === 'newline'" :key="'newline-' + index" />
+                        </div>
+                      </template>
+                      <template v-else>
+                        <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
+                          <span
+                            v-if="item.type === 'text'"
+                            :key="index"
+                            :style="{
+                              fontSize:
+                                data.unified_attrib && data.unified_attrib.font_size
+                                  ? data.unified_attrib.font_size
+                                  : '',
+                              fontFamily:
+                                data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
+                            }"
+                            v-html="convertText(sanitizeHTML(item.value))"
+                          ></span>
+                          <template v-if="item.type === 'input'">
+                            <template v-if="data.property.fill_type === fillTypeList[0].value">
                               <el-input
-                                slot="reference"
+                                :key="index"
                                 v-model="item.value"
-                                :readonly="true"
+                                :disabled="disabled"
                                 :class="[...computedAnswerClass(item, i, j)]"
-                                :style="[{ width: Math.max(40, item.value.length * 21.3) + 'px' }]"
+                                :style="[
+                                  {
+                                    width: Math.max(40, item.value.length * 21.3) + 'px',
+                                    fontSize:
+                                      data.unified_attrib && data.unified_attrib.font_size
+                                        ? data.unified_attrib.font_size
+                                        : '',
+                                    fontFamily:
+                                      data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
+                                  },
+                                ]"
                               />
-                            </el-popover>
-                          </template>
-
-                          <template v-else-if="data.property.fill_type === fillTypeList[2].value">
-                            <span :key="j" class="write-click" @click="handleWriteClick(item)">
-                              <img
-                                v-show="item.write_base64"
-                                style="background-color: #f4f4f4"
-                                :src="item.write_base64"
-                                alt="write-show"
+                            </template>
+                            <template v-else-if="data.property.fill_type === fillTypeList[1].value">
+                              <el-popover :key="index" 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, item)"
+                                  >
+                                    {{ convertText(content) }}
+                                  </span>
+                                </div>
+
+                                <el-input
+                                  slot="reference"
+                                  v-model="item.value"
+                                  :readonly="true"
+                                  :class="[...computedAnswerClass(item, i, j)]"
+                                  :style="[{ width: Math.max(40, item.value.length * 21.3) + 'px' }]"
+                                />
+                              </el-popover>
+                            </template>
+
+                            <template v-else-if="data.property.fill_type === fillTypeList[2].value">
+                              <span :key="j" class="write-click" @click="handleWriteClick(item)">
+                                <img
+                                  v-show="item.write_base64"
+                                  style="background-color: #f4f4f4"
+                                  :src="item.write_base64"
+                                  alt="write-show"
+                                />
+                              </span>
+                            </template>
+
+                            <template v-else-if="data.property.fill_type === fillTypeList[3].value">
+                              <SoundRecordBox
+                                ref="record"
+                                :key="j"
+                                type="mini"
+                                :many-times="false"
+                                class="record-box"
+                                :answer-record-list="data.audio_answer_list"
+                                :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
+                                :attrib="data.unified_attrib"
+                                @handleWav="handleMiniWav($event, item)"
                               />
-                            </span>
+                            </template>
                           </template>
-
-                          <template v-else-if="data.property.fill_type === fillTypeList[3].value">
-                            <SoundRecordBox
-                              ref="record"
-                              :key="j"
-                              type="mini"
-                              :many-times="false"
-                              class="record-box"
-                              :answer-record-list="data.audio_answer_list"
-                              :task-model="isJudgingRightWrong ? 'ANSWER' : ''"
-                              :attrib="data.unified_attrib"
-                              @handleWav="handleMiniWav($event, item)"
-                            />
-                          </template>
-                        </template>
-                      </div>
+                        </div>
+                      </template>
                     </template>
                   </div>
 
@@ -511,22 +674,123 @@
                         </template>
                       </template>
                       <template v-else>
-                        <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
-                          <span
-                            v-if="item.type === 'text'"
+                        <template v-if="col.rich_text_list && col.rich_text_list.length !== 0">
+                          <div
+                            v-for="(item, index) in col.rich_text_list"
                             :key="index"
-                            :style="{
-                              fontSize:
-                                data.unified_attrib && data.unified_attrib.font_size
-                                  ? data.unified_attrib.font_size
-                                  : '',
-                              fontFamily:
-                                data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
-                            }"
-                            v-html="convertText(sanitizeHTML(item.value))"
-                          ></span>
-                          <template v-if="item.type === 'input'">
-                            <!-- <template v-if="data.property.fill_type === fillTypeList[0].value">
+                            class="pinyin-text pinyin-text-sentence"
+                            :class="[
+                              index === 0 ? 'pinyin-text-left' : '',
+                              item.type === 'audio' ? 'pinyin-text-audio' : '',
+                            ]"
+                          >
+                            <!-- 文字块:包含 word_list -->
+                            <span
+                              v-if="item.type === 'text'"
+                              :key="'text-' + index"
+                              class="pinyin-sentence"
+                              :style="[
+                                { 'align-items': data.property.pinyin_position === 'top' ? 'flex-end' : 'flex-start' },
+                              ]"
+                            >
+                              <span
+                                v-for="(word, wIndex) in item.word_list"
+                                :key="wIndex"
+                                class="pinyin-text"
+                                :style="item.styleObj"
+                              >
+                                <span v-if="word.type === 'text'">
+                                  <span
+                                    :style="{
+                                      flexDirection:
+                                        data.property.pinyin_position === 'top' ? 'column' : 'column-reverse',
+                                      'align-items': getWordAlignItems(word, item),
+                                    }"
+                                  >
+                                    <span class="py-char" :style="getCharStyle(word, item)">{{
+                                      convertText(word.text)
+                                    }}</span>
+                                  </span>
+                                </span>
+                                <template v-else-if="word.type === 'input'">
+                                  <span
+                                    v-show="computedAnswerText(word, i, j).length > 0"
+                                    :key="`answer-${j}`"
+                                    class="right-answer"
+                                  >
+                                    {{ convertText(computedAnswerText(word, i, j)) }}
+                                  </span>
+                                </template>
+                              </span>
+                            </span>
+                            <!-- 图片块 -->
+                            <div
+                              v-else-if="item.type === 'image'"
+                              :key="'image-' + index"
+                              :style="item.containerStyle"
+                              class="image-container"
+                            >
+                              <img
+                                :src="item.src"
+                                :alt="item.alt"
+                                :width="item.width"
+                                :height="item.height"
+                                :style="item.imageStyle"
+                                class="inline-image"
+                              />
+                            </div>
+                            <!-- 视频块 -->
+                            <div
+                              v-else-if="item.type === 'video'"
+                              :key="'video-' + index"
+                              :style="item.containerStyle"
+                              class="video-container"
+                            >
+                              <video
+                                :poster="item.poster"
+                                :width="item.width"
+                                :height="item.height"
+                                :style="item.mediaStyle"
+                                controls
+                                class="inline-video"
+                              >
+                                <source :src="item.src" :type="item.videoType" />
+                                您的浏览器不支持视频播放
+                              </video>
+                            </div>
+
+                            <!-- 音频块 -->
+                            <div
+                              v-else-if="item.type === 'audio'"
+                              :key="'audio-' + index"
+                              :style="item.containerStyle"
+                              class="audio-container"
+                            >
+                              <audio :src="item.src" :style="item.mediaStyle" controls class="inline-audio">
+                                您的浏览器不支持音频播放
+                              </audio>
+                            </div>
+                            <!-- 换行符 -->
+                            <br v-else-if="item.type === 'newline'" :key="'newline-' + index" />
+                          </div>
+                        </template>
+                        <template v-else>
+                          <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
+                            <span
+                              v-if="item.type === 'text'"
+                              :key="index"
+                              :style="{
+                                fontSize:
+                                  data.unified_attrib && data.unified_attrib.font_size
+                                    ? data.unified_attrib.font_size
+                                    : '',
+                                fontFamily:
+                                  data.unified_attrib && data.unified_attrib.font ? data.unified_attrib.font : '',
+                              }"
+                              v-html="convertText(sanitizeHTML(item.value))"
+                            ></span>
+                            <template v-if="item.type === 'input'">
+                              <!-- <template v-if="data.property.fill_type === fillTypeList[0].value">
                             <el-input
                               :key="index"
                               v-model="item.value"
@@ -583,15 +847,16 @@
                             />
                           </template> -->
 
-                            <span
-                              v-show="computedAnswerText(item, i, j).length > 0"
-                              :key="`answer-${j}`"
-                              class="right-answer"
-                            >
-                              {{ convertText(computedAnswerText(item, i, j)) }}
-                            </span>
-                          </template>
-                        </div>
+                              <span
+                                v-show="computedAnswerText(item, i, j).length > 0"
+                                :key="`answer-${j}`"
+                                class="right-answer"
+                              >
+                                {{ convertText(computedAnswerText(item, i, j)) }}
+                              </span>
+                            </template>
+                          </div>
+                        </template>
                       </template>
                     </div>
                     <i
@@ -836,7 +1101,7 @@ export default {
       const altMatch = attrs.match(/alt=["']([^"']*)["']/i);
       const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
       const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       const containerStyleObj = {};
       tagStack.forEach((tagItem) => {
@@ -846,8 +1111,8 @@ export default {
       });
 
       const imageStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(imageStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(imageStyleObj, styleValue);
       }
 
       return {
@@ -974,8 +1239,8 @@ export default {
       const baseStyle = {};
       baseStyle['font-size'] = styles ? styles.fontSize : '16px';
       baseStyle['--input-font-family'] = styles ? styles.fontFamily : '';
-      baseStyle['width'] = Math.max(40, words.value.length * 21.3) + 'px';
-      baseStyle['height'] = styles && styles.fontSize ? styles.fontSize.replace('pt', '') * 1.5 + 'pt' : '24px';
+      baseStyle['width'] = `${Math.max(40, words.value.length * 21.3)}px`;
+      baseStyle['height'] = styles && styles.fontSize ? `${styles.fontSize.replace('pt', '') * 1.5}pt` : '24px';
 
       return baseStyle;
     },
@@ -987,10 +1252,23 @@ export default {
       }
       return 'center';
     },
+    /**
+     * 提取标签属性中的 style 值。
+     * style 的属性值内部可能包含与包裹引号不同的引号(如 style="font-family: 'times new roman'"),
+     * 必须按包裹引号成对匹配,否则会在内层引号处提前截断,导致字体等样式丢失。
+     * @param {String} attrs 标签的属性字符串(不含标签名)
+     * @returns {String} style 属性值,未匹配到时返回空字符串
+     */
+    extractStyleAttr(attrs = '') {
+      const styleMatch = String(attrs).match(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
+      if (!styleMatch) return '';
+      const [, doubleQuotedStyle, singleQuotedStyle] = styleMatch;
+      if (doubleQuotedStyle === undefined) return singleQuotedStyle || '';
+      return doubleQuotedStyle;
+    },
     // 提取标签信息(样式和类名)
     extractTagInfo(attrs, tagName) {
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/);
-      let style = styleMatch ? styleMatch[1] : null;
+      let style = this.extractStyleAttr(attrs) || null;
 
       const classMatch = attrs.match(/class=["']([^"']*)["']/);
       const className = classMatch ? classMatch[1] : '';
@@ -1033,7 +1311,7 @@ export default {
       const posterMatch = attrs.match(/poster=["']([^"']*)["']/i);
       const widthMatch = attrs.match(/width=["']?(\d+)["']?/i);
       const heightMatch = attrs.match(/height=["']?(\d+)["']?/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       // 直接从完整的文本中匹配 source
       const sourceMatch = item.text.match(/<source\s+([^>]*)\/?\s*>/i);
@@ -1057,8 +1335,8 @@ export default {
       });
 
       const mediaStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(mediaStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(mediaStyleObj, styleValue);
       }
 
       return {
@@ -1080,7 +1358,7 @@ export default {
 
       const attrs = audioMatch[1];
       const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
-      const styleMatch = attrs.match(/style=["']([^"']*)["']/i);
+      const styleValue = this.extractStyleAttr(attrs);
 
       const containerStyleObj = {};
       tagStack.forEach((tagItem) => {
@@ -1090,8 +1368,8 @@ export default {
       });
 
       const mediaStyleObj = {};
-      if (styleMatch) {
-        this.mergeStyleString(mediaStyleObj, styleMatch[1]);
+      if (styleValue) {
+        this.mergeStyleString(mediaStyleObj, styleValue);
       }
 
       return {
@@ -1471,8 +1749,10 @@ $border-color: #e6e6e6;
         }
 
         :deep input.el-input__inner {
+          height: 100%;
           padding: 0;
           font-family: var(--input-font-family);
+          line-height: 1;
 
           // font-size: 16pt;
           color: $font-color;

+ 1 - 1
src/views/book/courseware/preview/components/write_base/WriteBasePreview.vue

@@ -4,7 +4,7 @@
     <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
 
     <div v-if="width" class="main">
-      <h2>{{ convertText('书写区') }}</h2>
+      <!-- <h2>{{ convertText('书写区') }}</h2> -->
       <template v-if="isJudgingRightWrong">
         <img
           v-if="data.write_base64[0]"

+ 8 - 0
src/views/personal_workbench/common/MenuPopover.vue

@@ -129,7 +129,11 @@ export default {
 
       .name {
         flex: 1;
+        min-width: 0;
         padding: 4px 8px 4px 4px;
+        word-break: break-word;
+        overflow-wrap: anywhere;
+        white-space: normal;
         border-radius: 4px;
       }
 
@@ -146,6 +150,10 @@ export default {
 
 <style lang="scss">
 .menu-popover {
+  box-sizing: border-box;
+
+  /* 最大宽度约为屏幕宽度的一半,文字过多时换行显示 */
+  max-width: 50vw;
   max-height: calc(100vh - 200px);
   overflow: auto;
 }

+ 123 - 2
src/web_preview/index.vue

@@ -5,7 +5,9 @@
         {{ courseware_info.book_name }}
       </div>
       <div class="courseware-top">
-        <span class="name-path">{{ courseware_info.name_path }}</span>
+        <span class="name-path nowrap-ellipsis" :title="courseware_info.name_path">
+          {{ courseware_info.name_path }}
+        </span>
         <span class="flow-nodename"></span>
         <slot name="middle" :courseware="courseware_info"></slot>
         <div class="group"></div>
@@ -62,7 +64,7 @@
         <!-- 教材章节树 -->
         <div class="courseware-tree">
           <div
-            v-for="{ id: nodeId, name, deep, is_leaf_chapter } in node_list"
+            v-for="{ id: nodeId, name, deep, is_leaf_chapter } in visibleNodeList"
             :key="nodeId"
             :class="['menu-item', { active: curSelectId === nodeId }, { courseware: isTrue(is_leaf_chapter) }]"
             :style="computedNameStyle(deep, isTrue(is_leaf_chapter))"
@@ -71,6 +73,14 @@
             <span class="name nowrap-ellipsis" :title="name">
               {{ name }}
             </span>
+            <!-- 章节收缩/展开图标 -->
+            <span v-if="!isTrue(is_leaf_chapter)" class="tree-arrow">
+              <i
+                :class="isChapterCollapsed(nodeId) ? 'el-icon-arrow-right' : 'el-icon-arrow-down'"
+                :title="isChapterCollapsed(nodeId) ? '展开' : '收缩'"
+                @click.stop="toggleChapterCollapse(nodeId)"
+              ></i>
+            </span>
           </div>
         </div>
       </aside>
@@ -359,6 +369,18 @@
                 inactive-value="zh-Hans"
                 inactive-text="简体"
               />
+              <div class="setting-item">
+                <span class="setting-item__label">字号</span>
+                <el-slider
+                  v-model="fontScalePercent"
+                  class="setting-item__slider"
+                  :min="80"
+                  :max="150"
+                  :step="5"
+                  :show-tooltip="false"
+                />
+                <span class="setting-item__value">{{ fontScalePercent }}%</span>
+              </div>
             </div>
           </div>
         </div>
@@ -444,6 +466,7 @@ import {
 import { getLocalStore } from '@/utils/auth';
 import { toggleFullScreen } from '@/utils/common';
 import { buildCoursewareStyle } from '@/views/book/courseware/preview/common/utils/coursewareStyle';
+import { scaleComponentListFontSize } from '@/views/book/courseware/preview/common/utils/fontSizeScale';
 
 export default {
   name: 'CommonPreview',
@@ -518,8 +541,10 @@ export default {
         },
       },
       node_list: [],
+      collapsedNodeIds: [], // 已收缩的章节节点 id
       data: { row_list: [] },
       component_list: [],
+      raw_component_list: [], // 未缩放字号的原始组件数据
       content_group_row_list: [],
       remark_list: [],
       remark_list_obj: {}, // 存放以组件为对象的数组
@@ -574,6 +599,7 @@ export default {
       langList: [],
       lang: 'ZH',
       chinese: 'zh-Hans',
+      fontScalePercent: 100, // 预览字号缩放百分比(80-150)
       showPinYin: 'false',
       unified_attrib: {},
       curSelectId: this.id,
@@ -633,6 +659,27 @@ export default {
     computedCommonPreviewStyle() {
       return buildCoursewareStyle(this.background, 'commonPreview');
     },
+    /**
+     * 教材章节树实际展示的节点列表:过滤掉已收缩节点下的所有子节点
+     * @returns {Array} 需要展示的章节节点列表
+     */
+    visibleNodeList() {
+      let collapsedDeep = 0; // 当前被收缩的祖先节点层级,0 表示没有收缩
+      return (this.node_list || []).filter((node) => {
+        const deep = Number(node.deep) || 1;
+        // 处于已收缩节点之下的子节点直接过滤掉
+        if (collapsedDeep && deep > collapsedDeep) return false;
+        collapsedDeep = this.collapsedNodeIds.includes(node.id) ? deep : 0;
+        return true;
+      });
+    },
+    /**
+     * 预览字号缩放比例
+     * @returns {number} 缩放比例,如 1.2
+     */
+    fontScaleRatio() {
+      return this.fontScalePercent / 100;
+    },
   },
   watch: {
     curSelectId() {
@@ -644,6 +691,9 @@ export default {
         this.getFeedback();
       }
     },
+    fontScaleRatio() {
+      this.applyFontScale();
+    },
   },
   mounted() {
     this.calcDrawerPosition();
@@ -766,9 +816,19 @@ 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();
         },
       );
     },
+    /**
+     * 按当前字号比例重新派生展示用的组件列表
+     */
+    applyFontScale() {
+      this.component_list = scaleComponentListFontSize(this.raw_component_list, this.fontScaleRatio);
+    },
     processHtmlString(pElements, styleConfig, isClear) {
       pElements.forEach((pElement) => {
         if (isClear) {
@@ -1143,6 +1203,26 @@ export default {
     },
 
     /**
+     * 判断章节节点是否已收缩
+     * @param {string} nodeId - 节点ID
+     * @returns {boolean} - 是否已收缩
+     */
+    isChapterCollapsed(nodeId) {
+      return this.collapsedNodeIds.includes(nodeId);
+    },
+    /**
+     * 切换章节节点的收缩/展开状态
+     * @param {string} nodeId - 节点ID
+     */
+    toggleChapterCollapse(nodeId) {
+      const index = this.collapsedNodeIds.indexOf(nodeId);
+      if (index === -1) {
+        this.collapsedNodeIds.push(nodeId);
+      } else {
+        this.collapsedNodeIds.splice(index, 1);
+      }
+    },
+    /**
      * 选择节点
      * @param {string} nodeId - 节点ID
      * @param {boolean} isLeaf - 是否是叶子节点
@@ -1152,6 +1232,8 @@ export default {
       if (this.curSelectId === nodeId) return;
       this.curSelectId = nodeId;
       this.selectNode(nodeId);
+      // 切换课件时恢复默认字号,避免上一次的字号缩放叠加到新课件的展示上
+      this.fontScalePercent = 100;
     },
     /**
      * 计算章节名称样式
@@ -1570,6 +1652,7 @@ $total-width: $courseware-width + $courseware-left-margin + $courseware-right-ma
       display: flex;
       justify-content: space-between;
       width: 360px;
+      min-width: 320px;
       padding: 4px 8px;
       border-right: $border;
     }
@@ -1843,6 +1926,21 @@ $total-width: $courseware-width + $courseware-left-margin + $courseware-right-ma
             }
           }
 
+          .tree-arrow {
+            display: inline-flex;
+            flex: none;
+            align-items: center;
+            justify-content: center;
+            width: 16px;
+            margin-left: 4px;
+            font-size: 12px;
+            color: #909399;
+
+            i {
+              cursor: pointer;
+            }
+          }
+
           .name {
             flex: 1;
             padding: 4px 8px 4px 4px;
@@ -1924,6 +2022,29 @@ $total-width: $courseware-width + $courseware-left-margin + $courseware-right-ma
           &-content {
             padding: 10px;
           }
+
+          &-item {
+            display: flex;
+            column-gap: 8px;
+            align-items: center;
+            margin-top: 20px;
+
+            &__label {
+              font-size: 14px;
+              white-space: nowrap;
+            }
+
+            &__slider {
+              flex: 1;
+            }
+
+            &__value {
+              width: 42px;
+              font-size: 14px;
+              color: #4095e5;
+              text-align: right;
+            }
+          }
         }
 
         .resource_box {