4 Commits 0509e4fba3 ... 93e425b43e

Author SHA1 Message Date
  zq 93e425b43e Merge branch 'master' of http://gcls-git.helxsoft.cn/GCLS/eep_page 1 day ago
  zq ce3bd8f925 富文本气泡功能 1 day ago
  dsy 68f6f6ff2a Merge branch 'master' of http://60.205.254.193:3000/GCLS/eep_page 1 day ago
  dsy a654c7fea3 背景设置自定义模式增加锁定比例 1 day ago

+ 1 - 1
.env

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

+ 1 - 1
src/components/ExplanatoryNoteDialog.vue

@@ -8,7 +8,7 @@
   >
     <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="输入内容"

+ 63 - 106
src/components/PinyinText.vue

@@ -18,12 +18,13 @@
         >
           <span v-for="(word, wIndex) in block.word_list" :key="wIndex" class="pinyin-text">
             <span
-              :class="{ active: visible && word_index == wIndex && sentence_index === block.index }"
               :title="isPreview ? '' : '点击校对'"
               :style="{
                 cursor: isPreview ? '' : 'pointer',
               }"
-              @click="correctPinyin(word, block.paragraphIndex, block.oldIndex, wIndex)"
+              @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
@@ -71,15 +72,14 @@
         >
           <span v-for="(word, wIndex) in sentence" :key="wIndex" class="pinyin-text">
             <span
-              :class="{
-                active: visible && word_index == wIndex && sentence_index === sIndex && paragraph_index === pIndex,
-              }"
               :title="isPreview ? '' : '点击校对'"
               :style="{
                 cursor: isPreview ? '' : 'pointer',
                 'align-items': wIndex == 0 ? 'flex-start' : 'center',
               }"
-              @click="correctPinyin(word, pIndex, sIndex, wIndex)"
+              @click="!isPreview && handleWordClick(word, block.paragraphIndex, block.oldIndex, wIndex)"
+              @mouseover="isPreview && handlePreviewHover(word, $event)"
+              @mouseleave="onLeave"
             >
               <span
                 v-if="pinyinPosition === 'top' && hasPinyinInParagraph(sIndex)"
@@ -110,18 +110,15 @@
       @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>
 
@@ -159,6 +156,10 @@ export default {
       type: Array,
       default: () => [],
     },
+    richTextNoteList: {
+      type: Array,
+      default: () => [],
+    },
     pinyinPosition: {
       type: String,
       required: true,
@@ -201,14 +202,7 @@ export default {
       paragraph_index: 0,
       sentence_index: 0,
       word_index: 0,
-      noteDialogVisible: false,
       note: '',
-      dialogStyle: {
-        position: 'fixed',
-        top: '0',
-        left: '0',
-        margin: '0',
-      },
       isAllSetting: false,
     };
   },
@@ -225,7 +219,6 @@ export default {
     // 解析 richTextList 为可渲染的块
     parsedBlocks() {
       if (!this.richTextList || this.richTextList.length === 0) return [];
-
       const blocks = [];
       let textBlockIndex = 0;
       let oldIndex = -1;
@@ -262,6 +255,44 @@ export default {
     },
   },
   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) => {
@@ -498,72 +529,6 @@ export default {
       this.isAllSetting = false;
       return styles;
     },
-    /**
-     * 校对拼音
-     */
-    correctPinyin(item, pIndex, sIndex, wIndex) {
-      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 = pIndex;
-        this.sentence_index = sIndex;
-        this.word_index = wIndex;
-      }
-    },
     // 回填校对后的拼音
     fillTonePinyin(dataContent) {
       this.$emit('fillCorrectPinyin', {
@@ -579,11 +544,6 @@ export default {
       const paragraph = this.paragraphList[paragraphIndex];
       return paragraph.some((sentence) => sentence.some((item) => this.checkShowPinyin(item.showPinyin)));
     },
-    hasPinyinInParagraphNeVersion(paragraphIndex) {
-      const paragraphs = this.parsedBlocks.filter((item) => item.paragraphIndex === paragraphIndex);
-
-      return paragraphs.flatMap((item) => item.word_list).some((item) => this.checkShowPinyin(item.showPinyin));
-    },
     // 兼容历史数据,没有 showPinyin 字段的,则默认为 true
     checkShowPinyin(showPinyin) {
       if (showPinyin === undefined || showPinyin === null) {
@@ -641,8 +601,6 @@ export default {
 
     > span {
       display: inline-flex;
-
-      // flex-direction: column;
     }
 
     .py-char {
@@ -654,12 +612,6 @@ export default {
       display: inline-block;
       min-height: 12px;
       font-family: 'League';
-
-      // font-family: 'PINYIN-B';
-      // font-size: 12px;
-      // font-weight: lighter;
-      // line-height: 12px;
-      // color: $font-color;
       line-height: 1em;
       text-decoration: none !important;
 
@@ -668,8 +620,13 @@ export default {
     }
   }
 
-  .active {
-    color: rgb(242, 85, 90) !important;
+  :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>

+ 97 - 40
src/components/RichText.vue

@@ -223,7 +223,7 @@ export default {
               const formatName = `letterSpacing${config}_em`;
               editor.formatter.register(formatName, {
                 inline: 'span',
-                styles: { 'letter-spacing': `${config}em`},
+                styles: { 'letter-spacing': `${config}em` },
                 attributes: { 'data-letter-spacing': `${config}` },
                 wrapper: true,
                 remove_similar: true,
@@ -263,7 +263,7 @@ export default {
             }
             this.editorIsInited = true;
           });
-          
+
           // 自定义行高下拉(因为没有内置 lineheight 插件)
           editor.ui.registry.addMenuButton('lineheight', {
             text: '行高',
@@ -1063,11 +1063,7 @@ export default {
     replaceSpanString(str) {
       return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
     },
-    // createParsedTextInfoPinyin(content) {
-    //   let styles = this.getFirstCharStyles();
-    //   let text = content.replace(/<[^>]+>/g, '');
-    //   this.$emit('createParsedTextInfoPinyin', text, styles);
-    // },
+
     handleRichTextBlur() {
       this.$emit('handleRichTextBlur', this.itemIndex);
       let content = this.getRichContent();
@@ -1188,48 +1184,109 @@ export default {
 
     // 获取高亮 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 = this.getRichSelectionContent();
-      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 = this.getRichSelectionContent();
-        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) {

+ 54 - 0
src/views/book/courseware/create/components/SetBackground.vue

@@ -86,6 +86,9 @@
               <el-button v-if="cropMode" size="mini" @click="cancelRectCrop">取消</el-button>
               <el-button v-if="cropMode" size="mini" type="primary" @click="applyRectCrop">应用</el-button>
             </div>
+            <div v-if="background.imageMode === imageModeList[3].value">
+              <el-checkbox v-model="lockScale">锁定比例</el-checkbox>
+            </div>
           </div>
         </div>
         <div class="setup-item">
@@ -191,6 +194,7 @@ export default {
         startX: 0,
         startY: 0,
         type: '',
+        aspectRatio: 1, // 锁定比例时的宽高比
       },
       cropMode: false,
       crop: {
@@ -232,6 +236,7 @@ export default {
         { label: '平铺', value: 'fill' },
         { label: '自定义', value: 'auto' },
       ],
+      lockScale: false, // 是否锁定比例(仅自定义模式)
     };
   },
   computed: {
@@ -341,11 +346,13 @@ export default {
     dragStart(event, cursor, type) {
       if (this.cropMode) return;
       const { clientX, clientY } = event;
+      const aspectRatio = this.imgData.height ? this.imgData.width / this.imgData.height : 1;
       this.drag = {
         dragging: true,
         startX: clientX,
         startY: clientY,
         type,
+        aspectRatio,
       };
 
       document.querySelector('.el-dialog__wrapper').style.cursor = cursor;
@@ -377,6 +384,7 @@ export default {
       if (!this.drag.dragging) return;
       const { clientX, clientY } = event;
       const { startX, startY, type } = this.drag;
+      const prevImgData = { ...this.imgData };
 
       const widthDiff = clientX - startX;
       const heightDiff = clientY - startY;
@@ -422,11 +430,57 @@ export default {
         this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
       }
 
+      if (this.shouldLockScale(type)) {
+        this.applyLockedScaleResize(type, prevImgData);
+      }
+
       this.drag.startX = clientX;
       this.drag.startY = clientY;
       this.syncImageDisplayRect();
     },
     /**
+     * 判断在当前拖动类型下是否应该锁定比例调整尺寸
+     * @param {string} type 拖动类型
+     * @returns {boolean} 是否应该锁定比例调整尺寸
+     */
+    shouldLockScale(type) {
+      return this.background.imageMode === 'auto' && this.lockScale && type !== 'move';
+    },
+    /**
+     * 在锁定比例的情况下调整尺寸,确保宽高比保持不变,并且不会超出边界
+     * @param {string} type 拖动类型
+     * @param {object} prevImgData 拖动前的图片数据,用于计算边界限制
+     */
+    applyLockedScaleResize(type, prevImgData) {
+      const ratio = this.drag.aspectRatio || 1;
+      if (!ratio) return;
+
+      let width = type === 'top' || type === 'bottom' ? this.imgData.height * ratio : this.imgData.width;
+      let height = width / ratio;
+
+      // 根据拖动方向计算允许的最大宽高,确保在锁定比例时不会超出边界
+      const widthLimit = type.includes('left')
+        ? prevImgData.left + prevImgData.width
+        : this.maxWidth - prevImgData.left;
+      const heightLimit = type.includes('top')
+        ? prevImgData.top + prevImgData.height
+        : this.maxHeight - prevImgData.top;
+      const maxWidthByHeight = heightLimit * ratio;
+      const safeWidth = Math.max(1, Math.min(width, widthLimit, maxWidthByHeight));
+
+      width = safeWidth;
+      height = width / ratio;
+
+      // 根据拖动方向计算新的 left 和 top,确保在锁定比例时图片位置调整合理
+      const left = type.includes('left') ? prevImgData.left + prevImgData.width - width : prevImgData.left;
+      const top = type.includes('top') ? prevImgData.top + prevImgData.height - height : prevImgData.top;
+
+      this.imgData.width = width;
+      this.imgData.height = height;
+      this.imgData.left = Math.min(this.maxWidth - width, Math.max(0, left));
+      this.imgData.top = Math.min(this.maxHeight - height, Math.max(0, top));
+    },
+    /**
      * 鼠标抬起
      */
     mouseUp() {

+ 54 - 0
src/views/book/courseware/create/components/SetComponentBackground.vue

@@ -77,6 +77,9 @@
               <el-button v-if="cropMode" size="mini" @click="cancelRectCrop">取消</el-button>
               <el-button v-if="cropMode" size="mini" type="primary" @click="applyRectCrop">应用</el-button>
             </div>
+            <div v-if="background.imageMode === imageModeList[3].value">
+              <el-checkbox v-model="lockScale">锁定比例</el-checkbox>
+            </div>
           </div>
         </div>
         <div class="setup-item">
@@ -181,6 +184,7 @@ export default {
         startX: 0,
         startY: 0,
         type: '',
+        aspectRatio: 1, // 锁定比例时的宽高比
       },
       cropMode: false,
       crop: {
@@ -220,6 +224,7 @@ export default {
         { label: '平铺', value: 'fill' },
         { label: '自定义', value: 'auto' },
       ],
+      lockScale: false, // 是否锁定比例(仅自定义模式)
     };
   },
   computed: {
@@ -354,11 +359,13 @@ export default {
     dragStart(event, cursor, type) {
       if (this.cropMode) return;
       const { clientX, clientY } = event;
+      const aspectRatio = this.imgData.height ? this.imgData.width / this.imgData.height : 1;
       this.drag = {
         dragging: true,
         startX: clientX,
         startY: clientY,
         type,
+        aspectRatio,
       };
 
       document.querySelector('.el-dialog__wrapper').style.cursor = cursor;
@@ -390,6 +397,7 @@ export default {
       if (!this.drag.dragging) return;
       const { clientX, clientY } = event;
       const { startX, startY, type } = this.drag;
+      const prevImgData = { ...this.imgData };
 
       const widthDiff = clientX - startX;
       const heightDiff = clientY - startY;
@@ -435,11 +443,57 @@ export default {
         this.imgData.left = Math.min(this.maxWidth - this.imgData.width, Math.max(0, this.imgData.left + widthDiff));
       }
 
+      if (this.shouldLockScale(type)) {
+        this.applyLockedScaleResize(type, prevImgData);
+      }
+
       this.drag.startX = clientX;
       this.drag.startY = clientY;
       this.syncImageDisplayRect();
     },
     /**
+     * 判断在当前拖动类型下是否应该锁定比例调整尺寸
+     * @param {string} type 拖动类型
+     * @returns {boolean} 是否应该锁定比例调整尺寸
+     */
+    shouldLockScale(type) {
+      return this.background.imageMode === 'auto' && this.lockScale && type !== 'move';
+    },
+    /**
+     * 在锁定比例的情况下调整尺寸,确保宽高比保持不变,并且不会超出边界
+     * @param {string} type 拖动类型
+     * @param {object} prevImgData 拖动前的图片数据,用于计算边界限制
+     */
+    applyLockedScaleResize(type, prevImgData) {
+      const ratio = this.drag.aspectRatio || 1;
+      if (!ratio) return;
+
+      let width = type === 'top' || type === 'bottom' ? this.imgData.height * ratio : this.imgData.width;
+      let height = width / ratio;
+
+      // 根据拖动方向计算允许的最大宽高,确保在锁定比例时不会超出边界
+      const widthLimit = type.includes('left')
+        ? prevImgData.left + prevImgData.width
+        : this.maxWidth - prevImgData.left;
+      const heightLimit = type.includes('top')
+        ? prevImgData.top + prevImgData.height
+        : this.maxHeight - prevImgData.top;
+      const maxWidthByHeight = heightLimit * ratio;
+      const safeWidth = Math.max(1, Math.min(width, widthLimit, maxWidthByHeight));
+
+      width = safeWidth;
+      height = width / ratio;
+
+      // 根据拖动方向计算新的 left 和 top,确保在锁定比例时图片位置调整合理
+      const left = type.includes('left') ? prevImgData.left + prevImgData.width - width : prevImgData.left;
+      const top = type.includes('top') ? prevImgData.top + prevImgData.height - height : prevImgData.top;
+
+      this.imgData.width = width;
+      this.imgData.height = height;
+      this.imgData.left = Math.min(this.maxWidth - width, Math.max(0, left));
+      this.imgData.top = Math.min(this.maxHeight - height, Math.max(0, top));
+    },
+    /**
      * 鼠标抬起
      */
     mouseUp() {

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

@@ -75,7 +75,7 @@
       >一到四声分别用数字1-4表示,轻声用0表示,拼音间用空格隔开。</span
     >
 
-    <RichText
+    <!-- <RichText
       v-if="componentType === 'richtext' && visible"
       v-model="dataContent.note"
       toolbar="fontselect fontsizeselect forecolor backcolor|underline|bold italic strikethrough alignleft aligncenter alignright"
@@ -83,7 +83,7 @@
       :height="200"
       placeholder="输入注释"
       page-from="audit"
-    />
+    /> -->
     <template slot="footer">
       <el-button size="medium" @click="dialogClose">取消</el-button>
       <el-button type="primary" size="medium" @click="confirm">保存</el-button>

+ 0 - 1
src/views/book/courseware/create/components/base/common/UploadFile.vue

@@ -535,7 +535,6 @@ export default {
 
     // 提交到资源库
     handleSubmitToResource(file) {
-      debugger;
       this.visibleSubmitResource = true;
       this.curFile = file;
       this.resourceForm = {

+ 309 - 0
src/views/book/courseware/create/components/base/rich_text/CheckWord.vue

@@ -0,0 +1,309 @@
+<template>
+  <div class="check-article">
+    <div class="main">
+      <div class="main-top">
+        <div style="display: flex; align-items: end">
+          <b>校对分词</b>
+          <p class="tips">
+            分词:在需要分开的内容中间插入两个空格;分句:将句子另起一行;分段:两段中间加入空行。校对分词后请校对拼音。
+          </p>
+        </div>
+        <div class="btn-box">
+          <!-- <el-switch
+                class="show-pos"
+                v-model="showPos"
+                active-text="显示词性"
+                inactive-text="">
+            </el-switch> -->
+          <el-button type="primary" @click="saveWord">保存</el-button>
+        </div>
+      </div>
+      <el-input v-model="noPosContent" class="no-pos-content" type="textarea" :autosize="{ minRows: 27 }" />
+    </div>
+  </div>
+</template>
+
+<script>
+// import { publicMethods, reparse } from '@/api/api';
+
+export default {
+  components: {},
+  props: ['data'],
+  data() {
+    return {
+      noPosContent: '',
+    };
+  },
+  // 生命周期 - 创建完成(可以访问当前this实例)
+  created() {
+    this.getArticleData();
+  },
+  methods: {
+    // 获取分析结果
+    getArticleData() {
+      let str = '';
+
+      this.data.detail.forEach((item, index) => {
+        if (item.segList && item.segList.length > 0) {
+          item.segList.forEach((items, indexs) => {
+            items.forEach((itemss, indexss) => {
+              str += itemss + (indexss !== items.length - 1 ? '  ' : '');
+            });
+            str += '\n';
+          });
+        } else if (item.noticeSegList && item.noticeSegList.length > 0) {
+          item.sentenceStr = [];
+          item.noticeSegList.forEach((items, indexs) => {
+            items.forEach((itemss, indexss) => {
+              str += itemss + (indexss !== items.length - 1 ? '  ' : '');
+            });
+            str += '\n';
+          });
+        }
+
+        if (index !== this.data.detail.length - 1) {
+          str += '\n';
+        }
+      });
+      this.noPosContent = str;
+    },
+    saveWord() {
+      let saveArr = [];
+      let arr = this.noPosContent.split('\n');
+      let indexP = 0; // 段落索引
+      let indexS = 0; // 句子索引
+      let flag = true;
+      arr.forEach((item) => {
+        if (item === '') {
+          saveArr.push([]);
+        }
+      });
+      arr.forEach((item, index) => {
+        if (item === '') {
+          indexP++;
+          indexS = 0;
+        } else {
+          let arrs = item.trim().split('  ');
+          let saveItem = [];
+          arrs.forEach((items, indexs) => {
+            saveItem.push(items);
+          });
+          saveArr[indexP].push(saveItem);
+          indexS++;
+        }
+      });
+      this.$emit('saveWord', saveArr);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.check-article {
+  min-height: 100%;
+  background: #f6f6f6;
+
+  .wheader {
+    background: #fff;
+  }
+
+  .el-button {
+    padding: 5px 16px;
+    font-size: 14px;
+    font-weight: 400;
+    line-height: 22px;
+    color: #4e5969;
+    background: #f2f3f5;
+    border: 1px solid #f2f3f5;
+    border-radius: 2px;
+
+    &.el-button--primary {
+      color: #fff;
+      background: #165dff;
+      border: 1px solid #165dff;
+    }
+  }
+
+  .main {
+    background: #fff;
+
+    &-top {
+      position: relative;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 24px;
+
+      b {
+        margin-left: 16px;
+        font-size: 24px;
+        font-weight: 500;
+        line-height: 34px;
+        color: #000;
+      }
+
+      .tips {
+        margin-left: 12px;
+        font-size: 12px;
+        font-weight: 400;
+        line-height: 20px;
+        color: #979797;
+      }
+
+      .show-pos {
+        margin-right: 24px;
+
+        &.is-checked {
+          .el-switch__label.is-active {
+            color: rgba(13, 13, 13, 100%);
+          }
+        }
+      }
+    }
+
+    .go-back {
+      display: flex;
+      align-items: center;
+      width: 60px;
+      padding: 5px 8px;
+      font-size: 14px;
+      font-weight: 400;
+      line-height: 22px;
+      color: #333;
+      cursor: pointer;
+      background: #fff;
+      border: 1px solid #d9d9d9;
+      border-radius: 4px;
+      box-shadow: 0 2px 0 0 rgba(0, 0, 0, 2%);
+
+      .el-icon-arrow-left {
+        margin-right: 8px;
+        font-size: 16px;
+      }
+    }
+
+    .pos-box {
+      padding: 16px;
+      margin-bottom: 16px;
+      background: #f7f7f7;
+      border-radius: 4px;
+
+      h4 {
+        margin: 0;
+        font-size: 14px;
+        font-weight: 600;
+        line-height: 22px;
+        color: #000;
+      }
+
+      ul {
+        display: flex;
+        flex-flow: wrap;
+        padding: 0;
+        margin: 0;
+        list-style: none;
+
+        li {
+          padding: 4px 16px;
+          margin: 8px 8px 0 0;
+          background: #fff;
+          border-radius: 4px;
+
+          label {
+            margin-right: 8px;
+            font-size: 14px;
+            font-weight: 600;
+            line-height: 22px;
+            color: #007eff;
+          }
+
+          span {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 22px;
+            color: #000;
+          }
+        }
+      }
+    }
+  }
+}
+
+.check-box {
+  padding: 24px;
+  background: #fff;
+  border-radius: 4px;
+  box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 25%);
+
+  .content {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .words-box {
+      color: #000;
+      text-align: center;
+
+      .words {
+        display: block;
+        font-size: 28px;
+        font-weight: 400;
+        line-height: 40px;
+      }
+
+      .pinyin {
+        display: block;
+        height: 20px;
+        font-family: 'League';
+        font-size: 20px;
+        font-weight: 400;
+        line-height: 1;
+      }
+    }
+  }
+
+  .checkPinyinInput {
+    margin: 16px 0 8px;
+  }
+
+  .tips {
+    margin: 0 0 24px;
+    font-size: 12px;
+    font-weight: 400;
+    line-height: 18px;
+    color: #a0a0a0;
+  }
+
+  .btn-box {
+    text-align: right;
+
+    .el-button {
+      padding: 2px 12px;
+      font-size: 12px;
+      line-height: 20px;
+    }
+  }
+}
+</style>
+<style lang="scss">
+.check-article {
+  .show-pos {
+    .el-switch__label.is-active {
+      color: rgba(13, 13, 13, 100%);
+    }
+
+    &.el-switch.is-checked .el-switch__core {
+      background-color: #165dff;
+      border-color: #165dff;
+    }
+  }
+
+  .no-pos-content {
+    .el-textarea__inner {
+      font-family: 'Helvetica', '楷体';
+      font-size: 16px;
+      color: rgba(0, 0, 0, 88%);
+    }
+  }
+}
+</style>

+ 79 - 21
src/views/book/courseware/create/components/base/rich_text/RichText.vue

@@ -113,6 +113,14 @@
         @confirm="confirmExplanatoryNote"
         @cancel="cancelExplanatoryNote"
       />
+      <el-table :data="noteList" style="width: 100%; margin-top: 30px" border>
+        <el-table-column prop="text" label="词" width="200" />
+        <el-table-column label="注释内容">
+          <template #default="{ row }">
+            <div v-html="row.content"></div>
+          </template>
+        </el-table-column>
+      </el-table>
     </template>
   </ModuleBase>
 </template>
@@ -126,7 +134,7 @@ import ModuleMixin from '../../common/ModuleMixin';
 import PinyinText from '@/components/PinyinText.vue';
 import DOMPurify from 'dompurify';
 import ExplanatoryNoteDialog from '@/components/ExplanatoryNoteDialog.vue';
-import CheckWord from '@/views/book/courseware/create/components/question/article/CheckWord.vue';
+import CheckWord from '@/views/book/courseware/create/components/base/rich_text/CheckWord.vue';
 import SoundRecord from '@/views/book/courseware/create/components/question/fill/components/SoundRecord.vue';
 import UploadAudio from '@/views/book/courseware/create/components/question/fill/components/UploadAudio.vue';
 import { TextToAudioFile } from '@/api/app';
@@ -151,6 +159,14 @@ export default {
       paragraphVersion: 0,
     };
   },
+  computed: {
+    noteList() {
+      return this.data.note_list.map((note) => ({
+        text: note.selectText || note.text || '',
+        content: note.dataStr || '',
+      }));
+    },
+  },
   watch: {
     'data.property': {
       handler(val) {
@@ -198,6 +214,20 @@ export default {
       },
       deep: true,
     },
+    isViewExplanatoryNoteDialog(newVal, oldVal) {
+      if (oldVal && !newVal) {
+        // 弹窗关闭时,如果 oldRichData 还有值(说明没保存成功或被取消),则清理高亮
+        if (this.oldRichData && this.oldRichData.id) {
+          // 判断该 ID 是否还存在于 note_list 中
+          // 如果不存在,说明是新建后取消,需要移除高亮
+          this.data.note_list.some((n) => {
+            if (n.id === this.oldRichData.id && !n?.dataStr) {
+              this.cancelExplanatoryNote();
+            }
+          });
+        }
+      }
+    },
   },
   methods: {
     uploads(file_id, file_url) {
@@ -251,9 +281,8 @@ export default {
         return;
       }
       let styles = this.$refs.richText.getFirstCharStyles();
-      // let content = this.$refs.richText.getRichContent();
-      // let text = content.replace(/<[^>]+>/g, '');
-      this.createParsedTextInfoPinyin(null, styles);
+      let content = this.$refs.richText.getRichContent();
+      this.createParsedTextInfoPinyin(content, styles);
     },
     showSetting() {
       this.richId = this.$refs.richText.id;
@@ -265,7 +294,6 @@ export default {
     async createParsedTextInfoPinyin(text, styles, fc_list) {
       const data = this.data.paragraph_list_parameter;
       if (text === null) {
-        // text.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ');
         text = this.data.content;
       }
       if (text === '') {
@@ -280,6 +308,10 @@ export default {
       data.is_custom_fc = fcList.length > 0;
       data.fc_paragraph_list = fcList;
       data.is_rich_text = 'true';
+      // 新增:注释相关参数
+      let hasNotes = this.data.note_list && this.data.note_list.length > 0 ? 'true' : 'false';
+      data.is_match_annota = hasNotes; // 是否匹配注释,默认为 false
+      data.annota_list = hasNotes ? this.data.note_list : [];
 
       await PinyinBuild_OldFormat(data).then(({ parsed_text, rich_text }) => {
         if (parsed_text) {
@@ -309,7 +341,7 @@ export default {
           );
           this.$set(this.data, 'paragraph_list', mergedData);
           this.paragraphVersion++;
-          this.parseFClist();
+          this.parseFClist(); // 确保在这里调用
         }
         this.$set(this.data, 'rich_text_list', rich_text.text_list);
       });
@@ -397,7 +429,7 @@ export default {
     // 打开弹窗,接收子组件的值 { visible: true, noteId: start.id }
     viewExplanatoryNote(val) {
       this.isViewExplanatoryNoteDialog = val.visible;
-      let id = val.noteId;
+      let id = val.annota_id || val.noteId;
       if (this.data.note_list.some((p) => p.id === id)) {
         this.oldRichData = this.data.note_list.find((p) => p.id === id);
       } else {
@@ -408,9 +440,10 @@ export default {
     confirmExplanatoryNote(text) {
       this.isViewExplanatoryNoteDialog = false;
       try {
-        let ele = this.findComponentWithRefAndMethod(this.$children, 'richArea', 'setExplanatoryNote');
+        let ele = this.findComponentWithRefAndMethod(this.$children, 'richText', 'setExplanatoryNote');
         if (ele) {
-          ele.setExplanatoryNote(text);
+          let richTextID = this.$refs.richText.id;
+          ele.setExplanatoryNote(text, richTextID);
         }
       } catch (error) {
         console.error(error);
@@ -418,26 +451,51 @@ export default {
     },
     // 点击弹窗取消-删除
     cancelExplanatoryNote() {
+      const noteId = this.oldRichData?.id;
+      let richTextID = this.$refs.richText.id;
       try {
-        let ele = this.findComponentWithRefAndMethod(this.$children, 'richArea', 'cancelExplanatoryNote');
-        if (ele) {
-          ele.cancelExplanatoryNote();
+        // 1. 通知富文本子组件移除高亮标签
+        let ele = this.findComponentWithRefAndMethod(this.$children, 'richText', 'cancelExplanatoryNote');
+        if (ele && noteId && richTextID) {
+          ele.cancelExplanatoryNote(noteId, richTextID);
         }
       } catch (error) {
-        console.error(error);
+        console.error('移除高亮失败:', error);
+      }
+      if (noteId) {
+        this.data.note_list = this.data.note_list.filter((p) => p.id !== noteId);
       }
+      this.oldRichData = {};
+      this.isViewExplanatoryNoteDialog = false;
     },
     // 设置备注
     selectContentSetMemo(data, noteId) {
-      if (noteId) {
-        this.data.note_list = this.data.note_list.filter((p) => p.id !== noteId);
+      if (!data) return;
+      // 统一 ID 字段,方便查找
+      const currentId = data.annota_id || data.id;
+      let oldIndex = this.data.note_list.findIndex((p) => (p.id || p.annota_id) === currentId);
+      if (oldIndex > -1) {
+        // 更新:合并旧数据和新数据,防止索引信息丢失
+        const oldNote = this.data.note_list[oldIndex];
+        const updatedNote = {
+          ...oldNote,
+          ...data,
+          // 确保 ID 字段统一
+          annota_id: currentId,
+          id: currentId,
+        };
+        this.data.note_list.splice(oldIndex, 1, updatedNote);
       } else {
-        let oldIndex = this.data.note_list.findIndex((p) => p.id === data.id);
-        if (oldIndex > -1) {
-          this.data.note_list.splice(oldIndex, 1, data);
-        } else {
-          this.data.note_list.push(data);
-        }
+        // 新增:确保包含所有必要字段
+        const newNote = {
+          ...data,
+          annota_id: data.annota_id || data.id,
+          id: data.annota_id || data.id,
+          // 如果新数据里没有索引,给默认值
+          begin_index: data.begin_index === undefined ? 0 : data.begin_index,
+          end_index: data.end_index === undefined ? 0 : data.end_index,
+        };
+        this.data.note_list.push(newNote);
       }
     },
     compareAnnotationAndSave(existingIds) {

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

@@ -861,7 +861,6 @@ export default {
     },
     // 笔记
     setNote() {
-      debugger;
       this.showToolbar = false;
       this.oldRichData = {};
       let info = this.selectHandleInfo;

+ 36 - 76
src/views/book/courseware/preview/components/rich_text/RichTextPreview.vue

@@ -12,6 +12,7 @@
           :unified-attrib="data.unified_attrib"
           :paragraph-list="data.paragraph_list"
           :rich-text-list="data.rich_text_list"
+          :rich-text-note-list="data.note_list"
           :pinyin-position="data.property.pinyin_position"
           :pinyin-overall-position="data.property.pinyin_overall_position"
           :pinyin-size="data?.unified_attrib?.pinyin_size"
@@ -26,7 +27,12 @@
             :file-id="data.audio_file_id"
             :theme-color="data.unified_attrib && data.unified_attrib.topic_color ? data.unified_attrib.topic_color : ''"
           />
-          <span class="rich-text" @click="handleRichFillClick" v-html="convertText(sanitizeHTML(data.content))"></span>
+          <span
+            class="rich-text"
+            @mouseover="handleRichFillClick"
+            @mouseleave="onLeave"
+            v-html="convertText(sanitizeHTML(data.content))"
+          ></span>
         </div>
       </div>
 
@@ -35,18 +41,14 @@
       </div>
     </div>
 
-    <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(selectedNote)"></span>
-    </el-dialog>
+      <div class="popover-content" v-html="sanitizeHTML(selectedNote)"></div>
+    </el-popover>
   </div>
 </template>
 
@@ -68,14 +70,7 @@ export default {
       isPreview: true,
       divHeight: 'auto',
       observer: null,
-      noteDialogVisible: false,
       selectedNote: '',
-      dialogStyle: {
-        position: 'fixed',
-        top: '0',
-        left: '0',
-        margin: '0',
-      },
     };
   },
   mounted() {
@@ -89,65 +84,30 @@ export default {
   },
   methods: {
     handleRichFillClick(event) {
-      // 检查点击的元素是否是 rich-fill 或者其子元素
-      const richFillElement = event.target.closest('.rich-fill');
-      if (richFillElement) {
-        // 处理点击事件
-        let selectedNoteId = richFillElement.dataset.annotationId;
-        if (this.data.note_list.some((p) => p.id === selectedNoteId)) {
-          this.noteDialogVisible = true;
-          this.selectedNote = this.data.note_list.find((p) => p.id === selectedNoteId).note;
-        }
-      } else {
-        this.selectedNote = '';
-        this.noteDialogVisible = false;
+      const targetSpan = event.target.closest('.rich-fill');
+      if (!targetSpan) {
+        return;
       }
-      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;
+      const annotationId = targetSpan.getAttribute('data-annotation-id');
+      if (!annotationId) return;
+      const noteItem = this.data.note_list.find((item) => item.id === annotationId || item.annota_id === annotationId);
+      if (noteItem) {
+        this.selectedNote = noteItem.note || noteItem.dataStr || '';
+        // 关键:将 popover 的参考元素移动到被点击的文字位置
+        // 这样 popover 就会自动围绕这个文字显示
+        this.$nextTick(() => {
+          const refEl = this.$refs.notePopover;
+          refEl.referenceElm = targetSpan;
+          refEl.showPopper = true;
+          // 强制重新计算定位
+          if (refEl.updatePopper) {
+            refEl.updatePopper();
           }
-        }
-
-        this.dialogStyle = {
-          position: 'fixed',
-          top: `${top - 20}px`,
-          left: `${left}px`,
-          margin: '0',
-          transform: 'none',
-        };
-      });
+        });
+      }
+    },
+    onLeave() {
+      this.$refs.notePopover.showPopper = false;
     },
     updateHeight() {
       this.$nextTick(() => {