Bläddra i källkod

Merge branch 'master' into lhd

natasha 1 vecka sedan
förälder
incheckning
a1257f8280

+ 100 - 0
src/components/ExplanatoryNoteDialog.vue

@@ -0,0 +1,100 @@
+<template>
+  <el-dialog title="编辑备注" :visible.sync="visible" width="680px" @close="dialogClose()">
+    <RichText
+      v-model="richData.note"
+      toolbar="fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright"
+      :wordlimit-num="false"
+      :height="240"
+      placeholder="输入内容"
+      page-from="audit"
+    />
+    <template slot="footer">
+      <el-button size="medium" @click="deleteNote">删除</el-button>
+      <el-button type="primary" size="medium" @click="confirm">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script>
+import RichText from './RichText';
+export default {
+  name: 'ExplanatoryNoteDialog',
+  components: {
+    RichText,
+  },
+  props: {
+    open: {
+      type: Boolean,
+      default: false,
+      required: true,
+    },
+    initData: {
+      type: Object,
+      default: () => {},
+    },
+  },
+  data() {
+    return {
+      visible: false,
+      richData: this.initData,
+    };
+  },
+  watch: {
+    open(newVal) {
+      this.visible = newVal;
+      this.richData = this.initData;
+    },
+    visible(newVal) {
+      if (!newVal) {
+        this.$emit('update:open', false);
+        this.richData = {};
+      }
+    },
+  },
+  methods: {
+    // 关闭弹窗,调用富文本组件中的
+    dialogClose() {
+      this.visible = false;
+    },
+    deleteNote() {
+      this.$confirm('确定要删除此条注释吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+        .then(() => {
+          this.visible = false;
+          this.$emit('cancel');
+        })
+        .catch(() => {});
+    },
+    confirm() {
+      this.$emit('confirm', this.richData);
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.el-dialog {
+  :deep &__body {
+    display: flex;
+    flex-direction: column;
+    row-gap: 8px;
+    padding: 8px 8px 0;
+
+    .el-textarea__inner {
+      height: 108px;
+    }
+  }
+
+  :deep &__footer {
+    display: flex;
+    padding: 8px;
+
+    .el-button {
+      flex: 1;
+    }
+  }
+}
+</style>

+ 0 - 2
src/components/MindMap.vue

@@ -114,8 +114,6 @@ export default {
       if (!this.isEdit) {
         // 监听所有节点点击事件
         this.mindMap.on('node_click', (node) => {
-          console.log(node);
-
           // console.log('点击的节点:', node?.nodeData?.data?.text || '文本');
           this.$emit('child-click', `${node.uid}`);
         });

+ 156 - 13
src/components/RichText.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="rich-wrapper">
+  <div ref="richArea" class="rich-wrapper">
     <Editor
       v-bind="$attrs"
       :id="id"
@@ -12,13 +12,18 @@
       @onBlur="handleRichTextBlur"
     />
     <div v-show="isShow" :style="contentmenu" class="contentmenu">
-      <SvgIcon icon-class="slice" size="16" @click="setFill" />
-      <span class="button" @click="setFill">设为填空</span>
-      <span class="line"></span>
-      <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
-      <span class="button" @click="deleteFill">删除填空</span>
+      <div v-if="isViewNote" @click="openExplanatoryNoteDialog">
+        <SvgIcon icon-class="mark" size="14" />
+        <span class="button"> 编辑注释</span>
+      </div>
+      <div v-else>
+        <SvgIcon icon-class="slice" size="16" @click="setFill" />
+        <span class="button" @click="setFill">设为填空</span>
+        <span class="line"></span>
+        <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
+        <span class="button" @click="deleteFill">删除填空</span>
+      </div>
     </div>
-
     <MathDialog :visible.sync="isViewMathDialog" @confirm="mathConfirm" />
   </div>
 </template>
@@ -112,6 +117,10 @@ export default {
       type: String,
       default: '',
     },
+    isViewNote: {
+      type: Boolean,
+      default: false,
+    },
   },
   data() {
     return {
@@ -151,12 +160,13 @@ export default {
         statusbar: false, // 状态栏
         setup: (editor) => {
           let isRendered = false; // 标记是否已渲染
+          let that = this;
           editor.on('init', () => {
             editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小
             editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体
           });
 
-          editor.on('click', () => {
+          editor.on('click', (e) => {
             if (editor?.queryCommandState('ToggleToolbarDrawer')) {
               editor.execCommand('ToggleToolbarDrawer');
             }
@@ -164,6 +174,12 @@ export default {
               isRendered = true;
               window.MathJax.typesetPromise([editor.getBody()]);
             }
+            if (e.target.classList.contains('rich-fill')) {
+              const noteId = e.target.getAttribute('data-annotation-id');
+              that.$emit('selectNote', noteId);
+            } else {
+              that.$emit('selectNote', '');
+            }
           });
 
           // 添加 MathJax 按钮
@@ -177,6 +193,35 @@ export default {
 
           // 内容变化时重新渲染公式
           editor.on('change', () => this.renderMath());
+
+          editor.on('KeyDown', function (e) {
+            // 检测删除或退格键
+            if (e.keyCode === 8 || e.keyCode === 46) {
+              // 延迟执行以确保删除已完成
+              setTimeout(function () {
+                that.cleanupRemovedAnnotations(editor);
+              }, 500);
+            }
+          });
+
+          // 也可以监听剪切操作
+          editor.on('Cut', function () {
+            setTimeout(function () {
+              that.cleanupRemovedAnnotations(editor);
+            }, 500);
+          });
+          // editor.on('NodeChange', function (e) {
+          //   if (
+          //     e.element &&
+          //     e.element.tagName === 'SPAN' &&
+          //     e.element.hasAttribute('data-annotation-id') &&
+          //     (!e.element.textContent || /^\s*$/.test(e.element.textContent))
+          //   ) {
+          //     const annotationId = e.element.getAttribute('data-annotation-id');
+          //     e.element.parentNode.removeChild(e.element);
+          //     that.$emit('selectContentSetMemo', null, annotationId);
+          //   }
+          // });
         },
         font_formats:
           '楷体=楷体,微软雅黑;' +
@@ -195,7 +240,7 @@ export default {
         images_upload_handler: this.imagesUploadHandler,
         file_picker_types: 'media', // 文件上传类型
         file_picker_callback: this.filePickerCallback,
-        init_instance_callback: this.isFill ? this.initInstanceCallback : '',
+        init_instance_callback: this.isFill || this.isViewNote ? this.initInstanceCallback : '',
         paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
         // 粘贴预处理
         paste_preprocess(plugin, args) {
@@ -210,12 +255,25 @@ export default {
       },
     };
   },
+  watch: {
+    isViewNote: {
+      handler(newVal, oldVal) {
+        if (newVal) {
+          let editor = tinymce.get(this.id);
+          if (editor) {
+            let start = editor.selection.getStart();
+            this.$emit('selectNote', start.getAttribute('data-annotation-id'));
+          }
+        }
+      },
+    },
+  },
   created() {
     if (this.pageFrom !== 'audit') {
       window.addEventListener('click', this.hideToolbarDrawer);
     }
 
-    if (this.isFill) {
+    if (this.isFill || this.isViewNote) {
       window.addEventListener('click', this.hideContentmenu);
     }
     this.setBackgroundColor();
@@ -225,7 +283,7 @@ export default {
       window.removeEventListener('click', this.hideToolbarDrawer);
     }
 
-    if (this.isFill) {
+    if (this.isFill || this.isViewNote) {
       window.removeEventListener('click', this.hideContentmenu);
     }
   },
@@ -549,9 +607,10 @@ export default {
     },
     showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
       this.isShow = true;
+      // console.log(pixelsFromLeft, pixelsFromTop);
       this.contentmenu = {
-        left: `${pixelsFromLeft + 14}px`,
-        top: `${pixelsFromTop + 22}px`,
+        left: `${this.isViewNote ? pixelsFromLeft : pixelsFromLeft + 14}px`,
+        top: `${this.isViewNote ? pixelsFromTop + 62 : pixelsFromTop + 22}px`,
       };
     },
 
@@ -587,6 +646,90 @@ export default {
         this.mathEleIsInit = true;
       }
     },
+
+    // 获取高亮 span 标签
+    getLightSpanString(noteId, str) {
+      return `<span data-annotation-id="${noteId}" class="rich-fill" style="background-color:#FACB2F;cursor: pointer;">${str}</span>`;
+    },
+    // 选中文本打开弹窗
+    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') });
+      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');
+      } 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));
+      }
+      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();
+      }
+    },
+    // 删除,监听处理备注
+    cleanupRemovedAnnotations(editor) {
+      if (!this.isViewNote) return; // 只有富文本才处理
+      const body = editor.getBody();
+      const annotations = body.querySelectorAll('span[data-annotation-id]');
+
+      this.handleEmptySpan(editor);
+
+      // 存储所有现有的注释ID
+      const existingIds = new Set();
+      annotations.forEach((span) => {
+        existingIds.add(span.getAttribute('data-annotation-id'));
+      });
+
+      // 与你存储的注释数据对比,清理不存在的
+      this.$emit('compareAnnotationAndSave', existingIds);
+    },
+    // 删除span里面的文字之后,会出现空 span 标签残留,需处理掉
+    handleEmptySpan(editor) {
+      let that = this;
+      const selection = editor.selection;
+      const selectedNode = selection.getNode();
+      // 如果选中的是注释span内的内容
+      if (selectedNode.nodeType === 1 && selectedNode.hasAttribute('data-annotation-id')) {
+        const span = selectedNode;
+        // 检查删除后是否为空
+        if (!span.textContent || /^\s*$/.test(span.textContent)) {
+          // 保存注释ID
+          const annotationId = span.getAttribute('data-annotation-id');
+
+          // 用其父节点替换span
+          span.parentNode.replaceChild(document.createTextNode(''), span);
+
+          // 从存储中移除注释
+          that.$emit('selectContentSetMemo', null, annotationId);
+        }
+      }
+    },
   },
 };
 </script>

+ 3 - 0
src/icons/svg/close-circle.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.99992 13.6663C3.31802 13.6663 0.333252 10.6815 0.333252 6.99967C0.333252 3.31777 3.31802 0.333008 6.99992 0.333008C10.6818 0.333008 13.6666 3.31777 13.6666 6.99967C13.6666 10.6815 10.6818 13.6663 6.99992 13.6663ZM6.99992 12.333C9.94545 12.333 12.3333 9.94521 12.3333 6.99967C12.3333 4.05415 9.94545 1.66634 6.99992 1.66634C4.0544 1.66634 1.66659 4.05415 1.66659 6.99967C1.66659 9.94521 4.0544 12.333 6.99992 12.333ZM6.99992 6.05687L8.88552 4.17125L9.82832 5.11405L7.94272 6.99967L9.82832 8.88527L8.88552 9.82807L6.99992 7.94247L5.1143 9.82807L4.17149 8.88527L6.05712 6.99967L4.17149 5.11405L5.1143 4.17125L6.99992 6.05687Z" fill="black"/>
+</svg>

+ 3 - 0
src/icons/svg/slice.svg

@@ -0,0 +1,3 @@
+<svg width="14" height="12" viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.4606 6.60973L10.6391 7.7882C6.63213 11.7951 3.33233 11.7951 0.503906 10.8523L10.8748 0.481445L13.2318 2.83847L9.4606 6.60973ZM7.575 6.60973L11.3462 2.83847L10.8748 2.36706L3.20967 10.0322C5.03071 10.1038 6.78147 9.42793 8.72053 7.75527L7.575 6.60973Z" fill="black"/>
+</svg>

+ 143 - 16
src/views/book/courseware/create/components/base/rich_text/RichText.vue

@@ -1,21 +1,47 @@
 <template>
   <ModuleBase :type="data.type">
     <template #content>
-      <RichText
-        ref="rich"
-        v-model="data.content"
-        :font-size="18"
-        placeholder="输入描述"
-        :is-view-pinyin="isEnable(data.property.view_pinyin)"
-        @crateParsedTextInfoPinyin="crateParsedTextInfoPinyin"
-      />
-      <el-divider v-if="isEnable(data.property.view_pinyin)" content-position="left">拼音效果</el-divider>
-      <PinyinText
-        v-if="isEnable(data.property.view_pinyin)"
-        :id="richId + '_pinyin_text'"
-        :paragraph-list="data.paragraph_list"
-        :pinyin-position="data.property.pinyin_position"
-        @fillCorrectPinyin="fillCorrectPinyin"
+      <div :style="{ width: data.note_list.length > 0 ? '' : '100%' }">
+        <RichText
+          ref="rich"
+          v-model="data.content"
+          :font-size="18"
+          :is-view-note="true"
+          placeholder="输入内容"
+          :is-view-pinyin="isEnable(data.property.view_pinyin)"
+          @view-explanatory-note="viewExplanatoryNote"
+          @selectContentSetMemo="selectContentSetMemo"
+          @crateParsedTextInfoPinyin="crateParsedTextInfoPinyin"
+          @compareAnnotationAndSave="compareAnnotationAndSave"
+          @selectNote="selectNote"
+        />
+        <el-divider v-if="isEnable(data.property.view_pinyin)" content-position="left">拼音效果</el-divider>
+        <PinyinText
+          v-if="isEnable(data.property.view_pinyin)"
+          :id="richId + '_pinyin_text'"
+          ref="PinyinText"
+          :paragraph-list="data.paragraph_list"
+          :pinyin-position="data.property.pinyin_position"
+          @fillCorrectPinyin="fillCorrectPinyin"
+        />
+      </div>
+      <div v-if="data.note_list.length > 0" class="note-list">
+        <span>注释</span>
+        <ul>
+          <li v-for="note in data.note_list" :key="note.id">
+            <p :style="{ 'background-color': selectedNoteId == note.id ? '#FFF2C9' : '#CCC' }">
+              {{ note.selectText }}
+            </p>
+            <span v-html="sanitizeHTML(note.note)"></span>
+          </li>
+        </ul>
+      </div>
+      <ExplanatoryNoteDialog
+        ref="explanatoryNote"
+        :open.sync="isViewExplanatoryNoteDialog"
+        :initData="oldRichData"
+        @confirm="confirmExplanatoryNote"
+        @cancel="cancelExplanatoryNote"
       />
     </template>
   </ModuleBase>
@@ -29,10 +55,11 @@ import { isEnable } from '@/views/book/courseware/data/common';
 import ModuleMixin from '../../common/ModuleMixin';
 import PinyinText from '@/components/PinyinText.vue';
 import DOMPurify from 'dompurify';
+import ExplanatoryNoteDialog from '@/components/ExplanatoryNoteDialog.vue';
 
 export default {
   name: 'RichTextPage',
-  components: { PinyinText },
+  components: { PinyinText, ExplanatoryNoteDialog },
   mixins: [ModuleMixin],
   data() {
     return {
@@ -40,6 +67,9 @@ export default {
       data: getRichTextData(),
       selectContent: '',
       richId: '',
+      isViewExplanatoryNoteDialog: false,
+      oldRichData: {},
+      selectedNoteId: '',
     };
   },
   watch: {
@@ -110,6 +140,103 @@ export default {
     sanitizeHTML(html) {
       return DOMPurify.sanitize(html, { ALLOWED_TAGS: [] });
     },
+    // 打开弹窗,接收子组件的值 { visible: true, noteId: start.id }
+    viewExplanatoryNote(val) {
+      debugger;
+      this.isViewExplanatoryNoteDialog = val.visible;
+      let 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 {
+        this.oldRichData = {};
+      }
+    },
+    selectNote(id) {
+      this.selectedNoteId = id;
+    },
+    // 点击弹窗确认-保存
+    confirmExplanatoryNote(text) {
+      console.log(text);
+      this.isViewExplanatoryNoteDialog = false;
+      try {
+        let ele = this.findComponentWithRefAndMethod(this.$children, 'richArea', 'setExplanatoryNote');
+        if (ele) {
+          ele.setExplanatoryNote(text);
+        }
+      } catch (error) {
+        console.error(error);
+      }
+    },
+    // 点击弹窗取消-删除
+    cancelExplanatoryNote() {
+      try {
+        let ele = this.findComponentWithRefAndMethod(this.$children, 'richArea', 'cancelExplanatoryNote');
+        if (ele) {
+          ele.cancelExplanatoryNote();
+        }
+      } catch (error) {
+        console.error(error);
+      }
+    },
+    // 设置备注
+    selectContentSetMemo(data, noteId) {
+      if (noteId) {
+        this.data.note_list = this.data.note_list.filter((p) => p.id !== noteId);
+      } 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);
+        }
+      }
+    },
+    compareAnnotationAndSave(existingIds) {
+      this.data.note_list.forEach((annotation) => {
+        if (!existingIds.has(annotation.id)) {
+          // 从你的数据存储中移除这个注释
+          this.selectContentSetMemo(null, annotation.id);
+        }
+      });
+    },
   },
 };
 </script>
+<style lang="scss" scoped>
+:deep .module-content {
+  display: flex;
+  column-gap: 10px;
+
+  .note-list {
+    display: flex;
+    flex-direction: column;
+    width: 20%;
+
+    > span {
+      font-weight: bold;
+    }
+
+    li {
+      margin-top: 6px;
+      border-radius: 8px;
+      box-shadow: 1px 3px 2px rgba(0, 0, 0, 10%);
+
+      p {
+        padding: 4px;
+        margin: 0;
+        background-color: #ccc;
+        border-radius: 8px 8px 0 0;
+      }
+
+      span {
+        display: block;
+        padding: 8px;
+      }
+    }
+
+    p {
+      text-align: center;
+    }
+  }
+}
+</style>

+ 15 - 0
src/views/book/courseware/create/components/common/ModuleMixin.js

@@ -109,6 +109,21 @@ const mixin = {
         content: JSON.stringify(this.data),
       });
     },
+    findComponentWithRefAndMethod(children, refName, methodName) {
+      for (const child of children) {
+        // 检查当前组件是否符合条件
+        if (child.$refs[refName] && typeof child[methodName] === 'function') {
+          return child;
+        }
+
+        // 递归检查子组件
+        if (child.$children?.length) {
+          const found = this.findComponentWithRefAndMethod(child.$children, refName, methodName);
+          if (found) return found;
+        }
+      }
+      return null;
+    },
   },
 };
 

+ 1 - 0
src/views/book/courseware/data/richText.js

@@ -465,6 +465,7 @@ export function getRichTextData() {
     type: 'richtext',
     title: '富文本',
     content: '',
+    note_list: [], // 注释
     paragraph_list: [],
     paragraph_list_parameter: {
       text: '',

+ 75 - 7
src/views/book/courseware/preview/components/rich_text/RichTextPreview.vue

@@ -3,12 +3,25 @@
     <SerialNumberPosition v-if="isEnable(data.property.sn_display_mode)" :property="data.property" />
 
     <div class="main">
-      <PinyinText
-        v-if="isEnable(data.property.view_pinyin)"
-        :paragraph-list="data.paragraph_list"
-        :pinyin-position="data.property.pinyin_position"
-      />
-      <span v-else class="rich-text" v-html="sanitizeHTML(data.content)"></span>
+      <div :style="{ width: data.note_list.length > 0 ? '' : '100%' }">
+        <PinyinText
+          v-if="isEnable(data.property.view_pinyin)"
+          :paragraph-list="data.paragraph_list"
+          :pinyin-position="data.property.pinyin_position"
+        />
+        <span v-else class="rich-text" v-html="sanitizeHTML(data.content)" @click="handleRichFillClick"></span>
+      </div>
+      <div v-if="data.note_list.length > 0" class="note-list">
+        <span>注释</span>
+        <ul>
+          <li v-for="note in data.note_list" :key="note.id">
+            <p :style="{ 'background-color': selectedNoteId == note.id ? '#FFF2C9' : '#CCC' }">
+              {{ note.selectText }}
+            </p>
+            <span v-html="sanitizeHTML(note.note)"></span>
+          </li>
+        </ul>
+      </div>
     </div>
   </div>
 </template>
@@ -27,9 +40,22 @@ export default {
     return {
       isEnable,
       data: getRichTextData(),
+      selectedNoteId: '',
     };
   },
-  methods: {},
+  methods: {
+    handleRichFillClick(event) {
+      // 检查点击的元素是否是 rich-fill 或者其子元素
+      const richFillElement = event.target.closest('.rich-fill');
+      if (richFillElement) {
+        // 处理点击事件
+        this.selectedNoteId = richFillElement.dataset.annotationId;
+        // 这里可以添加你的业务逻辑
+      } else {
+        this.selectedNoteId = '';
+      }
+    },
+  },
 };
 </script>
 
@@ -38,5 +64,47 @@ export default {
 
 .describe-preview {
   @include preview-base;
+
+  .main {
+    display: flex;
+    column-gap: 10px;
+    justify-content: space-between;
+
+    .note-list {
+      display: flex;
+      flex-direction: column;
+      width: 20%;
+
+      > span {
+        font-weight: bold;
+      }
+
+      li {
+        margin-top: 6px;
+        border-radius: 8px;
+        box-shadow: 1px 3px 2px rgba(0, 0, 0, 10%);
+
+        p {
+          padding: 4px;
+          margin: 0;
+          background-color: #ccc;
+          border-radius: 8px 8px 0 0;
+        }
+
+        span {
+          display: block;
+          padding: 8px;
+
+          :deep p {
+            margin: 0 !important;
+          }
+        }
+      }
+
+      p {
+        text-align: center;
+      }
+    }
+  }
 }
 </style>