|
|
@@ -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>
|