浏览代码

知识图谱与收藏

dsy 4 周之前
父节点
当前提交
37fc4e209e

文件差异内容过多而无法显示
+ 155 - 587
package-lock.json


+ 24 - 18
package.json

@@ -15,9 +15,10 @@
   },
   "dependencies": {
     "@tinymce/tinymce-vue": "^3.2.8",
+    "7zip-bin": "^5.2.0",
     "axios": "^1.7.2",
     "cnchar": "^3.2.6",
-    "core-js": "^3.37.1",
+    "core-js": "^3.47.0",
     "dompurify": "^3.1.5",
     "element-ui": "^2.15.14",
     "hanzi-writer": "^3.7.0",
@@ -27,10 +28,12 @@
     "md5": "^2.3.0",
     "nprogress": "^0.2.0",
     "opencc-js": "^1.0.5",
-    "simple-mind-map": "^0.14.0-fix.1",
-    "three": "^0.178.0",
+    "simple-mind-map": "^0.14.0",
+    "three": "^0.181.2",
     "tinymce": "^5.10.9",
     "v-fit-columns": "^0.2.0",
+    "vis-data": "^8.0.3",
+    "vis-network": "^10.0.2",
     "vue": "^2.6.14",
     "vue-esign": "^1.1.4",
     "vue-router": "^3.6.5",
@@ -39,24 +42,27 @@
     "vuex": "^3.6.2"
   },
   "devDependencies": {
-    "@babel/core": "^7.24.7",
-    "@babel/eslint-parser": "^7.24.7",
-    "@rushstack/eslint-patch": "^1.10.3",
-    "@types/md5": "^2.3.5",
-    "@vue/cli-plugin-babel": "~5.0.8",
-    "@vue/cli-plugin-eslint": "~5.0.8",
-    "@vue/cli-service": "~5.0.8",
+    "@babel/core": "^7.28.5",
+    "@babel/eslint-parser": "^7.28.5",
+    "@electron/fuses": "^1.8.0",
+    "@rushstack/eslint-patch": "^1.15.0",
+    "@types/md5": "^2.3.6",
+    "@vue/cli-plugin-babel": "~5.0.9",
+    "@vue/cli-plugin-eslint": "~5.0.9",
+    "@vue/cli-service": "~5.0.9",
     "@vue/eslint-config-prettier": "^9.0.0",
     "@vue/preload-webpack-plugin": "^2.0.0",
     "compression-webpack-plugin": "^6.1.2",
-    "eslint": "^8.57.0",
-    "eslint-plugin-prettier": "^5.1.3",
-    "eslint-plugin-vue": "^9.26.0",
-    "nodemon": "^3.1.3",
-    "patch-package": "^8.0.0",
-    "postcss-html": "^1.7.0",
-    "prettier": "^3.3.2",
-    "sass": "^1.77.6",
+    "electron": "29.3.2",
+    "electron-builder": "^24.13.3",
+    "eslint": "^8.57.1",
+    "eslint-plugin-prettier": "^5.5.4",
+    "eslint-plugin-vue": "^9.33.0",
+    "nodemon": "^3.1.11",
+    "patch-package": "^8.0.1",
+    "postcss-html": "^1.8.0",
+    "prettier": "^3.7.3",
+    "sass": "^1.94.2",
     "sass-loader": "^14.2.1",
     "stylelint": "^15.11.0",
     "stylelint-config-recess-order": "^4.6.0",

+ 77 - 0
src/api/book.js

@@ -282,3 +282,80 @@ export function ApplyBookUnifiedAttrib(data) {
 export function GetBookUnifiedAttrib(data) {
   return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-GetBookUnifiedAttrib`, data);
 }
+
+/**
+ *@description 添加我的笔记
+ * @param {object} data
+ */
+export function AddMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-AddMyNote`, data);
+}
+/**
+ *@description 更新我的笔记
+ * @param {object} data
+ */
+export function UpdateMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-UpdateMyNote`, data);
+}
+/**
+ * @description 得到我的笔记列表
+ * @param {object} data
+ */
+export function GetMyNoteList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-GetMyNoteList`, data);
+}
+/**
+ * @description 删除我的笔记
+ * @param {object} data
+ */
+export function DeleteMyNote(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-DeleteMyNote`, data);
+}
+/**
+ *@description 添加我的收藏
+ * @param {object} data
+ */
+export function AddMyCollect(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-AddMyCollect`, data);
+}
+/**
+ * @description 得到我的收藏列表
+ * @param {object} data
+ */
+export function GetMyCollectList(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-GetMyCollectList`, data);
+}
+/**
+ * @description 删除我的收藏
+ * @param {object} data
+ */
+export function DeleteMyCollect(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_preview_manager-DeleteMyCollect`, data);
+}
+
+/**
+ * @description 根据教材内容生成知识图谱
+ * @param {object} data
+ */
+export function MangerGenerateKnowledgeGraphByBookContent(data) {
+  return http.post(
+    `${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-GenerateKnowledgeGraphByBookContent`,
+    data,
+  );
+}
+
+/**
+ * @description 得到教材知识图谱
+ * @param {object} data
+ */
+export function GetBookKnowledgeGraph(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-GetBookKnowledgeGraph`, data);
+}
+
+/**
+ * @description 保存教材知识图谱
+ * @param {object} data
+ */
+export function SaveBookKnowledgeGraph(data) {
+  return http.post(`${process.env.VUE_APP_EepServer}?MethodName=book_content_manager-SaveBookKnowledgeGraph`, data);
+}

+ 111 - 0
src/components/ExplanatoryNoteDialog copy.vue

@@ -0,0 +1,111 @@
+<template>
+  <el-dialog
+    :title="'编辑' + titleText"
+    :visible.sync="visible"
+    width="680px"
+    :close-on-click-modal="false"
+    @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: () => ({}),
+    },
+    titleText: {
+      type: String,
+      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(`确定要删除此条${this.titleText}吗?`, '提示', {
+        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>

+ 417 - 0
src/components/VisNetwork.vue

@@ -0,0 +1,417 @@
+<template>
+  <div class="vis-network-container">
+    <div v-if="isEdit" class="toolbar">
+      说明:双击节点可以进行操作
+      <el-button type="primary" @click="addNode">添加子节点</el-button>
+      <el-button type="danger" @click="deleteNode">删除</el-button>
+    </div>
+    <div ref="network" class="network">
+      <p class="noData">{{ loadingTitle }}</p>
+    </div>
+    <el-dialog
+      title="编辑"
+      :visible.sync="showEditModal"
+      width="30%"
+      :close-on-click-modal="false"
+      :append-to-body="true"
+    >
+      <el-form :model="editForm" label-width="80px">
+        <el-form-item label="所属层级">
+          <el-input v-model="editForm.fullPath" type="textarea" disabled />
+        </el-form-item>
+        <el-form-item label="节点名称">
+          <el-input v-model="editForm.label" type="textarea" placeholder="请输入节点名称" />
+        </el-form-item>
+
+        <el-form-item v-if="false" label="节点类型">
+          <select v-model="editForm.type" class="form-select">
+            <option value="0">章节</option>
+            <option value="3">资源类型</option>
+            <option value="4">具体文件</option>
+          </select>
+        </el-form-item>
+      </el-form>
+
+      <!-- 按钮区域 -->
+      <div slot="footer" class="dialog-footer">
+        <el-button type="primary" @click="cancelEdit">取消</el-button>
+        <el-button type="success" @click="saveEdit">保存</el-button>
+        <el-button type="danger" @click="deleteNode">删除</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { DataSet } from 'vis-data';
+import { Network } from 'vis-network';
+import { GetBookKnowledgeGraph } from '@/api/book';
+export default {
+  name: 'VisNetwork',
+  props: {
+    isEdit: {
+      type: Boolean,
+      default: false,
+    },
+    bookId: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      loadingTitle: '数据加载中...',
+      network: null,
+      nodes: null,
+      edges: null,
+      selectedNode: null,
+      showEditModal: false,
+      editingNode: null,
+      positions: {},
+      jsonData: {},
+      hasData: false,
+      editForm: {
+        label: '',
+        type: '0',
+        fullPath: '',
+      },
+    };
+  },
+  computed: {},
+  created() {
+    this.hasData = false;
+    this.getBookVisNetWork();
+  },
+  mounted() {},
+  beforeDestroy() {
+    this.destroyNetwork();
+  },
+  methods: {
+    /**
+     * 得到教材知识图谱
+     * @param {string} id - 教材ID
+     */
+    getBookVisNetWork() {
+      GetBookKnowledgeGraph({ book_id: this.bookId })
+        .then((res) => {
+          this.convertToVisNetworkData(res);
+        })
+        .catch(() => {
+          this.loadingTitle = '数据未生成';
+        });
+    },
+    init() {
+      this.destroyNetwork();
+      this.initNetwork(this.jsonData.nodes, this.jsonData.edges);
+    },
+    destroyNetwork() {
+      if (this.network) {
+        this.network.destroy();
+      }
+      this.positions = {};
+    },
+    initNetwork(nodeData, edgeData) {
+      this.nodes = new DataSet(nodeData);
+      this.edges = new DataSet(edgeData);
+      const data = {
+        nodes: this.nodes,
+        edges: this.edges,
+      };
+
+      // 创建网络图
+      const container = this.$refs.network;
+
+      let options = {
+        nodes: {
+          shape: 'box',
+          margin: 10,
+          size: 20,
+          font: {
+            size: 14,
+          },
+          borderWidth: 2,
+        },
+        edges: {
+          width: 2,
+          color: { color: '#848484' },
+          smooth: {
+            type: 'continuous',
+          },
+        },
+        groups: {
+          default: {
+            color: { background: '#97C2FC', border: '#6AA6F8' },
+            borderWidth: 2,
+            font: { color: '#FFFFFF' },
+          },
+          type0: {
+            color: { background: '#97C2FC', border: '#84B7FD' },
+            borderWidth: 3,
+          },
+          type1: {
+            color: { background: '#FFA807', border: '#FDA400' },
+            borderWidth: 2,
+          },
+          type2: {
+            color: { background: '#2ECC71', border: '#0BCC5D' },
+            borderWidth: 2,
+          },
+          type3: {
+            color: { background: '#FB7E81', border: '#F85C5F' },
+            borderWidth: 2,
+          },
+        },
+        physics: {
+          enabled: true,
+          stabilization: {
+            iterations: 100,
+          },
+        },
+        interaction: {
+          hover: true,
+          tooltipDelay: 200,
+        },
+      };
+
+      this.network = new Network(container, data, options);
+
+      // 添加事件监听
+      this.network.on('click', (params) => {
+        if (params.nodes.length > 0) {
+          const nodeId = params.nodes[0];
+          if (!this.isEdit) {
+            this.$emit('child-click', nodeId);
+            return;
+          }
+          this.selectedNode = this.nodes.get(nodeId);
+          this.editingNode = nodeId;
+        } else {
+          this.selectedNode = null;
+        }
+      });
+
+      this.network.on('doubleClick', (params) => {
+        if (!this.isEdit) return;
+        if (params.nodes.length > 0) {
+          const nodeId = params.nodes[0];
+          this.handleNodeDoubleClick(nodeId);
+        }
+      });
+
+      this.network.on('oncontext', (params) => {
+        params.event.preventDefault();
+      });
+      this.network.on('hoverNode', (params) => {});
+    },
+
+    // 树形数据转换工具
+    convertToVisData(treeData) {
+      const nodes = [];
+      const edges = [];
+      let x = 0;
+      let y = 0;
+
+      const traverse = (node, parentId = null, level = 0, parentPath = '') => {
+        const nodeId = node.data.uid;
+
+        // 创建节点
+        nodes.push({
+          id: nodeId,
+          label: node.data.text,
+          type: node.data.type,
+          level,
+          fullPath: parentPath, // 保存所有父级路径,用->分隔
+          group: `type${node.data.type || 0}`,
+        });
+        // 创建边(如果有父节点)
+        if (parentId) {
+          edges.push({
+            from: parentId,
+            to: nodeId,
+            arrows: 'to',
+            smooth: { type: 'cubicBezier' },
+          });
+        }
+
+        // 递归处理子节点
+        if (node.children && node.children.length > 0) {
+          // 构建当前节点的完整路径
+          const currentPath = parentPath ? `${parentPath}->${node.data.text}` : node.data.text;
+
+          node.children.forEach((child, index) => {
+            if (level < 3) traverse(child, nodeId, level + 1, currentPath);
+          });
+        }
+      };
+
+      // 调用时传入空字符串作为初始路径
+      traverse(treeData.root, null, 0, '');
+      return { nodes, edges };
+    },
+
+    handleNodeDoubleClick(nodeId) {
+      const node = this.nodes.get(nodeId);
+      console.info(node);
+      if (node) {
+        this.editingNode = nodeId;
+        this.editForm = {
+          label: node.label,
+          type: node.type,
+          fullPath: node.fullPath,
+        };
+        this.showEditModal = true;
+      }
+    },
+    addOrUpdate(data) {
+      if (!data) return;
+      let oldNodes = this.nodes.get();
+      let oldEdges = this.edges.get();
+      const node = this.nodes.get(data.id);
+      if (node) {
+        oldNodes = oldNodes.filter((x) => x.id !== node.id);
+        let newData = Object.assign({}, node, data);
+        oldNodes.push(newData);
+      } else {
+        oldNodes.push(data);
+      }
+
+      this.positions = this.network.getPositions() || {};
+      oldNodes.map((x) => {
+        if (x.id && this.positions[x.id]) {
+          let tmpPosition = this.positions[x.id];
+          x.x = tmpPosition.x;
+          x.y = tmpPosition.y;
+        }
+      });
+      console.info('oldNodes', oldNodes);
+      this.initNetwork(oldNodes, oldEdges);
+    },
+    addNode() {
+      if (!this.selectedNode) {
+        this.$message.error('请先选择父节点!');
+        return;
+      }
+      let newId = this.generateUniqueId();
+      const newNode = Object.assign(
+        {},
+        {
+          id: newId,
+          label: '新增节点',
+          fullPath: `${this.selectedNode.fullPath}->${this.selectedNode.label}`,
+          type: (this.selectedNode.type || 0) + 1,
+          level: (this.selectedNode.level || 0) + 1,
+        },
+      );
+      newNode.group = `type${newNode.type}`;
+
+      newId = this.generateUniqueId();
+      this.edges.add({
+        id: newId,
+        from: this.selectedNode.id,
+        to: newNode.id,
+        arrows: 'to',
+      });
+
+      // this.nodes.add(newNode);
+      this.addOrUpdate(newNode);
+    },
+
+    // 保存编辑
+    saveEdit() {
+      if (!this.editingNode) return;
+
+      const updatedNode = {
+        id: this.editingNode,
+        label: this.editForm.label,
+        type: parseInt(this.editForm.type),
+      };
+
+      // this.nodes.update(updatedNode);
+      // this.$emit('node-updated', updatedNode);
+      this.addOrUpdate(updatedNode);
+      this.cancelEdit();
+    },
+
+    // 取消编辑
+    cancelEdit() {
+      this.showEditModal = false;
+      this.editingNode = null;
+      this.editForm = { label: '', type: '0' };
+    },
+
+    // 删除节点
+    deleteNode() {
+      if (!this.editingNode) return;
+      this.$confirm('确定要删除此节点吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      })
+        .then(() => {
+          this.nodes.remove({ id: this.editingNode });
+          this.$emit('node-deleted', this.editingNode);
+          this.cancelEdit();
+        })
+        .catch(() => {});
+    },
+    saveData() {
+      if (!this.hasData) return null;
+      let nodes = this.nodes.get();
+      let edges = this.edges.get();
+      this.positions = this.network.getPositions() || {};
+      nodes.map((x) => {
+        if (x.id && this.positions[x.id]) {
+          let tmpPosition = this.positions[x.id];
+          x.x = tmpPosition.x;
+          x.y = tmpPosition.y;
+        }
+      });
+      return { nodes, edges };
+    },
+    generateUniqueId() {
+      return Date.now() + Math.random().toString(36).substr(2, 9);
+    },
+
+    convertToVisNetworkData(res) {
+      if (res && res.status === 1) {
+        this.loadingTitle = '数据渲染中...';
+        if (res.content_node && res.content_edge) {
+          let nodes = (JSON.parse(res.content_node) || {}).nodes;
+          let edges = (JSON.parse(res.content_edge) || {}).edges;
+          nodes.map((x) => (x.group = `type${x.type || 0}`));
+          // nodes=nodes.filter(x=>x.level<4)
+          this.jsonData = {
+            nodes,
+            edges,
+          };
+          this.hasData = true;
+          this.init();
+        }
+      }
+      this.loadingTitle = '数据未生成';
+    },
+  },
+};
+</script>
+
+<style scoped>
+.vis-network-container {
+  width: 100%;
+  padding: 1px;
+}
+
+.network {
+  width: 100%;
+  height: calc(100vh - 280px);
+  margin-bottom: 5px;
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+}
+
+.noData {
+  display: flex;
+  align-items: center; /* 垂直居中 */
+  justify-content: center; /* 水平居中 */
+  height: calc(100vh - 250px);
+}
+</style>

+ 19 - 6
src/utils/http.js

@@ -79,17 +79,27 @@ service.interceptors.response.use(
 /**
  * 得到必需的请求参数
  * @param {boolean} noTransmit 不传递旧的请求参数
+ * @param {object} options 其他选项
+ * @param {boolean} options.newAccessToken 是否使用新的AccessToken
  * @returns {object} 返回必需的请求参数
  * */
-function getRequestParams(noTransmit = false) {
+function getRequestParams(noTransmit = false, options = { newAccessToken: false }) {
+  const { newAccessToken } = options;
+
   const token = getToken();
+  let AccessToken = token?.access_token ?? '';
+  if (newAccessToken) {
+    AccessToken = token?.gcls_sys_session_info?.access_token || AccessToken;
+  }
+
   const params = {
     UserCode: token?.gcls_sys_session_info?.user_id ?? '',
     UserType: token?.gcls_sys_session_info?.user_type ?? '',
     SessionID: token?.gcls_sys_session_info?.session_id ?? '',
   };
+
   return {
-    AccessToken: token?.access_token ?? '',
+    AccessToken,
     ...(!noTransmit && params),
   };
 }
@@ -107,18 +117,21 @@ export const http = {
    * @param {string} url 请求地址
    * @param {object} data 请求数据
    * @param {object} config 请求配置
+   * @param {boolean} noTransmit 不传递旧的请求参数
+   * @param {object} options 其他选项
+   * @param {boolean} options.newAccessToken 是否使用新的AccessToken
    */
-  post: (url, data = {}, config = {}, noTransmit = false) => {
+  post: (url, data = {}, config = {}, noTransmit = false, options = { newAccessToken: false }) => {
     config.params = {
       ...config.params,
-      ...getRequestParams(noTransmit),
+      ...getRequestParams(noTransmit, options),
     };
     return service.post(url, data, config);
   },
-  postForm: (url, data = {}, config = {}, noTransmit = false) => {
+  postForm: (url, data = {}, config = {}, noTransmit = false, options = { newAccessToken: false }) => {
     config.params = {
       ...config.params,
-      ...getRequestParams(noTransmit),
+      ...getRequestParams(noTransmit, options),
     };
     return service.postForm(url, data, config);
   },

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

@@ -1,5 +1,5 @@
 <template>
-  <div ref="courserware" class="courserware" :style="computedCourserwareStyle()">
+  <div ref="courserware" class="courserware" :style="computedCourserwareStyle()" @mouseup="handleTextSelection">
     <template v-for="(row, i) in data.row_list">
       <div v-show="computedRowVisibility(row.row_id)" :key="i" class="row" :style="getMultipleColStyle(i)">
         <el-checkbox
@@ -68,6 +68,12 @@
         </template>
       </div>
     </template>
+    <!-- 选中文本的工具栏 -->
+    <div v-show="showToolbar" class="contentmenu" :style="contentmenu">
+      <span class="button" @click="setNote"><SvgIcon icon-class="sidebar-text" size="14" /> 笔记</span>
+      <span class="line"></span>
+      <span class="button" @click="setCollect"><SvgIcon icon-class="sidebar-collect" size="14" /> 收藏 </span>
+    </div>
   </div>
 </template>
 
@@ -87,6 +93,10 @@ export default {
       type: Object,
       default: () => ({}),
     },
+    coursewareId: {
+      type: String,
+      default: '',
+    },
     background: {
       type: Object,
       default: () => ({}),
@@ -127,6 +137,7 @@ export default {
   data() {
     return {
       previewComponentList,
+      courseware_id: this.coursewareId,
       bookInfo: {
         theme_color: '',
       },
@@ -138,6 +149,12 @@ export default {
       menuPosition: { x: 0, y: 0, select_node: '' }, // 用于存储菜单的位置
       componentId: '', // 添加批注的组件id
       rowCheckList: {},
+      showToolbar: false,
+      contentmenu: {
+        left: 0,
+        top: 0,
+      },
+      selectedInfo: null,
     };
   },
   watch: {
@@ -152,6 +169,11 @@ export default {
           }, {});
       },
     },
+    coursewareId: {
+      handler(val) {
+        this.courseware_id = val;
+      },
+    },
   },
   mounted() {
     const element = this.$refs.courserware;
@@ -411,6 +433,285 @@ export default {
         });
       });
     },
+    // 处理选中文本
+    handleTextSelection() {
+      this.showToolbar = false;
+      // 延迟处理,确保选择已完成
+      setTimeout(() => {
+        const selection = window.getSelection();
+        if (selection.toString().trim() === '') return null;
+
+        const selectedText = selection.toString().trim();
+        const range = selection.getRangeAt(0);
+        console.info(selectedText, range);
+        this.showToolbar = true;
+        const container = document.querySelector('.courserware');
+        const boxRect = container.getBoundingClientRect();
+        const selectRect = range.getBoundingClientRect();
+        this.contentmenu = {
+          left: `${Math.round(selectRect.left - boxRect.left + selectRect.width / 2 - 63)}px`,
+          top: `${Math.round(selectRect.top - boxRect.top + selectRect.height)}px`, // 向上偏移10px
+        };
+
+        this.selectedInfo = {
+          text: selectedText,
+          range,
+        };
+      }, 100);
+    },
+    // 笔记
+    setNote() {
+      this.showToolbar = false;
+      this.oldRichData = {};
+      let info = this.getSelectionInfo();
+      if (!info) return;
+      info.coursewareId = this.courseware_id;
+
+      this.$emit('editNote', info);
+      this.selectedInfo = null;
+    },
+    // 加入收藏
+    setCollect() {
+      this.showToolbar = false;
+
+      let info = this.getSelectionInfo();
+      if (!info) return;
+      info.coursewareId = this.courseware_id;
+
+      this.$emit('saveCollect', info);
+      this.selectedInfo = null;
+    },
+    // 定位
+    handLocation(item) {
+      this.scrollToDataId(item.blockId);
+    },
+    getSelectionInfo() {
+      if (!this.selectedInfo) return;
+      const range = this.selectedInfo.range;
+      let selectedText = this.selectedInfo.text;
+      if (!selectedText) return null;
+
+      let commonAncestor = range.commonAncestorContainer;
+      if (commonAncestor.nodeType === Node.TEXT_NODE) {
+        commonAncestor = commonAncestor.parentNode;
+      }
+
+      const blockElement = commonAncestor.closest('[data-id]');
+      if (!blockElement) return null;
+
+      const blockId = blockElement.dataset.id;
+
+      // 获取所有汉字元素
+      const charElements = blockElement.querySelectorAll('.py-char,.rich-text,.NNPE-chs');
+
+      // 构建包含位置信息的文本数组
+      const textFragments = Array.from(charElements)
+        .map((el, index) => {
+          let text = '';
+          if (el.classList.contains('rich-text')) {
+            const pElements = Array.from(el.querySelectorAll('p'));
+            text = pElements.map((p) => p.textContent.trim()).join('');
+          } else if (el.classList.contains('NNPE-chs')) {
+            const spanElements = Array.from(el.querySelectorAll('span'));
+            spanElements.push(el);
+            text = spanElements.map((span) => span.textContent.trim()).join('');
+          } else {
+            text = el.textContent.trim();
+          }
+
+          // 过滤掉拼音和空文本
+          if (!text || /^[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]+$/i.test(text)) {
+            return { text: '', element: el, index };
+          }
+
+          return { text, element: el, index };
+        })
+        .filter((fragment) => fragment.text);
+
+      // 获取完整的纯文本
+      const fullText = textFragments.map((f) => f.text).join('');
+
+      // 清理选中文本
+      let cleanSelectedText = selectedText.replace(/\n/g, '').trim();
+      cleanSelectedText = cleanSelectedText.replace(/[a-zāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜü]/gi, '').trim();
+
+      if (!cleanSelectedText) return null;
+
+      // 方案1A:使用Range的边界点精确定位
+      try {
+        const startContainer = range.startContainer;
+        const startOffset = range.startOffset;
+
+        // 找到选择开始的元素在textFragments中的位置
+        let startFragmentIndex = -1;
+        let cumulativeLength = 0;
+        let startIndexInFullText = -1;
+
+        for (let i = 0; i < textFragments.length; i++) {
+          const fragment = textFragments[i];
+
+          // 检查这个元素是否包含选择起点
+          if (fragment.element.contains(startContainer) || fragment.element === startContainer) {
+            // 计算在这个元素内的起始位置
+            if (startContainer.nodeType === Node.TEXT_NODE) {
+              // 如果是文本节点,需要计算在父元素中的偏移
+              const elementText = fragment.text;
+              startFragmentIndex = i;
+              startIndexInFullText = cumulativeLength + Math.min(startOffset, elementText.length);
+              break;
+            } else {
+              // 如果是元素节点,从0开始
+              startFragmentIndex = i;
+              startIndexInFullText = cumulativeLength;
+              break;
+            }
+          }
+          cumulativeLength += fragment.text.length;
+        }
+
+        if (startIndexInFullText === -1) {
+          // 如果精确定位失败,回退到文本匹配(但使用更智能的匹配)
+          return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
+        }
+
+        const endIndexInFullText = startIndexInFullText + cleanSelectedText.length;
+
+        return {
+          blockId,
+          text: cleanSelectedText,
+          startIndex: startIndexInFullText,
+          endIndex: endIndexInFullText,
+          fullText,
+        };
+      } catch (error) {
+        console.warn('精确位置计算失败,使用备选方案:', error);
+        return this.fallbackToTextMatch(fullText, cleanSelectedText, range, textFragments);
+      }
+    },
+
+    // 备选方案:基于DOM位置的智能匹配
+    fallbackToTextMatch(fullText, selectedText, range, textFragments) {
+      // 获取选择范围的近似位置
+      const rangeRect = range.getBoundingClientRect();
+
+      // 找到最接近选择中心的文本片段
+      let closestFragment = null;
+      let minDistance = Infinity;
+
+      textFragments.forEach((fragment) => {
+        const rect = fragment.element.getBoundingClientRect();
+        if (rect.width > 0 && rect.height > 0) {
+          // 确保元素可见
+          const centerX = rect.left + rect.width / 2;
+          const centerY = rect.top + rect.height / 2;
+          const rangeCenterX = rangeRect.left + rangeRect.width / 2;
+          const rangeCenterY = rangeRect.top + rangeRect.height / 2;
+
+          const distance = Math.sqrt(Math.pow(centerX - rangeCenterX, 2) + Math.pow(centerY - rangeCenterY, 2));
+
+          if (distance < minDistance) {
+            minDistance = distance;
+            closestFragment = fragment;
+          }
+        }
+      });
+
+      if (closestFragment) {
+        // 从最近的片段开始向前后搜索匹配
+        const fragmentIndex = textFragments.indexOf(closestFragment);
+        let cumulativeLength = 0;
+
+        // 计算到当前片段的累计长度
+        for (let i = 0; i < fragmentIndex; i++) {
+          cumulativeLength += textFragments[i].text.length;
+        }
+
+        // 在当前片段附近搜索匹配
+        const searchStart = Math.max(0, cumulativeLength - selectedText.length * 3);
+        const searchEnd = Math.min(
+          fullText.length,
+          cumulativeLength + closestFragment.text.length + selectedText.length * 3,
+        );
+
+        const searchArea = fullText.substring(searchStart, searchEnd);
+        const localIndex = searchArea.indexOf(selectedText);
+
+        if (localIndex !== -1) {
+          return {
+            startIndex: searchStart + localIndex,
+            endIndex: searchStart + localIndex + selectedText.length,
+            text: selectedText,
+            fullText,
+          };
+        }
+      }
+
+      // 最终回退:使用所有匹配位置,选择最合理的一个
+      const allMatches = [];
+      let searchIndex = 0;
+
+      while ((searchIndex = fullText.indexOf(selectedText, searchIndex)) !== -1) {
+        allMatches.push(searchIndex);
+        searchIndex += selectedText.length;
+      }
+
+      if (allMatches.length === 1) {
+        return {
+          startIndex: allMatches[0],
+          endIndex: allMatches[0] + selectedText.length,
+          text: selectedText,
+          fullText,
+        };
+      } else if (allMatches.length > 1) {
+        // 如果有多个匹配,选择位置最接近选择中心的
+        if (closestFragment) {
+          let cumulativeLength = 0;
+          let fragmentStartIndex = 0;
+
+          for (let i = 0; i < textFragments.length; i++) {
+            if (textFragments[i] === closestFragment) {
+              fragmentStartIndex = cumulativeLength;
+              break;
+            }
+            cumulativeLength += textFragments[i].text.length;
+          }
+
+          // 选择最接近当前片段起始位置的匹配
+          const bestMatch = allMatches.reduce((best, current) => {
+            return Math.abs(current - fragmentStartIndex) < Math.abs(best - fragmentStartIndex) ? current : best;
+          });
+
+          return {
+            startIndex: bestMatch,
+            endIndex: bestMatch + selectedText.length,
+            text: selectedText,
+            fullText,
+          };
+        }
+      }
+
+      return null;
+    },
+    /**
+     * 滚动到指定data-id的元素
+     * @param {string} dataId 元素的data-id属性值
+     * @param {number} offset 偏移量
+     */
+    scrollToDataId(dataId, offset) {
+      let _offset = offset;
+      if (!_offset) _offset = 0;
+      const element = document.querySelector(`div[data-id="${dataId}"]`);
+      if (element) {
+        const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
+        const offsetPosition = elementPosition - _offset;
+
+        element.scrollIntoView({
+          behavior: 'smooth', // 滚动行为:'auto' | 'smooth'
+          block: 'center', // 垂直对齐:'start' | 'center' | 'end' | 'nearest'
+          inline: 'nearest', // 水平对齐:'start' | 'center' | 'end' | 'nearest'
+        });
+      }
+    },
   },
 };
 </script>
@@ -483,5 +784,29 @@ export default {
     background: url('../../../../assets/icon-publish.png') left center no-repeat;
     background-size: 24px;
   }
+
+  .contentmenu {
+    position: absolute;
+    z-index: 999;
+    display: flex;
+    column-gap: 4px;
+    align-items: center;
+    padding: 8px;
+    font-size: 14px;
+    color: #000;
+    background-color: #e7e7e7;
+    border-radius: 4px;
+    box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 30%);
+
+    .svg-icon,
+    .button {
+      cursor: pointer;
+    }
+
+    .line {
+      min-height: 16px;
+      margin: 0 4px;
+    }
+  }
 }
 </style>

+ 25 - 5
src/web_preview/index.vue

@@ -227,6 +227,17 @@
       />
     </el-dialog>
 
+    <el-dialog
+      title=""
+      :visible="visibleVisNetwork"
+      width="1100px"
+      class="audit-dialog"
+      :close-on-click-modal="false"
+      @close="dialogClose('VisNetwork')"
+    >
+      <VisNetwork ref="visNetworkRef" :book-id="projectId" @child-click="handleNodeClick" />
+    </el-dialog>
+
     <ExplanatoryNoteDialog
       ref="explanatoryNote"
       :open.sync="editDialogOpen"
@@ -245,6 +256,7 @@ import MindMap from '@/components/MindMap.vue';
 import VideoPlay from '@/views/book/courseware/preview/components/common/VideoPlay.vue';
 import AudioPlay from '@/views/book/courseware/preview/components/common/AudioPlay.vue';
 import ExplanatoryNoteDialog from '@/components/ExplanatoryNoteDialog.vue';
+import VisNetwork from '@/components/VisNetwork.vue';
 import * as OpenCC from 'opencc-js';
 
 import { GetBookCoursewareInfo, GetCoursewareAuditRemarkList, GetProjectInfo } from '@/api/project';
@@ -273,6 +285,7 @@ export default {
     VideoPlay,
     AudioPlay,
     ExplanatoryNoteDialog,
+    VisNetwork,
   },
   provide() {
     return {
@@ -288,7 +301,7 @@ export default {
     const sidebarIconList = [
       // { icon: 'search', title: '搜索', handle: '', param: {} },
       { icon: 'mindmap', title: '思维导图', handle: 'openMindMap', param: {} },
-      // { icon: 'knowledge', title: '知识图谱', handle: '', param: {} },
+      { icon: 'knowledge', title: '知识图谱', handle: 'openVisNetwork', param: {} },
       // { icon: 'totalResources', title: '总资源', handle: '', param: {} },
       { icon: 'collect', title: '收藏', handle: 'getCollect', param: { type: '3' } },
       { icon: 'audio', title: '音频', handle: 'openDrawer', param: { type: '1' } },
@@ -340,6 +353,7 @@ export default {
       curToolbarIcon: this.isShowAudit ? 'audit' : '',
       sidebarIconList,
       visibleMindMap: false,
+      visibleVisNetwork: false,
       isChildDataLoad: false,
       mindMapJsonData: {}, // 思维导图json数据
       drawerType: '', // 抽屉类型
@@ -377,6 +391,7 @@ export default {
       oldRichData: {},
       newSelectedInfo: null,
       allCottectList: [],
+      book_id: '',
     };
   },
   computed: {
@@ -594,6 +609,11 @@ export default {
         }
       }
       this.visibleMindMap = false;
+      this.visibleVisNetwork = false;
+    },
+
+    async openVisNetwork() {
+      this.visibleVisNetwork = true;
     },
 
     // 计算抽屉滑出位置
@@ -768,8 +788,8 @@ export default {
         this.$refs.courserware.handLocation(item);
       }
     },
-    async getNote() {
-      this.drawerType = 4;
+    async getNote(params) {
+      if (params && params.type) this.drawerType = Number(params.type);
       this.allNoteList = [];
       await GetMyNoteList({ courseware_id: this.curSelectId }).then((res) => {
         if (res.status === 1) {
@@ -863,8 +883,8 @@ export default {
       });
     },
 
-    async getCollect() {
-      this.drawerType = 3;
+    async getCollect(params) {
+      if (params && params.type) this.drawerType = Number(params.type);
       this.allCottectList = [];
       await GetMyCollectList({ courseware_id: this.curSelectId }).then((res) => {
         if (res.status === 1) {

部分文件因为文件数量过多而无法显示