فهرست منبع

富文本气泡功能

dsy 5 ماه پیش
والد
کامیت
0b169b506d

+ 1 - 1
src/components/ExplanatoryNoteDialog.vue

@@ -2,7 +2,7 @@
   <el-dialog :title="'编辑' + titleText" :visible.sync="visible" width="680px" @close="dialogClose()">
     <RichText
       v-model="richData.dataStr"
-      toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright image"
+      toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright image media link"
       :wordlimit-num="false"
       :height="240"
       placeholder="输入内容"

+ 505 - 138
src/components/PinyinText.vue

@@ -1,70 +1,164 @@
 <template>
   <div class="pinyin-area" :style="{ 'text-align': pinyinOverallPosition, padding: pinyinPadding }">
-    <div v-for="(paragraph, i) in paragraphList" :key="i" class="pinyin-paragraph">
-      <span
-        v-for="(sentence, j) in paragraph"
-        :key="j"
-        class="pinyin-sentence"
-        :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
-      >
-        <span v-for="(item, k) in sentence" :key="k" class="pinyin-text">
-          <span
-            :class="{ active: visible && word_index == k && paragraph_index === i && sentence_index === j }"
-            :title="isPreview ? '' : '点击校对'"
-            :style="{
-              cursor: isPreview ? '' : 'pointer',
-              'align-items': k == 0 ? 'flex-start' : 'center',
-            }"
-            @click="correctPinyin(item, i, j, k)"
-          >
-            <span v-if="pinyinPosition === 'top'" class="pinyin" :style="{ 'font-size': pinyinSize }">{{
-              item.pinyin.replace(/\s+/g, '')
-            }}</span>
-            <span class="py-char" :style="textStyle(item)">{{ convertText(item.text) }}</span>
-            <span v-if="pinyinPosition !== 'top'" class="pinyin" :style="{ 'font-size': pinyinSize }">{{
-              item.pinyin.replace(/\s+/g, '')
-            }}</span>
+    <AudioPlay
+      v-if="isEnable(isEnableVoice)"
+      :file-id="audioFileId"
+      :theme-color="unifiedAttrib && unifiedAttrib.topic_color ? unifiedAttrib.topic_color : ''"
+    />
+
+    <!-- 新规则:使用 richTextList -->
+    <div v-if="richTextList && richTextList.length > 0" class="rich-text-container">
+      <template v-for="(block, index) in parsedBlocks">
+        <!-- 文字块:包含 word_list -->
+        <span
+          v-if="block.type === 'text'"
+          :key="'text-' + index"
+          class="pinyin-sentence"
+          :style="[{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }, block.styleObj]"
+        >
+          <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text">
+            <span
+              :title="isPreview ? '' : '点击校对'"
+              :style="{
+                cursor: isPreview ? '' : 'pointer',
+              }"
+              @click="!isPreview && handleWordClick(word, block.paragraphIndex, block.oldIndex, wIndex)"
+              @mouseover="isPreview && handlePreviewHover(word, $event)"
+              @mouseleave="onLeave"
+            >
+              <span v-for="(char, cIndex) in Array.from(word.text)" :key="cIndex">
+                <span
+                  :style="{
+                    flexDirection: pinyinPosition === 'top' ? 'column' : 'column-reverse',
+                    'align-items': getWordAlignItems(word, block),
+                  }"
+                >
+                  <span class="pinyin" :style="getPinyinStyle(word)"> {{ getCharPinyin(word, cIndex) }}</span>
+                  <span class="py-char" :style="getCharStyle(word, block, cIndex)">{{ convertText(char) }}</span>
+                </span>
+              </span>
+            </span>
           </span>
         </span>
-      </span>
+        <!-- 图片块 -->
+        <div
+          v-else-if="block.type === 'image'"
+          :key="'image-' + index"
+          :style="block.containerStyle"
+          class="image-container"
+        >
+          <img
+            :src="block.src"
+            :alt="block.alt"
+            :width="block.width"
+            :height="block.height"
+            :style="block.imageStyle"
+            class="inline-image"
+          />
+        </div>
+        <!-- 换行符 -->
+        <br v-else-if="block.type === 'newline'" :key="'newline-' + index" />
+      </template>
     </div>
 
+    <!-- 老规则:使用 paragraphList -->
+    <template v-else-if="paragraphList && paragraphList.length > 0">
+      <div v-for="(paragraph, pIndex) in paragraphList" :key="pIndex" class="pinyin-paragraph">
+        <div
+          v-for="(sentence, sIndex) in paragraph"
+          :key="sIndex"
+          class="pinyin-sentence"
+          :style="{ 'align-items': pinyinPosition === 'top' ? 'flex-end' : 'flex-start' }"
+        >
+          <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
+            <span
+              :title="isPreview ? '' : '点击校对'"
+              :style="{
+                cursor: isPreview ? '' : 'pointer',
+                'align-items': wIndex == 0 ? 'flex-start' : 'center',
+              }"
+              @click="!isPreview && handleWordClick(word, block.paragraphIndex, block.oldIndex, wIndex)"
+              @mouseover="isPreview && handlePreviewHover(word, $event)"
+              @mouseleave="onLeave"
+            >
+              <span
+                v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
+                class="pinyin"
+                :style="{ 'font-size': pinyinSize }"
+              >
+                {{ getPinyinText(word) }}</span
+              >
+              <span class="py-char" :style="textStyle(word)">{{ convertText(word.text) }}</span>
+              <span
+                v-if="pinyinPosition !== 'top' && hasPinyinInParagraph(sIndex)"
+                class="pinyin"
+                :style="{ 'font-size': pinyinSize }"
+              >
+                {{ getPinyinText(word) }}</span
+              >
+            </span>
+          </span>
+        </div>
+      </div>
+    </template>
+
     <CorrectPinyin
       :visible.sync="visible"
+      :new-version="newVersion"
       :select-content="selectContent"
       :component-type="componentType"
       @fillTonePinyin="fillTonePinyin"
     />
 
-    <el-dialog
-      ref="optimizedDialog"
-      title=""
-      :visible.sync="noteDialogVisible"
-      width="680px"
-      :style="dialogStyle"
-      :close-on-click-modal="false"
-      destroy-on-close
-      @close="noteDialogVisible = false"
+    <!-- 气泡弹窗 -->
+    <el-popover
+      ref="notePopover"
+      :popper-options="{ boundariesElement: 'viewport' }"
+      placement="bottom-start"
+      trigger="manual"
     >
-      <span v-html="sanitizeHTML(note)"></span>
-    </el-dialog>
+      <div class="popover-content" v-html="sanitizeHTML(note)"></div>
+    </el-popover>
   </div>
 </template>
 
 <script>
 import CorrectPinyin from '@/views/book/courseware/create/components/base/common/CorrectPinyin.vue';
 import { sanitizeHTML } from '@/utils/common';
+import { isEnable } from '@/views/book/courseware/data/common';
+import AudioPlay from '@/views/book/courseware/preview/components/character_base/components/AudioPlay.vue';
 
 export default {
   name: 'PinyinText',
   components: {
     CorrectPinyin,
+    AudioPlay,
   },
   inject: ['convertText'],
   props: {
+    isEnableVoice: {
+      type: String,
+      default: 'false',
+    },
+    audioFileId: {
+      type: String,
+      default: '',
+    },
+    unifiedAttrib: {
+      type: Object,
+      default: () => ({}),
+    },
     paragraphList: {
       type: Array,
-      required: true,
+      default: () => [],
+    },
+    richTextList: {
+      type: Array,
+      default: () => [],
+    },
+    richTextNoteList: {
+      type: Array,
+      default: () => [],
     },
     pinyinPosition: {
       type: String,
@@ -101,31 +195,55 @@ export default {
   },
   data() {
     return {
+      isEnable,
       sanitizeHTML,
-      paragraph_list: [],
       visible: false,
       selectContent: {},
       paragraph_index: 0,
       sentence_index: 0,
       word_index: 0,
-      noteDialogVisible: false,
       note: '',
-      dialogStyle: {
-        position: 'fixed',
-        top: '0',
-        left: '0',
-        margin: '0',
-      },
       isAllSetting: false,
     };
   },
   computed: {
+    newVersion() {
+      return this.richTextList && this.richTextList.length > 0;
+    },
     styleWatch() {
       return {
         fontSize: this.fontSize,
         fontFamily: this.fontFamily,
       };
     },
+    // 解析 richTextList 为可渲染的块
+    parsedBlocks() {
+      if (!this.richTextList || this.richTextList.length === 0) return [];
+      const blocks = [];
+      let textBlockIndex = 0;
+      let oldIndex = -1;
+      let paragraphIndex = 0;
+      const tagStack = [];
+
+      for (const item of this.richTextList) {
+        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;
+    },
   },
   watch: {
     styleWatch: {
@@ -136,84 +254,281 @@ export default {
       deep: true,
     },
   },
-
   methods: {
+    // 统一处理点击事件
+    handleWordClick(item, pIndex, sIndex, wIndex) {
+      if (item) {
+        this.visible = true;
+        this.selectContent = item;
+        this.paragraph_index = pIndex;
+        this.sentence_index = sIndex;
+        this.word_index = wIndex;
+      }
+    },
+    // 预览模式:悬停触发气泡
+    handlePreviewHover(item, event) {
+      if (!item) return;
+      let noteContent = '';
+      // 查找注释内容
+      if (item.annota_id && this.richTextNoteList) {
+        const noteItem = this.richTextNoteList.find((p) => p.id === item.annota_id);
+        if (noteItem) {
+          noteContent = noteItem.dataStr || noteItem.note || '';
+        }
+      } else if (item.note) {
+        noteContent = item.note;
+      }
+      if (noteContent) {
+        this.note = noteContent;
+        const refEl = this.$refs.notePopover;
+        refEl.referenceElm = event.target;
+        refEl.showPopper = true;
+        // 强制重新计算定位
+        if (refEl.updatePopper) {
+          refEl.updatePopper();
+        }
+      }
+    },
+    onLeave() {
+      this.$refs.notePopover.showPopper = false;
+    },
+
+    // 检查标签栈中是否有着重点样式
+    checkHasEmphasisDot(tagStack) {
+      return tagStack.some((tagItem) => {
+        if (!tagItem.className) return false;
+        return tagItem.className.includes('rich-text-emphasis-dot');
+      });
+    },
+
+    // 解析图片块
+    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,
+      };
+    },
+
+    // 合并样式字符串到对象
+    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;
+          }
+        }
+      });
+    },
+
+    // 处理样式标签
+    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 });
+        }
+      }
+    },
+
+    // 提取标签信息(样式和类名)
+    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;
+    },
+
+    // 创建文本块
+    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,
+      };
+    },
+
+    // 解析样式字符串为对象
+    parseStyleToObject(styleStr) {
+      const styleObj = {};
+      if (!styleStr) return styleObj;
+
+      this.mergeStyleString(styleObj, styleStr);
+      return styleObj;
+    },
+
+    // 获取单个字符的样式(包括着重点)
+    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;
+    },
+    // 兼容历史数据
+    getPinyinText(item) {
+      return this.checkShowPinyin(item.showPinyin) ? item.pinyin.replace(/\s+/g, '') : '\u200B';
+    },
+    // 拼音与汉字一一对应
+    getCharPinyin(word, charIndex) {
+      if (!this.checkShowPinyin(word.showPinyin)) {
+        return '\u200B';
+      }
+      // 优先使用新的 pinyin_list 字段(与字符一一对应)
+      if (word.pinyin_list && Array.isArray(word.pinyin_list)) {
+        return word.pinyin_list[charIndex] || '\u200B';
+      }
+      // 兼容旧数据:使用 pinyin 字段
+      const pinyinList = word.pinyin ? word.pinyin.trim().split(/\s+/) : [];
+      return pinyinList[charIndex] || '\u200B';
+    },
+    // 如果汉字有字间距,拼音与汉字左对齐,如果没有则居中对齐
+    getWordAlignItems(word, block) {
+      const letterSpacing = block.styleObj?.letterSpacing;
+      if (letterSpacing && letterSpacing !== '0' && letterSpacing !== '0px') {
+        return 'flex-start';
+      }
+      return 'center';
+    },
+    // 拼音固定为拼音字体,跟随汉字的字号、颜色、粗细的样式
+    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;
+    },
+    removeTagFromStack(tagStack, tagName) {
+      for (let i = tagStack.length - 1; i >= 0; i--) {
+        if (tagStack[i].tag === tagName) {
+          tagStack.splice(i, 1);
+          break;
+        }
+      }
+    },
     textStyle(item) {
       const styles = { ...item.activeTextStyle };
       styles['font-size'] = styles.fontSize;
       styles['font-family'] = styles.fontFamily;
 
-      // if (this.fontSize) styles['font-size'] = this.fontSize;
-      // if (this.fontFamily) styles['font-family'] = this.fontFamily;
-      if (this.isAllSetting || !styles.fontSize) styles['font-size'] = this.fontSize;
-      if (this.isAllSetting || !styles.fontFamily) styles['font-family'] = this.fontFamily;
+      if (this.isAllSetting) styles['font-size'] = this.fontSize;
+      if (this.isAllSetting) styles['font-family'] = this.fontFamily;
       this.isAllSetting = false;
       return styles;
     },
-    // 校对拼音
-    correctPinyin(item, i, j, k) {
-      if (this.isPreview) {
-        if (item.note) {
-          this.note = item.note;
-
-          this.noteDialogVisible = true;
-          this.$nextTick(() => {
-            const dialogElement = this.$refs.optimizedDialog;
-            // 确保对话框DOM已渲染
-            if (!dialogElement) {
-              return;
-            }
-            // 获取对话框内容区域的DOM元素
-            const dialogContent = dialogElement.$el.querySelector('.el-dialog');
-            if (!dialogContent) {
-              return;
-            }
-            const dialogRect = dialogContent.getBoundingClientRect();
-            const dialogWidth = dialogRect.width;
-            const dialogHeight = dialogRect.height;
-            const padding = 10; // 安全边距
-
-            const clickX = event.clientX;
-            const clickY = event.clientY;
-
-            const windowWidth = window.innerWidth;
-            const windowHeight = window.innerHeight;
-
-            // 水平定位 - 中心对齐
-            let left = clickX - dialogWidth / 2;
-            // 边界检查
-            left = Math.max(padding, Math.min(left, windowWidth - dialogWidth - padding));
-
-            // 垂直定位 - 点击位置作为下边界中心
-            let top = clickY - dialogHeight;
-            // 上方空间不足时,改为向下展开
-            if (top < padding) {
-              top = clickY + padding;
-              // 如果向下展开会超出屏幕,则贴底部显示
-              if (top + dialogHeight > windowHeight - padding) {
-                top = windowHeight - dialogHeight - padding;
-              }
-            }
-
-            this.dialogStyle = {
-              position: 'fixed',
-              top: `${top - 20}px`,
-              left: `${left}px`,
-              margin: '0',
-              transform: 'none',
-            };
-          });
-        }
-        return;
-      } // 如果是预览模式,不操作
-      if (item) {
-        this.visible = true;
-        this.selectContent = item;
-        this.paragraph_index = i;
-        this.sentence_index = j;
-        this.word_index = k;
-      }
-    },
     // 回填校对后的拼音
     fillTonePinyin(dataContent) {
       this.$emit('fillCorrectPinyin', {
@@ -221,45 +536,97 @@ export default {
         i: this.paragraph_index,
         j: this.sentence_index,
         k: this.word_index,
+        newVersion: this.newVersion,
       });
     },
+    // 如段落有拼音,则返回true ,否则不显示拼音行
+    hasPinyinInParagraph(paragraphIndex) {
+      const paragraph = this.paragraphList[paragraphIndex];
+      return paragraph.some((sentence) => sentence.some((item) => this.checkShowPinyin(item.showPinyin)));
+    },
+    // 兼容历史数据,没有 showPinyin 字段的,则默认为 true
+    checkShowPinyin(showPinyin) {
+      if (showPinyin === undefined || showPinyin === null) {
+        return true;
+      }
+      return this.isEnable(showPinyin);
+    },
   },
 };
 </script>
 
 <style lang="scss" scoped>
 .pinyin-area {
-  .pinyin-paragraph {
+  // 新规则的容器样式
+  .rich-text-container {
+    p {
+      display: inline-flex;
+      flex-wrap: wrap;
+      margin: 0.5em 0;
+    }
+
+    span {
+      display: inline-flex;
+    }
+
     .pinyin-sentence {
-      .pinyin-text {
-        padding: 0 2px;
-        font-size: 16px;
-        text-wrap: pretty;
-        hanging-punctuation: allow-end;
-
-        > span {
-          display: inline-flex;
-          flex-direction: column;
-          align-items: center;
-        }
+      display: inline-flex;
+      flex-wrap: wrap;
+    }
 
-        .py-char {
-          ruby-align: center;
-        }
+    .image-container {
+      display: block;
 
-        .pinyin {
-          font-family: 'PINYIN-B';
-          font-size: 12px;
-          font-weight: lighter;
-          line-height: 12px;
-          color: $font-color;
-        }
+      .inline-image {
+        display: inline-block;
+        max-width: 100%;
+        height: auto;
       }
     }
   }
 
-  .active {
-    color: rgb(242, 85, 90) !important;
+  .pinyin-paragraph {
+    .pinyin-sentence {
+      display: inline-flex;
+      flex-wrap: wrap;
+    }
+  }
+
+  .pinyin-text {
+    padding: 0 2px;
+
+    // font-size: 16px;
+    text-wrap: pretty;
+    hanging-punctuation: allow-end;
+
+    > span {
+      display: inline-flex;
+    }
+
+    .py-char {
+      ruby-align: center;
+      line-height: 1em;
+    }
+
+    .pinyin {
+      display: inline-block;
+      min-height: 12px;
+      font-family: 'League';
+      line-height: 1em;
+      text-decoration: none !important;
+
+      // 防止继承父元素的删除线
+      text-decoration-skip-ink: none;
+    }
+  }
+
+  :deep(.annotation-popover) {
+    z-index: 9999 !important; /* 确保气泡在最上层 */
+    min-width: 200px;
+    max-width: 400px;
+    padding: 12px;
+    border-radius: 8px;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
   }
 }
 </style>

+ 612 - 61
src/components/RichText.vue

@@ -14,7 +14,7 @@
     <div v-show="isShow" :style="contentmenu" class="contentmenu">
       <div v-if="isViewNote" @click="openExplanatoryNoteDialog">
         <SvgIcon icon-class="mark" size="14" />
-        <span class="button"> 编辑注释</span>
+        <span class="button"> 气泡</span>
       </div>
       <div v-else>
         <SvgIcon icon-class="slice" size="16" @click="setFill" />
@@ -65,6 +65,7 @@ export default {
     Editor,
     MathDialog,
   },
+  inject: ['processHtmlString'],
   inheritAttrs: false,
   props: {
     inline: {
@@ -91,7 +92,7 @@ export default {
       type: [String, Boolean],
       /* eslint-disable max-len */
       default:
-        'fontselect fontsizeselect forecolor backcolor | lineheight underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr mathjax',
+        'fontselect fontsizeselect forecolor backcolor lineheight paragraphSpacing letterSpacing indent outdent customUnderline bold italic strikethrough alignleft aligncenter alignright bullist numlist dotEmphasis image media link blockquote hr mathjax',
     },
     wordlimitNum: {
       type: [Number, Boolean],
@@ -109,6 +110,10 @@ export default {
       type: String,
       default: 'Arial',
     },
+    fontColor: {
+      type: String,
+      default: '#000000',
+    },
     isHasSpace: {
       type: Boolean,
       default: false,
@@ -129,6 +134,10 @@ export default {
       type: Number,
       default: null,
     },
+    isTitle: {
+      type: Boolean,
+      default: false,
+    },
   },
   data() {
     return {
@@ -141,12 +150,29 @@ export default {
         left: 0,
       },
       id: getRandomNumber(),
+      editorIsInited: false,
+      editorBeforeInitConfig: {},
       init: {
         content_style: `
           mjx-container, mjx-container * {
             font-size: 16px !important; /* 强制固定字体 */
             line-height: 1.2 !important; /* 避免行高影响 */
-          }`, // 解决公式每点击一次字体就变大
+          }
+          .rich-text-emphasis-dot {
+           --letter-spacing-value: attr(data-letter-spacing number, 0);
+            border-bottom: none;
+            background-image: radial-gradient(
+              circle at center,
+              currentColor 0.15em,  /* 圆点大小相对于字体 */
+              transparent 0.16em
+            );
+            background-size: calc((1 + var(--letter-spacing-value)) * 1em) 0.3em;
+            background-repeat: repeat-x;
+            background-position: calc(var(--letter-spacing-value) * -0.5em) 100%;
+            padding-bottom: 0.3em; /* 间距也相对于字体 */
+            display: inline;
+          }
+          `, // 解决公式每点击一次字体就变大
         valid_elements: '*[*]', // 允许所有标签和属性
         valid_children: '+body[style]', // 允许 MathJax 的样式
         extended_valid_elements: 'span[*],mjx-container[*],svg[*],path[*]', // 明确允许 MathJax 标签
@@ -164,17 +190,78 @@ export default {
         plugins: 'link lists image hr media autoresize ax_wordlimit paste', // 移除 lineheight
         toolbar: this.toolbar, // 工具栏
         lineheight_formats: '0.5 1.0 1.2 1.5 2.0 2.5 3.0', // 行高选项(倍数)
+        paragraphheight_formats: [1.0, 1.2, 1.5, 2.0, 2.5, 3.0], // 段落间距
+        letterSpacing_formats: [1.0, 1.2, 1.5, 2.0, 2.5, 3.0], // 字间距选项
         contextmenu: false, // 右键菜单
         menubar: false, // 菜单栏
         branding: false, // 品牌
         statusbar: false, // 状态栏
         entity_encoding: 'raw', // raw不编码任何字符;named: 使用命名实体(如 &nbsp;);numeric: 使用数字实体(如 &#160;)
+        target_list: false,
+        default_link_target: '_blank', // 或 '_self'
         setup: (editor) => {
+          editor.on('GetContent', (e) => {
+            if (e.format === 'html') {
+              e.content = this.smartPreserveLineBreaks(editor, e.content);
+            }
+          });
+
           let isRendered = false; // 标记是否已渲染
-          let that = this;
           editor.on('init', () => {
             editor.getBody().style.fontSize = this.init.font_size; // 设置默认字体大小
             editor.getBody().style.fontFamily = this.init.font_family; // 设置默认字体
+            editor.getBody().style.color = this.fontColor; // 设置默认字体颜色
+
+            this.init.paragraphheight_formats.forEach((config) => {
+              const formatName = `paragraphSpacing${config}_em`;
+              editor.formatter.register(formatName, {
+                selector: 'p',
+                styles: { 'margin-bottom': `${config}em` },
+              });
+            });
+            this.init.letterSpacing_formats.forEach((config) => {
+              const formatName = `letterSpacing${config}_em`;
+              editor.formatter.register(formatName, {
+                inline: 'span',
+                styles: { 'letter-spacing': `${config}em` },
+                attributes: { 'data-letter-spacing': `${config}` },
+                wrapper: true,
+                remove_similar: true,
+              });
+            });
+
+            if (!editor.formatter.has('emphasisDot')) {
+              editor.formatter.register('emphasisDot', {
+                inline: 'span',
+                classes: 'rich-text-emphasis-dot',
+                // styles: {
+                //   'border-bottom': 'none',
+                //   'background-image':
+                //     'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)' /* 圆点大小相对于字体 */,
+                //   'background-size': '1em 0.3em' /* 间距相对于字体大小,高度相对字体 */,
+                //   'background-repeat': 'repeat-x',
+                //   'background-position': '0 100%',
+                //   'padding-bottom': '0.3em' /* 间距也相对于字体 */,
+                //   display: 'inline',
+                // },
+                wrapper: true,
+                remove_similar: true,
+              });
+            }
+            if (!editor.formatter.has('coloredunderline')) {
+              editor.formatter.register('coloredunderline', {
+                inline: 'span',
+                styles: {
+                  'border-bottom': `0.1em solid #000000`, // 要固定线粗细的话,就使用2px
+                  'padding-bottom': '1px',
+                },
+                merge_siblings: false,
+                exact: true, // 添加 exact 属性
+                wrapper: true,
+                remove_similar: true,
+              });
+            }
+            this.editorIsInited = true;
           });
 
           // 自定义行高下拉(因为没有内置 lineheight 插件)
@@ -206,14 +293,190 @@ export default {
             },
           });
 
-          editor.on('click', () => {
-            if (editor?.queryCommandState('ToggleToolbarDrawer')) {
-              editor.execCommand('ToggleToolbarDrawer');
-            }
-            if (!isRendered && window.MathJax) {
-              isRendered = true;
-              window.MathJax.typesetPromise([editor.getBody()]);
-            }
+          // 重写下划线
+          editor.ui.registry.addButton('customUnderline', {
+            icon: 'underline',
+            tooltip: '下划线',
+            onAction: () => {
+              editor.windowManager.open({
+                title: '选择下划线颜色',
+                body: {
+                  type: 'panel',
+                  items: [
+                    {
+                      type: 'colorpicker',
+                      name: 'color',
+                      label: '颜色',
+                      value: '#000000',
+                    },
+                  ],
+                },
+                buttons: [
+                  {
+                    type: 'custom',
+                    name: 'cancelUndeline',
+                    text: '取消下划线',
+                  },
+                  {
+                    type: 'cancel',
+                    text: '取消',
+                  },
+                  {
+                    type: 'submit',
+                    text: '确定',
+                    buttonType: 'primary',
+                    enabled: true,
+                  },
+                ],
+                initialData: {
+                  color: '#000000',
+                },
+                onSubmit: (api) => {
+                  const color = api.getData().color;
+                  // 加下划线
+                  // editor.execCommand('Underline');
+                  // 注册自定义格式
+                  editor.formatter.register('coloredunderline', {
+                    inline: 'span',
+                    styles: {
+                      'border-bottom': `0.1em solid ${color}`, // 要固定线粗细的话,就使用2px
+                      'padding-bottom': '1px',
+                    },
+                    merge_siblings: false,
+                    exact: true, // 添加 exact 属性
+                    wrapper: true,
+                    remove_similar: true,
+                  });
+
+                  // 应用格式
+                  editor.formatter.apply('coloredunderline');
+
+                  api.close();
+                },
+                onAction: (api, details) => {
+                  if (details.name === 'cancelUndeline') {
+                    editor.formatter.remove('coloredunderline');
+                    api.close();
+                  }
+                },
+              });
+            },
+          });
+
+          // 添加段落间距下拉菜单
+          editor.ui.registry.addMenuButton('paragraphSpacing', {
+            icon: 'paragraph',
+            // text: '段落间距',
+            tooltip: '段落间距',
+            fetch: (callback) => {
+              const items = [];
+
+              // 动态生成菜单项
+              this.init.paragraphheight_formats.forEach((config) => {
+                const formatName = `paragraphSpacing${config}_em`;
+                items.push({
+                  type: 'menuitem',
+                  text: `${config}`,
+                  onAction: () => {
+                    // 先清除其他间距格式
+                    this.init.paragraphheight_formats.forEach((cfg) => {
+                      const fmtName = `paragraphSpacing${cfg}_em`;
+                      editor.formatter.remove(fmtName);
+                    });
+                    // 应用当前选择的间距
+                    editor.formatter.apply(formatName);
+                  },
+                });
+              });
+
+              // 添加清除间距选项
+              items.push({
+                type: 'separator', // 分隔线
+              });
+              items.push({
+                type: 'menuitem',
+                text: '清除间距',
+                onAction: () => {
+                  // 清除所有间距格式
+                  this.init.paragraphheight_formats.forEach((config) => {
+                    const formatName = `paragraphSpacing${config}_em`;
+                    editor.formatter.remove(formatName);
+                  });
+                },
+              });
+
+              callback(items);
+            },
+          });
+
+          // 注册自定义字间距图标
+          editor.ui.registry.addIcon(
+            'letter-spacing-icon',
+            `<svg viewBox="4 4 18 18" width="44" height="24">
+              <text x="17" y="18" text-anchor="middle" font-size="14" font-weight="bold" fill="#000">A</text>
+              <path d="M12 10 L6 10 M6 10 L7.5 8.5 M6 10 L7.5 11.5" stroke="#1E90FF" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
+              <path d="M22 10 L28 10 M28 10 L26.5 8.5 M28 10 L26.5 11.5" stroke="#1E90FF" stroke-width="1.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
+            </svg>`,
+          );
+
+          // 添加字间距下拉菜单
+          editor.ui.registry.addMenuButton('letterSpacing', {
+            icon: 'letter-spacing-icon',
+            // text: '字间距',
+            tooltip: '字间距',
+            fetch: (callback) => {
+              const items = [];
+
+              // 动态生成菜单项
+              this.init.letterSpacing_formats.forEach((config) => {
+                const formatName = `letterSpacing${config}_em`;
+                items.push({
+                  type: 'menuitem',
+                  text: `${config}`,
+                  onAction: () => {
+                    // 先清除其他字间距格式
+                    this.init.letterSpacing_formats.forEach((cfg) => {
+                      const fmtName = `letterSpacing${cfg}_em`;
+                      editor.formatter.remove(fmtName);
+                    });
+                    // 应用当前选择的字间距
+                    editor.formatter.apply(formatName);
+                  },
+                });
+              });
+
+              // 添加清除字间距选项
+              items.push({
+                type: 'separator', // 分隔线
+              });
+              items.push({
+                type: 'menuitem',
+                text: '清除字间距',
+                onAction: () => {
+                  // 清除所有字间距格式
+                  this.init.letterSpacing_formats.forEach((config) => {
+                    const formatName = `letterSpacing${config}_em`;
+                    editor.formatter.remove(formatName);
+                  });
+                },
+              });
+
+              callback(items);
+            },
+          });
+          // 添加 添加着重点 按钮
+          editor.ui.registry.addButton('dotEmphasis', {
+            text: '●',
+            tooltip: '着重点',
+            onAction: () => {
+              const editor = tinymce.activeEditor;
+
+              if (editor.formatter.match('emphasisDot')) {
+                editor.formatter.remove('emphasisDot');
+              } else {
+                editor.formatter.apply('emphasisDot');
+              }
+            },
           });
 
           // 添加 MathJax 按钮
@@ -225,6 +488,16 @@ export default {
             },
           });
 
+          editor.on('click', () => {
+            if (editor?.queryCommandState('ToggleToolbarDrawer')) {
+              editor.execCommand('ToggleToolbarDrawer');
+            }
+            if (!isRendered && window.MathJax) {
+              isRendered = true;
+              window.MathJax.typesetPromise([editor.getBody()]);
+            }
+          });
+
           // 内容变化时重新渲染公式
           editor.on('change', () => this.renderMath());
 
@@ -233,7 +506,7 @@ export default {
             if (e.keyCode === 8 || e.keyCode === 46) {
               // 延迟执行以确保删除已完成
               setTimeout(() => {
-                that.cleanupRemovedAnnotations(editor);
+                this.cleanupRemovedAnnotations(editor);
               }, 500);
             }
           });
@@ -241,7 +514,7 @@ export default {
           // 也可以监听剪切操作
           editor.on('Cut', () => {
             setTimeout(() => {
-              that.cleanupRemovedAnnotations(editor);
+              this.cleanupRemovedAnnotations(editor);
             }, 500);
           });
           // editor.on('NodeChange', function (e) {
@@ -253,7 +526,7 @@ export default {
           //   ) {
           //     const annotationId = e.element.getAttribute('data-annotation-id');
           //     e.element.parentNode.removeChild(e.element);
-          //     that.$emit('selectContentSetMemo', null, annotationId);
+          //     this.$emit('selectContentSetMemo', null, annotationId);
           //   }
           // });
         },
@@ -264,7 +537,7 @@ export default {
           'Arial=arial,helvetica,sans-serif;' +
           'Times New Roman=times new roman,times,serif;' +
           '拼音=League;',
-        fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
+        fontsize_formats: '8pt 10pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
         // 字数限制
         ax_wordlimit_num: this.wordlimitNum,
         ax_wordlimit_callback(editor) {
@@ -276,11 +549,13 @@ export default {
         file_picker_callback: this.filePickerCallback,
         init_instance_callback: this.isFill || this.isViewNote ? this.initInstanceCallback : '',
         paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
+        paste_as_text: true, // 默认作为纯文本粘贴
         // 粘贴预处理
         paste_preprocess(plugin, args) {
           let content = args.content;
           // 使用正则表达式去掉 style 中的 background 属性
           content = content.replace(/background(-color)?:[^;]+;/g, '');
+          content = content.replace(/\t/g, '    '); // 将制表符替换为4个空格
           args.content = content;
         },
         // 指定在 WebKit 中粘贴时要保留的样式
@@ -289,6 +564,7 @@ export default {
       },
     };
   },
+  computed: {},
   watch: {
     isViewNote: {
       handler(newVal) {
@@ -346,6 +622,29 @@ export default {
         }
       },
     },
+    isTitle: {
+      handler(newVal) {
+        this.displayToolbar(newVal);
+      },
+    },
+    // 未初始化完成或者数据未加载完成的时候,执行会有问题
+    editorIsInited: {
+      handler(newVal) {
+        if (newVal) {
+          let isTitle = this.editorBeforeInitConfig['isTitle'];
+          if (isTitle === true || isTitle === false) {
+            this.displayToolbar(isTitle);
+            this.editorBeforeInitConfig['isTitle'] = null;
+          }
+
+          let style = this.editorBeforeInitConfig['style'];
+          if (style) {
+            this.setRichTitleFormat(style);
+            this.editorBeforeInitConfig['style'] = null;
+          }
+        }
+      },
+    },
   },
   created() {
     if (this.pageFrom !== 'audit') {
@@ -367,6 +666,89 @@ export default {
     }
   },
   methods: {
+    getRichContent() {
+      return tinymce.get(this.id).getContent();
+    },
+    getRichSelectionContent() {
+      return tinymce.get(this.id).selection.getContent();
+    },
+    displayToolbar(isTitle, isInit) {
+      if (!this.editorIsInited) {
+        this.editorBeforeInitConfig['isTitle'] = isTitle;
+        return;
+      }
+
+      let editor = tinymce.get(this.id);
+      if (!editor) return;
+      const header = editor.editorContainer?.querySelector('.tox-editor-header');
+      if (header) {
+        header.style.display = isTitle ? 'none' : '';
+        if (!isInit) {
+          const body = editor.getBody();
+          if (!body) return;
+
+          const pElements = body.querySelectorAll('p');
+          if (this.processHtmlString && typeof this.processHtmlString === 'function') {
+            this.processHtmlString(pElements, {}, true);
+          }
+          editor.fire('change');
+          editor.nodeChanged();
+          this.setPinYinStyleForTitle();
+        }
+      }
+    },
+
+    smartPreserveLineBreaks(editor, content) {
+      let body = editor.getBody();
+      let originalParagraphs = Array.from(body.getElementsByTagName('p'));
+
+      let tempDiv = document.createElement('div');
+      tempDiv.innerHTML = content;
+      let outputParagraphs = Array.from(tempDiv.getElementsByTagName('p'));
+
+      outputParagraphs.forEach((outputP, index) => {
+        let originalP = originalParagraphs[index];
+
+        if (originalP && outputP.innerHTML === '') {
+          // 判断这个空段落是否应该包含 <br>
+          let shouldHaveBr = this.shouldPreserveLineBreak(originalP, index, originalParagraphs);
+          if (shouldHaveBr) {
+            outputP.innerHTML = '<br>';
+          }
+        }
+      });
+
+      return tempDiv.innerHTML;
+    },
+
+    shouldPreserveLineBreak(paragraph, index, allParagraphs) {
+      // 规则1:如果段落原本包含 <br>
+      if (paragraph.innerHTML.includes('<br>')) {
+        return true;
+      }
+
+      // 规则2:如果段落位于内容中间(不是第一个或最后一个)
+      if (index > 0 && index < allParagraphs.length - 1) {
+        let prevHasContent = allParagraphs[index - 1].textContent.trim() !== '';
+        let nextHasContent = allParagraphs[index + 1].textContent.trim() !== '';
+
+        if (prevHasContent && nextHasContent) {
+          return true;
+        }
+      }
+
+      // 规则3:如果段落是通过回车创建的(前后有内容)
+      let isBetweenContent = false;
+      if (index > 0 && allParagraphs[index - 1].textContent.trim() !== '') {
+        isBetweenContent = true;
+      }
+      if (index < allParagraphs.length - 1 && allParagraphs[index + 1].textContent.trim() !== '') {
+        isBetweenContent = true;
+      }
+
+      return isBetweenContent;
+    },
+
     // 设置背景色
     setBackgroundColor() {
       let iframes = document.getElementsByTagName('iframe');
@@ -462,7 +844,45 @@ export default {
         }
       }
       editor.selection.collapse(false);
+      editor.fire('change'); // 触发内容变化事件,确保内容更新
+
+      if (this.isViewPinyin) {
+        this.$nextTick(() => {
+          let styles = this.getFirstCharStyles();
+          this.$emit('createParsedTextInfoPinyin', null, styles);
+        });
+      }
     },
+    setRichTitleFormat(config) {
+      if (!this.editorIsInited) {
+        this.editorBeforeInitConfig['style'] = config;
+        return;
+      }
+
+      let editor = tinymce.get(this.id);
+      if (!editor) return;
+
+      // 获取编辑器内容区域
+      const body = editor.getBody();
+      if (!body) return;
+
+      const pElements = body.querySelectorAll('p');
+      if (typeof this.processHtmlString === 'function') {
+        this.processHtmlString(pElements, config);
+      }
+      editor.fire('change');
+      editor.nodeChanged();
+      this.setPinYinStyleForTitle();
+    },
+    // 标题类型的富文本,如果开启了拼音,需要同步拼音样式
+    setPinYinStyleForTitle() {
+      if (this.isViewPinyin) {
+        let styles = this.getFirstCharStyles();
+        this.$emit('createParsedTextStyleForTitle', styles);
+        return;
+      }
+    },
+
     /**
      * 图片上传自定义逻辑函数
      * @param {object} blobInfo 文件数据
@@ -603,7 +1023,7 @@ export default {
       let start = editor.selection.getStart();
       if (isNodeType(start, 'span')) {
         let textContent = start.textContent;
-        let content = editor.selection.getContent();
+        let content = this.getRichSelectionContent();
         let str = textContent.split(content);
         start.remove();
         editor.selection.setContent(str.join(content));
@@ -615,7 +1035,7 @@ export default {
     setContent() {
       let editor = tinymce.get(this.id);
       let start = editor.selection.getStart();
-      let content = editor.selection.getContent();
+      let content = this.getRichSelectionContent();
       if (isNodeType(start, 'span')) {
         let textContent = start.textContent;
         let str = textContent.split(content);
@@ -643,15 +1063,16 @@ export default {
     replaceSpanString(str) {
       return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
     },
-    createParsedTextInfoPinyin(content) {
-      let text = content.replace(/<[^>]+>/g, '');
-      this.$emit('createParsedTextInfoPinyin', text);
-    },
+
     handleRichTextBlur() {
       this.$emit('handleRichTextBlur', this.itemIndex);
-      let content = tinymce.get(this.id).getContent();
+      let content = this.getRichContent();
+
       if (this.isViewPinyin) {
-        this.createParsedTextInfoPinyin(content);
+        // updated by 2026-3-24 失去焦点时,不再生成拼音,改成手动点击生成拼音
+        // let styles = this.getFirstCharStyles();
+        //  let text = content.replace(/<[^>]+>/g, '');
+        //  this.$emit('createParsedTextInfoPinyin', text, styles);
         return;
       }
 
@@ -659,7 +1080,7 @@ export default {
       let isHasPinyin = content
         .split(/<[^>]+>/g)
         .filter((item) => item)
-        .some((item) => item.match(/[a-zA-Z]+\d(\s|&nbsp;)*/));
+        .some((item) => item.match(/[a-zA-Z]+\d+(\s|&nbsp;)+/));
 
       if (!isHasPinyin) {
         return;
@@ -756,55 +1177,116 @@ export default {
         }
         if (eleMathArs.length === 0) return;
         await this.$nextTick();
-        window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error('MathJax error:', err));
+        window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error(err));
         this.mathEleIsInit = true;
       }
     },
 
     // 获取高亮 span 标签
     getLightSpanString(noteId, str) {
-      return `<span data-annotation-id="${noteId}" class="rich-fill" style="background-color:#fff3b7;cursor: pointer;">${str}</span>`;
+      return `<span data-annotation-id="${noteId}" class="rich-fill" style="color:#8206BF;cursor: pointer;">${str}</span>`;
     },
+
+    generateAnnotationId() {
+      return `${this.id}_${Math.floor(Math.random() * 1000000)
+        .toString()
+        .padStart(10, '0')}`;
+    },
+
     // 选中文本打开弹窗
     openExplanatoryNoteDialog() {
-      let editor = tinymce.get(this.id);
-      let start = editor.selection.getStart();
-      this.$emit('view-explanatory-note', { visible: true, noteId: start.getAttribute('data-annotation-id') });
+      const editor = tinymce.get(this.id);
+      if (!editor) {
+        console.error('编辑器未初始化');
+        return;
+      }
+      // 获取当前选区的起始元素
+      const start = editor.selection.getStart();
+      let noteId = start.getAttribute('data-annotation-id');
+
+      // 如果当前选区没有注释ID,说明是新建注释,需要先创建高亮 span
+      if (!noteId) {
+        const content = this.getRichSelectionContent();
+        if (!content || !content.trim()) {
+          this.$message.warning('请先选择要添加注释的文本');
+          return;
+        }
+        noteId = this.generateAnnotationId();
+        // 清理已有的 span 标签
+        const cleanContent = this.replaceSpanString(content);
+        // 创建高亮 span
+        editor.selection.setContent(this.getLightSpanString(noteId, cleanContent));
+        // 获取纯文本
+        const selectText = content.replace(/<[^>]+>/g, '');
+        // 使用 Range 精确计算索引,解决重复文本定位错误问题
+        const indices = this.getSelectionIndices(editor);
+        const note = {
+          annota_id: noteId,
+          text: selectText,
+          begin_index: indices.end - selectText.length + 1,
+          end_index: indices.end,
+          note: '',
+        };
+        // console.log('初始化注释数据:', note);
+        this.$emit('selectContentSetMemo', note, noteId);
+      }
+
+      // 打开弹窗
+      this.$emit('view-explanatory-note', {
+        visible: true,
+        annota_id: noteId,
+      });
+
       this.hideContentmenu();
     },
-    // 设置高亮背景,并保留备注
-    setExplanatoryNote(richData) {
-      let noteId = '';
-      let editor = tinymce.get(this.id);
-      let start = editor.selection.getStart();
-      let content = editor.selection.getContent();
-      if (isNodeType(start, 'span')) {
-        noteId = start.getAttribute('data-annotation-id');
+    // 获取当前选区在全文中的精确索引(开始和结束位置)
+    getSelectionIndices(editor) {
+      const rng = editor.selection.getRng();
+      if (!rng) return { start: 0, end: 0 };
+
+      // 创建一个临时的 Range 用于计算偏移量
+      const preRng = editor.dom.createRng();
+      preRng.selectNodeContents(editor.getBody());
+      preRng.setEnd(rng.startContainer, rng.startOffset);
+      // 开始索引 = 选区起点前的所有文本长度
+      const endIndex = preRng.toString().length;
+      return {
+        end: endIndex,
+      };
+    },
+
+    // 设置高亮文本样式,并保存注释
+    setExplanatoryNote(richData, richTextID) {
+      const editor = tinymce.get(richTextID);
+      if (!editor) {
+        console.error('编辑器未初始化');
+        return;
+      }
+      const noteId = richData?.id || richData?.annota_id;
+      const noteContent = richData?.note || richData?.dataStr || '';
+      // 查找是否已存在该注释的 span(由 openExplanatoryNoteDialog 创建)
+      const existingSpan = editor.dom.select(`span[data-annotation-id="${noteId}"]`)[0];
+      if (existingSpan) {
+        // 从 span 中获取选中文本
+        const selectText = existingSpan.textContent || '';
+        // 发送更新后的数据到父组件
+        const note = {
+          id: noteId, // 确保这里有 id
+          annota_id: noteId, // 兼容字段
+          note: noteContent,
+          dataStr: noteContent, // 兼容字段
+          selectText,
+        };
+        this.$emit('selectContentSetMemo', note, noteId);
       } else {
-        noteId = `${this.id}_${Math.floor(Math.random() * 1000000)
-          .toString()
-          .padStart(10, '0')}`;
-        let str = this.replaceSpanString(content);
-        editor.selection.setContent(this.getLightSpanString(noteId, str));
+        console.error('未找到对应的注释');
       }
-      let selectText = content.replace(/<[^>]+>/g, '');
-      let note = { id: noteId, note: richData.note, selectText };
-      this.$emit('selectContentSetMemo', note);
     },
     // 取消注释
-    cancelExplanatoryNote() {
-      let editor = tinymce.get(this.id);
-      let start = editor.selection.getStart();
-      if (isNodeType(start, 'span')) {
-        let textContent = start.textContent;
-        let content = editor.selection.getContent();
-        let str = textContent.split(content);
-        start.remove();
-        editor.selection.setContent(str.join(content));
-        this.$emit('selectContentSetMemo', null, start.getAttribute('data-annotation-id'));
-      } else {
-        this.collapse();
-      }
+    cancelExplanatoryNote(noteId, richTextID) {
+      let editor = tinymce.get(richTextID);
+      const node = editor.dom.select(`span[data-annotation-id="${noteId}"]`)[0];
+      editor.dom.remove(node, true);
     },
     // 删除,监听处理备注
     cleanupRemovedAnnotations(editor) {
@@ -825,7 +1307,6 @@ export default {
     },
     // 删除span里面的文字之后,会出现空 span 标签残留,需处理掉
     handleEmptySpan(editor) {
-      let that = this;
       const selection = editor.selection;
       const selectedNode = selection.getNode();
       // 如果选中的是注释span内的内容
@@ -840,10 +1321,80 @@ export default {
           span.parentNode.replaceChild(document.createTextNode(''), span);
 
           // 从存储中移除注释
-          that.$emit('selectContentSetMemo', null, annotationId);
+          this.$emit('selectContentSetMemo', null, annotationId);
         }
       }
     },
+
+    getFirstCharStyles() {
+      const editor = tinymce.activeEditor;
+      if (!editor) return {};
+
+      const firstTextNode = this.findFirstTextNode(editor.getBody());
+      if (!firstTextNode) return {};
+
+      const styles = {};
+      let element = firstTextNode.parentElement;
+
+      while (element && element !== editor.getBody().parentElement) {
+        const computed = window.getComputedStyle(element);
+
+        if (!styles.fontFamily && computed.fontFamily && computed.fontFamily !== 'inherit') {
+          styles.fontFamily = computed.fontFamily;
+        }
+
+        if (!styles.fontSize && computed.fontSize && computed.fontSize !== 'inherit') {
+          const fontSize = computed.fontSize;
+          const pxValue = parseFloat(fontSize);
+          if (isNaN(pxValue)) {
+            styles.fontSize = fontSize;
+          } else {
+            // px转pt公式:pt = px * 3/4
+            const ptValue = Math.round(pxValue * 0.75 * 10) / 10;
+            styles.fontSize = `${ptValue}pt`;
+          }
+        }
+
+        if (!styles.color && computed.color && computed.color !== 'inherit') {
+          styles.color = computed.color;
+        }
+
+        if (!styles.bold && (computed.fontWeight === 'bold' || computed.fontWeight >= '700')) {
+          styles.bold = true;
+          styles.fontWeight = 'bold';
+        } else {
+          styles.bold = false;
+          styles.fontWeight = '';
+        }
+
+        if (!styles.underline && computed.textDecoration.includes('underline')) {
+          styles.underline = true;
+          styles.textDecoration = 'underline';
+        }
+
+        if (!styles.strikethrough && computed.textDecoration.includes('line-through')) {
+          styles.strikethrough = true;
+          styles.textDecoration = 'line-through';
+        }
+
+        if (Object.keys(styles).length >= 6) break;
+
+        element = element.parentElement;
+      }
+      return styles;
+    },
+
+    findFirstTextNode(element) {
+      const walker = document.createTreeWalker(
+        element,
+        NodeFilter.SHOW_TEXT,
+        {
+          acceptNode: (node) => (node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
+        },
+        false,
+      );
+      return walker.nextNode();
+    },
   },
 };
 </script>

+ 106 - 50
src/views/book/courseware/create/components/base/common/CorrectPinyin.vue

@@ -5,14 +5,16 @@
     top="20vh"
     :show-close="false"
     :close-on-click-modal="false"
+    :modal-append-to-body="true"
+    :append-to-body="true"
     @close="dialogClose"
   >
-    <div ref="toolbarMenu" class="toolbar">
+    <div v-if="!newVersion" ref="toolbarMenu" class="toolbar">
       <el-select v-model="dataContent.activeTextStyle.fontFamily" placeholder="请选择">
         <el-option v-for="item in fontFamilyOptions" :key="item.value" :label="item.label" :value="item.value" />
       </el-select>
       <el-select v-model="dataContent.activeTextStyle.fontSize" placeholder="请选择">
-        <el-option v-for="item in fontSizeOptions" :key="item.value" :label="item.label" :value="item.value" />
+        <el-option v-for="size in fontSizeOptions" :key="size" :label="size" :value="size" />
       </el-select>
       <span class="picker-area">
         <el-color-picker
@@ -32,19 +34,47 @@
         <SvgIcon icon-class="strikethrough" title="删除线" size="20" @click="setActiveTextStyle('line-through')" />
       </span>
     </div>
-    <span class="tone-pinyin">{{ dataContent.pinyin }}</span>
+    <span v-show="isEnable(dataContent.showPinyin)" class="tone-pinyin">{{ dataContent.pinyin }}</span>
     <span class="content-text" :style="dataContent.activeTextStyle">{{ dataContent.text }}</span>
-    <el-input v-model="numberPinyin" autocomplete="off" placeholder="请输入正确的拼音" @blur="convertTonePinyin" />
-    <span class="tips">一到四声分别用数字1-4表示,轻声用0表示,拼音间用空格隔开。</span>
-    <RichText
-      v-if="componentType === 'richtext'"
-      v-model="dataContent.note"
-      toolbar="fontselect fontsizeselect forecolor backcolor|underline|bold italic strikethrough alignleft aligncenter alignright"
-      :wordlimit-num="false"
-      :height="200"
-      placeholder="输入注释"
-      page-from="audit"
-    />
+
+    <div class="pinyin-row">
+      <el-switch
+        v-model="dataContent.showPinyin"
+        active-value="true"
+        inactive-value="false"
+        active-text="显示拼音"
+        inactive-text="隐藏拼音"
+      />
+      <!-- <el-input
+        v-show="isEnable(dataContent.showPinyin)"
+        v-model="numberPinyin"
+        autocomplete="off"
+        placeholder="请输入正确的拼音"
+        @blur="convertTonePinyin"
+        class="pinyin-input"
+      /> -->
+      <el-select
+        v-show="isEnable(dataContent.showPinyin)"
+        v-model="numberPinyin"
+        placeholder="请选择"
+        filterable
+        allow-create
+        default-first-option
+        class="pinyin-input"
+        @change="convertTonePinyin"
+      >
+        <el-option v-for="item in pinyinList" :key="item.pinyin" :value="item.pinyin.replace(/,/g, '')">
+          <span style="float: left">{{ item.pinyin ? item.pinyin.replace(/,/g, '') : '' }}</span>
+          <span style="float: right; font-size: 13px; color: #8492a6">{{
+            item.storage_type === 1 ? '机构库' : item.storage_type === 2 ? '全域库' : '个人库'
+          }}</span>
+        </el-option>
+      </el-select>
+    </div>
+    <span v-show="isEnable(dataContent.showPinyin)" class="tips">
+      一到四声分别用数字1-4表示,轻声用0表示,拼音间用空格隔开。
+    </span>
+
     <template slot="footer">
       <el-button size="medium" @click="dialogClose">取消</el-button>
       <el-button type="primary" size="medium" @click="confirm">保存</el-button>
@@ -54,14 +84,13 @@
 
 <script>
 import { addTone, handleToneValue } from '@/utils/common';
-import RichText from '@/components/RichText.vue';
 import _ from 'lodash';
+import { fontFamilyList } from '@/views/book/courseware/data/table.js';
+import { isEnable } from '@/views/book/courseware/data/common';
+import { toolGetWordPinyinCorrectionList } from '@/api/pinyinCorrection';
 
 export default {
   name: 'CorrectPinyin',
-  components: {
-    RichText,
-  },
   props: {
     visible: {
       type: Boolean,
@@ -75,44 +104,36 @@ export default {
       type: String,
       default: '',
     },
+    newVersion: {
+      type: Boolean,
+      default: false,
+    },
   },
-
   data() {
     return {
+      isEnable,
       numberPinyin: '',
-      fontFamilyOptions: [
-        {
-          value: '楷体',
-          label: '中文',
-        },
-        {
-          value: 'Sans-GBNPC',
-          label: '拼音',
-        },
-        {
-          value: 'robot',
-          label: '英文',
-        },
-        {
-          value: 'Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif, alabo',
-          label: '系统字体',
-        },
-      ],
+      fontFamilyOptions: fontFamilyList,
       fontSizeOptions: [
-        { value: '12px', label: '12px' },
-        { value: '14px', label: '14px' },
-        { value: '16px', label: '16px' },
-        { value: '18px', label: '18px' },
-        { value: '20px', label: '20px' },
-        { value: '22px', label: '22px' },
-        { value: '24px', label: '24px' },
-        { value: '26px', label: '26px' },
-        { value: '28px', label: '28px' },
-        { value: '30px', label: '30px' },
+        '8pt',
+        '10pt',
+        '12pt',
+        '14pt',
+        '16pt',
+        '18pt',
+        '20pt',
+        '22pt',
+        '24pt',
+        '26pt',
+        '28pt',
+        '30pt',
+        '32pt',
+        '34pt',
+        '36pt',
       ],
       activeTextStyle: {
         fontFamily: 'Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif, alabo',
-        fontSize: '16px',
+        fontSize: '12pt',
         color: 'black',
         textDecoration: '',
         fontWeight: '',
@@ -122,6 +143,7 @@ export default {
         left: 0,
       },
       dataContent: { activeTextStyle: {}, note: {} },
+      pinyinList: [], // 拼音校正列表
     };
   },
   watch: {
@@ -135,8 +157,13 @@ export default {
             this.$set(style, key, this.activeTextStyle[key]);
           }
         }
+        let _showPinyin = this.dataContent.showPinyin;
+        if (_showPinyin === undefined || _showPinyin === null) {
+          _showPinyin = 'true';
+        }
+        this.$set(this.dataContent, 'showPinyin', _showPinyin);
         this.$set(this.dataContent, 'activeTextStyle', style);
-        // console.info(this.dataContent);
+        this.getPinyinList();
       },
       deep: true,
     },
@@ -144,6 +171,7 @@ export default {
   methods: {
     // 将数字拼音转换为声调拼音
     convertTonePinyin() {
+      if (!this.numberPinyin) return;
       this.dataContent.pinyin = this.numberPinyin
         .trim()
         .split(/\s+/)
@@ -168,6 +196,9 @@ export default {
         this.$set(this.dataContent.activeTextStyle, 'backgroundColor', '');
         this.$set(this.dataContent.activeTextStyle, 'cursor', 'text');
       }
+      if (this.newVersion) {
+        this.$set(this.dataContent, 'activeTextStyle', {});
+      }
       this.$emit('update:visible', false);
       this.$emit('fillTonePinyin', this.dataContent);
       this.numberPinyin = '';
@@ -186,6 +217,19 @@ export default {
         this.$set(this.dataContent.activeTextStyle, 'textDecoration', style.textDecoration);
       }
     },
+    // 获取拼音校正库列表
+    getPinyinList() {
+      this.pinyinList = [];
+      toolGetWordPinyinCorrectionList({
+        word: this.dataContent.text,
+      })
+        .then((res) => {
+          if (res.status === 1) {
+            this.pinyinList = res.pinyin_correction_list;
+          }
+        })
+        .catch(() => {});
+    },
   },
 };
 </script>
@@ -240,7 +284,7 @@ export default {
     }
 
     .content-text {
-      margin-top: -12px;
+      /* margin-top: -12px; */
       font-size: 16px;
     }
 
@@ -265,4 +309,16 @@ export default {
     }
   }
 }
+
+.pinyin-row {
+  display: flex;
+  column-gap: 16px;
+  align-items: center;
+  width: 100%;
+  margin-bottom: 8px; // 与下方 tips 的间距
+
+  .pinyin-input {
+    flex: 1;
+  }
+}
 </style>

+ 0 - 26
src/views/book/courseware/preview/components/rich_text/RichTextPreview.vue

@@ -58,7 +58,6 @@ import PreviewMixin from '../common/PreviewMixin';
 import { isEnable } from '@/views/book/courseware/data/common';
 import PinyinText from '@/components/PinyinText.vue';
 import AudioPlay from '../character_base/components/AudioPlay.vue';
-import { getRandomNumber } from '@/utils';
 
 export default {
   name: 'RichTextPreview',
@@ -120,31 +119,6 @@ export default {
         }, 800);
       });
     },
-
-    /**
-     * 获取无文本内容的数据结构,用于保存为个人模板时的样式模板
-     */
-    getNoTextContentData() {
-      let noTextContentData = JSON.parse(JSON.stringify(this.data));
-      const resetFieldMap = {
-        paragraph_list: [],
-        rich_text_list: [],
-        content: '',
-        audio_file_id: '',
-        note_list: [],
-        analysis_list: [],
-        answer_list: [],
-      };
-
-      Object.assign(noTextContentData, resetFieldMap);
-
-      if (noTextContentData.answer) {
-        noTextContentData.answer.answer_list = [];
-        noTextContentData.answer.reference_answer = '';
-      }
-
-      return noTextContentData;
-    },
   },
 };
 </script>