소스 검색

表格预览-兼容带样式后开启拼音预览

natasha 3 달 전
부모
커밋
bf0dc62fa7
1개의 변경된 파일451개의 추가작업 그리고 146개의 파일을 삭제
  1. 451 146
      src/views/book/courseware/preview/components/table/TablePreview.vue

+ 451 - 146
src/views/book/courseware/preview/components/table/TablePreview.vue

@@ -53,71 +53,124 @@
                 <div class="cell-wrap">
                   <div class="cell-wrap-content" :style="[tdStyle, computedRichStyle(col.content)]">
                     <template v-if="isEnable(data.property.view_pinyin)">
-                      <div
-                        v-for="(item, index) in col.model_pinyin"
-                        :key="index"
-                        class="pinyin-text"
-                        :class="[index === 0 ? 'pinyin-text-left' : '']"
-                      >
-                        <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' }]"
-                            />
-                          </template>
-                          <template v-else-if="data.property.fill_type === fillTypeList[1].value">
-                            <el-popover :key="index" placement="top" trigger="click">
-                              <div class="word-list">
+                      <template v-if="col.rich_text_list">
+                        <div
+                          v-for="(item, index) in col.rich_text_list"
+                          :key="index"
+                          class="pinyin-text"
+                          :class="[index === 0 ? 'pinyin-text-left' : '']"
+                        >
+                          <!-- 文字块:包含 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="pinyin" :style="getPinyinStyle(word)"> {{ word.pinyin }}</span>
+                                  <span class="py-char" :style="getCharStyle(word, item)">{{
+                                    convertText(word.text)
+                                  }}</span>
                                 </span>
-                              </div>
+                              </span>
+                              <template v-else-if="word.type === 'input'">
+                                <span v-if="data.property.pinyin_position === 'top'" class="pinyin">&nbsp;</span>
+                                <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="[{ width: Math.max(40, word.value.length * 21.3) + 'px' }]"
+                                  />
+                                </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="item.value"
-                                :readonly="true"
-                                :class="[...computedAnswerClass(item, i, j)]"
-                                :style="[{ width: Math.max(40, item.value.length * 21.3) + 'px' }]"
-                              />
-                            </el-popover>
-                          </template>
+                                    <el-input
+                                      slot="reference"
+                                      v-model="word.value"
+                                      :readonly="true"
+                                      :class="[...computedAnswerClass(word, i, j)]"
+                                      :style="[{ width: Math.max(40, word.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[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, item)"
+                                <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>
+                                <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">&nbsp;</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"
                             />
-                          </template>
-                          <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">&nbsp;</span>
-                        </template>
+                          </div>
+                          <!-- 换行符 -->
+                          <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
+
+                          <!-- 
                         <template v-else>
                           <span v-if="data.property.pinyin_position === 'top'" class="pinyin">
                             {{ item.pinyin.replace(/\s+/g, '') }}
@@ -128,8 +181,9 @@
                           <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">{{
                             item.pinyin.replace(/\s+/g, '')
                           }}</span>
-                        </template>
-                      </div>
+                        </template> -->
+                        </div>
+                      </template>
                     </template>
                     <template v-else>
                       <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
@@ -247,8 +301,6 @@
           class="table-box"
           :style="{
             width: isMobile ? '100%' : data.property.width + 'px',
-
-            height: data.property.height + 'px',
           }"
         >
           <table style="width: 100%; table-layout: fixed">
@@ -298,90 +350,75 @@
                   <div class="cell-wrap">
                     <div class="cell-wrap-content" :style="[tdStyle, computedRichStyle(col.content)]">
                       <template v-if="isEnable(data.property.view_pinyin)">
-                        <div
-                          v-for="(item, index) in col.model_pinyin"
-                          :key="index"
-                          class="pinyin-text"
-                          :class="[index === 0 ? 'pinyin-text-left' : '']"
-                        >
-                          <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' }]"
-                            />
-                          </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) }}
+                        <template v-if="col.rich_text_list">
+                          <div
+                            v-for="(item, index) in col.rich_text_list"
+                            :key="index"
+                            class="pinyin-text"
+                            :class="[index === 0 ? 'pinyin-text-left' : '']"
+                          >
+                            <!-- 文字块:包含 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="pinyin" :style="getPinyinStyle(word)"> {{ word.pinyin }}</span>
+                                    <span class="py-char" :style="getCharStyle(word, item)">{{
+                                      convertText(word.text)
+                                    }}</span>
+                                  </span>
                                 </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)">
+                                <template v-else-if="word.type === 'input'">
+                                  <span v-if="data.property.pinyin_position === 'top'" class="pinyin">&nbsp;</span>
+                                  <span
+                                    :key="`answer-${j}`"
+                                    class="right-answer"
+                                    v-show="computedAnswerText(word, i, j).length > 0"
+                                  >
+                                    {{ convertText(computedAnswerText(word, i, j)) }}
+                                  </span>
+                                  <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">&nbsp;</span>
+                                </template>
+                              </span>
+                            </span>
+                            <!-- 图片块 -->
+                            <div
+                              v-else-if="item.type === 'image'"
+                              :key="'image-' + index"
+                              :style="item.containerStyle"
+                              class="image-container"
+                            >
                               <img
-                                v-show="item.write_base64"
-                                style="background-color: #f4f4f4"
-                                :src="item.write_base64"
-                                alt="write-show"
+                                :src="item.src"
+                                :alt="item.alt"
+                                :width="item.width"
+                                :height="item.height"
+                                :style="item.imageStyle"
+                                class="inline-image"
                               />
-                            </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)"
-                            />
-                          </template> -->
-                            <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">&nbsp;</span>
-                          </template>
-                          <template v-else>
-                            <span v-if="data.property.pinyin_position === 'top'" class="pinyin">
-                              {{ item.pinyin.replace(/\s+/g, '') }}
-                            </span>
-                            <span :style="{ ...item.activeTextStyle }">
-                              {{ convertText(item.text) }}
-                            </span>
-                            <span v-if="data.property.pinyin_position === 'bottom'" class="pinyin">{{
-                              item.pinyin.replace(/\s+/g, '')
-                            }}</span>
-                          </template>
-                          <span
-                            :key="`answer-${j}`"
-                            class="right-answer"
-                            v-show="computedAnswerText(item, i, j).length > 0"
-                          >
-                            {{ convertText(computedAnswerText(item, i, j)) }}
-                          </span>
-                        </div>
+                            </div>
+                            <!-- 换行符 -->
+                            <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
+                          </div>
+                        </template>
                       </template>
                       <template v-else>
                         <div v-for="(item, index) in col.model_essay" :key="index" :style="[tdStyle]">
@@ -575,6 +612,234 @@ export default {
     this.computedTableCellShow();
   },
   methods: {
+    // 解析 richTextList 为可渲染的块
+    parsedBlocks(col) {
+      if (!col.rich_text_list || col.rich_text_list.length === 0) return [];
+      const blocks = [];
+      let textBlockIndex = 0;
+      let oldIndex = -1;
+      let paragraphIndex = 0;
+      const tagStack = [];
+
+      for (const item of col.rich_text_list) {
+        oldIndex += 1;
+
+        if (item.text && typeof item.text === 'string' && item.text.includes('<img')) {
+          blocks.push(this.parseImageBlock(item, tagStack));
+        } else if (item.is_style === 'true' || item.is_style === true) {
+          this.handleStyleTag(item, tagStack);
+        } else if (item.text === '\n') {
+          blocks.push({ type: 'newline' });
+          paragraphIndex += 1;
+        } else if (item.word_list && item.word_list.length > 0) {
+          const hasEmphasisDot = this.checkHasEmphasisDot(tagStack);
+          blocks.push(this.createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot));
+          textBlockIndex += 1;
+        }
+      }
+      return blocks;
+    },
+    // 解析图片块
+    parseImageBlock(item, tagStack) {
+      const imgMatch = item.text.match(/<img\s+([^>]*)\/?\s*>/i);
+      if (!imgMatch) return null;
+
+      const attrs = imgMatch[1];
+      const srcMatch = attrs.match(/src=["']([^"']*)["']/i);
+      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 containerStyleObj = {};
+      tagStack.forEach((tagItem) => {
+        if (tagItem.style) {
+          this.mergeStyleString(containerStyleObj, tagItem.style);
+        }
+      });
+
+      const imageStyleObj = {};
+      if (styleMatch) {
+        this.mergeStyleString(imageStyleObj, styleMatch[1]);
+      }
+
+      return {
+        type: 'image',
+        src: srcMatch ? srcMatch[1] : '',
+        alt: altMatch ? altMatch[1] : '',
+        width: widthMatch ? widthMatch[1] : null,
+        height: heightMatch ? heightMatch[1] : null,
+        containerStyle: containerStyleObj,
+        imageStyle: imageStyleObj,
+      };
+    },
+    // 处理样式标签
+    handleStyleTag(item, tagStack) {
+      const tagText = item.text || '';
+      const closeTagMatch = tagText.match(/^<\/(\w+)>$/);
+
+      if (closeTagMatch) {
+        const tagName = closeTagMatch[1];
+        this.removeTagFromStack(tagStack, tagName);
+      } else {
+        const openTagMatch = tagText.match(/^<(\w+)([^>]*)>$/);
+        if (openTagMatch) {
+          const tagName = openTagMatch[1];
+          const attrs = openTagMatch[2];
+          const { style, className } = this.extractTagInfo(attrs, tagName);
+          tagStack.push({ tag: tagName, style, className });
+        }
+      }
+    },
+    // 检查标签栈中是否有着重点样式
+    checkHasEmphasisDot(tagStack) {
+      return tagStack.some((tagItem) => {
+        if (!tagItem.className) return false;
+        return tagItem.className.includes('rich-text-emphasis-dot');
+      });
+    },
+    // 创建文本块
+    createTextBlock(item, tagStack, textBlockIndex, oldIndex, paragraphIndex, hasEmphasisDot) {
+      const combinedStyle = tagStack
+        .filter((tagItem) => tagItem.style)
+        .map((tagItem) => tagItem.style)
+        .join(';');
+
+      const styleObj = combinedStyle ? this.parseStyleToObject(combinedStyle) : null;
+
+      return {
+        type: 'text',
+        word_list: item.word_list,
+        index: textBlockIndex,
+        oldIndex,
+        paragraphIndex,
+        styleObj,
+        hasEmphasisDot,
+      };
+    },
+    // 拼音固定为拼音字体,跟随汉字的字号、颜色、粗细的样式
+    getPinyinStyle(item) {
+      const styles = {};
+
+      // 固定使用 League 字体
+      styles['font-family'] = 'League';
+
+      // 只继承字号和颜色
+      if (item.activeTextStyle) {
+        if (item.activeTextStyle.fontSize) {
+          styles['font-size'] = item.activeTextStyle.fontSize;
+        }
+        if (item.activeTextStyle.color) {
+          styles['color'] = item.activeTextStyle.color;
+        }
+      }
+
+      // 如果设置了全局字号,优先使用全局字号
+      if (this.isAllSetting && this.fontSize) {
+        styles['font-size'] = this.fontSize;
+        this.isAllSetting = false;
+      }
+
+      // 明确重置不应该继承的样式
+      styles['text-decoration'] = 'none';
+      styles['font-style'] = 'normal';
+      styles['letter-spacing'] = '0';
+
+      return styles;
+    },
+    // 获取单个字符的样式(包括着重点)
+    getCharStyle(word, block, charIndex) {
+      const baseStyle = { ...word.activeTextStyle };
+      baseStyle['font-size'] = baseStyle.fontSize;
+      baseStyle['font-family'] = baseStyle.fontFamily;
+
+      if (this.isAllSetting) {
+        baseStyle['font-size'] = this.fontSize;
+        baseStyle['font-family'] = this.fontFamily;
+        this.isAllSetting = false;
+      }
+
+      // 如果该文字块有着重点标记,应用到字符上
+      if (block.hasEmphasisDot) {
+        const letterSpacing = block.styleObj?.letterSpacing || '0';
+        const letterSpacingValue = parseFloat(letterSpacing) || 0;
+
+        baseStyle['border-bottom'] = 'none';
+        baseStyle['background-image'] = 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)';
+        baseStyle['background-repeat'] = 'repeat-x';
+        baseStyle['background-position'] = `${letterSpacingValue * -0.5}em 100%`;
+        baseStyle['background-size'] = `${1 + letterSpacingValue}em 0.3em`;
+        baseStyle['padding-bottom'] = '0.3em';
+        baseStyle['display'] = 'inline';
+      }
+
+      return baseStyle;
+    },
+    // 如果汉字有字间距,拼音与汉字左对齐,如果没有则居中对齐
+    getWordAlignItems(word, block) {
+      const letterSpacing = block.styleObj?.letterSpacing;
+      if (letterSpacing && letterSpacing !== '0' && letterSpacing !== '0px') {
+        return 'flex-start';
+      }
+      return 'center';
+    },
+    // 提取标签信息(样式和类名)
+    extractTagInfo(attrs, tagName) {
+      const styleMatch = attrs.match(/style=["']([^"']*)["']/);
+      let style = styleMatch ? styleMatch[1] : null;
+
+      const classMatch = attrs.match(/class=["']([^"']*)["']/);
+      const className = classMatch ? classMatch[1] : '';
+
+      if (!style) {
+        style = this.getDefaultTagStyle(tagName);
+      }
+
+      return { style, className };
+    },
+    // 获取标签默认样式
+    getDefaultTagStyle(tagName) {
+      const tagStyleMap = {
+        strong: 'font-weight: bold',
+        b: 'font-weight: bold',
+        em: 'font-style: italic',
+        i: 'font-style: italic',
+        u: 'text-decoration: underline',
+        s: 'text-decoration: line-through',
+        del: 'text-decoration: line-through',
+        sub: 'vertical-align: sub',
+        sup: 'vertical-align: super',
+      };
+      return tagStyleMap[tagName] || null;
+    },
+    // 解析样式字符串为对象
+    parseStyleToObject(styleStr) {
+      const styleObj = {};
+      if (!styleStr) return styleObj;
+
+      this.mergeStyleString(styleObj, styleStr);
+      return styleObj;
+    },
+    // 合并样式字符串到对象
+    mergeStyleString(styleObj, styleStr) {
+      styleStr.split(';').forEach((rule) => {
+        if (rule.trim()) {
+          const [prop, value] = rule.split(':').map((s) => s.trim());
+          if (prop && value) {
+            const camelProp = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+            styleObj[camelProp] = value;
+          }
+        }
+      });
+    },
+    removeTagFromStack(tagStack, tagName) {
+      for (let i = tagStack.length - 1; i >= 0; i--) {
+        if (tagStack[i].tag === tagName) {
+          tagStack.splice(i, 1);
+          break;
+        }
+      }
+    },
     toggle(status) {
       const index = this.statusList.findIndex((item) => status.crossAnswer === item);
       status.crossAnswer = index === this.statusList.length - 1 ? this.statusList[0] : this.statusList[index + 1];
@@ -602,13 +867,16 @@ export default {
       if (!this.isJudgingRightWrong) {
         this.answer.answer_list = this.data.answer_lists;
       }
-      this.data.option_list.forEach((item) => {
-        item.forEach((items) => {
+      this.data.option_list.forEach((item, index) => {
+        item.forEach((items, indexs) => {
           items.model_essay.forEach((li) => {
             if (li.type === 'input') {
               this.isHasInput = true;
             }
           });
+          if (items.rich_text_list) {
+            this.data.option_list[index][indexs].rich_text_list = this.parsedBlocks(items);
+          }
         });
       });
     },
@@ -942,7 +1210,7 @@ $border-color: #e6e6e6;
       position: relative;
       left: 0;
       display: inline-block;
-      height: 24px;
+      min-height: 24px;
       line-height: 24px;
       vertical-align: bottom;
       border-bottom: 1px solid $font-color;
@@ -975,6 +1243,43 @@ $border-color: #e6e6e6;
         }
       }
     }
+
+    .cell-wrap-content {
+      p {
+        display: inline-flex;
+        flex-wrap: wrap;
+        margin: 0.5em 0;
+      }
+
+      span {
+        display: inline-flex;
+      }
+
+      .pinyin-sentence {
+        display: inline;
+        white-space: normal;
+      }
+
+      .image-container {
+        display: block;
+
+        .inline-image {
+          display: inline-block;
+          max-width: 100%;
+          height: auto;
+        }
+      }
+    }
+
+    :deep .el-input--small .el-input__inner {
+      height: 100%;
+      line-height: 1;
+    }
+
+    :deep audio,
+    :deep video {
+      max-width: 100%;
+    }
   }
 }