需求原本是根据neo4j实现知识图谱,但是数据来源不需要通过neo4j.driver去调用,而是通过接口获取数据,实现效果如下:

1. 实现对节点的增删改查和对关系的增删改查。

2.实现画布的缩放和对图谱的下载。

主要代码如下:

index.vue(知识图谱数据获取和逻辑处理)

<script setup lang="ts">
import { onMounted, ref } from "vue";
import NeoGraph from "@/views/data-set/neoGraph/index.vue";
import RelationForm from "@/views/data-set/neoGraph/relationForm.vue";
import { message } from "@/utils/message";
import { ElMessageBox } from "element-plus";
import NodeForm from "@/views/data-set/neoGraph/nodeForm.vue";

const clickAction = ref("create");
const showForm = ref(false);
const nodeFormShow = ref(false);
const nodeId = ref("");
const nodeInfo = ref(null);
const relationInfo = ref(null);
const records = ref({
  nodes: [
    {
      id: "1",
      name: "John Doe",
      email: "john.doe@example.com"
    },
    {
      id: "2",
      name: "Jane Doe",
      email: "jane.doe@example.com"
    },
    {
      id: "3",
      name: "Jim Doe",
      email: "jim.doe@example.com"
    }
  ],
  relations: [
    {
      host: "1",
      target: "2",
      type: "friend",
      years: "3"
    },
    {
      host: "1",
      target: "3",
      type: "friend",
      years: "5"
    },
    {
      host: "2",
      target: "3",
      type: "friend",
      years: "2"
    }
  ]
});
// 存储一份原始数据,用于刷新(暂时不要)
// const originalRecords = ref(records.value);
const echartsNode = ref([]);
const category = ref([]);
const nodesRelation = ref([]);
const fullscreenLoading = ref(false);

const executeCypher = async () => {
  // fullscreenLoading.value = true;
  // fullscreenLoading.value = false;
  nodesRelation.value = records.value.relations.map(item => {
    return {
      ...item,
      source: item.host,
      name: item.type
    };
  });
  echartsNode.value = records.value.nodes;
  category.value = records.value.relations.map(item => {
    return {
      name: item.type
    };
  });
};
// 新增节点
const handleAddNode = () => {
  nodeInfo.value = null;
  clickAction.value = "create";
  nodeFormShow.value = true;
};
// 新增节点保存
const handleAddSave = node => {
  records.value.nodes.push(node);
  nodeFormShow.value = false;
  executeCypher();
};
// 修改节点
const handelUpdateNode = node => {
  nodeInfo.value = node;
  clickAction.value = "update";
  nodeFormShow.value = true;
};
// 修改节点保存
const handleEditSave = node => {
  const i = records.value.nodes.findIndex(item => item.id === node.id);
  if (i === -1) return;
  records.value.nodes[i] = node;
  nodeFormShow.value = false;
  executeCypher();
};
// 新增关系
const handelAddRelation = node => {
  clickAction.value = "create";
  nodeId.value = node.id;
  relationInfo.value = null;
  showForm.value = true;
};
// 保存关系
const handleSaveRelation = info => {
  if (info.type === "create") {
    records.value.relations.push(info.data);
  } else {
    const i = records.value.relations.findIndex(
      item => item.host === info.data.host && item.target === info.data.target
    );
    if (i === -1) return;
    records.value.relations[i] = info.data;
  }
  showForm.value = false;
  executeCypher();
};
// 删除节点
const handleDeleteNode = node => {
  // 查看是否有与其他节点的关系
  const relationIndex = records.value.relations.findIndex(
    item => item.host === node.id || item.target === node.id
  );
  if (relationIndex !== -1) {
    return message("该节点存在关系,请先删除关系后再删除节点", {
      type: "error"
    });
  }
  ElMessageBox.confirm("确认删除节点?", "警告", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning"
  })
    .then(() => {
      const i = records.value.nodes.findIndex(item => item.id === node.id);
      if (i === -1) return;
      records.value.nodes.splice(i, 1);
      executeCypher();
    })
    .catch(() => {});
};
// 修改关系
const handleUpdateRelation = relation => {
  clickAction.value = "update";
  relationInfo.value = relation;
  showForm.value = true;
};
// 删除关系
const handleDeleteRelation = relation => {
  ElMessageBox.confirm("确认删除关系?", "警告", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning"
  })
    .then(() => {
      const i = records.value.relations.findIndex(
        item => item.host === relation.host && item.target === relation.target
      );
      if (i === -1) return;
      records.value.relations.splice(i, 1);
      executeCypher();
    })
    .catch(() => {});
};
// 右键菜单的功能
const handleSelectMenu = args => {
  switch (args.type) {
    // 新增关系
    case "addRelation":
      handelAddRelation(args.node);
      break;
    // 修改节点
    case "updateNode":
      handelUpdateNode(args.node);
      break;
    //   删除节点
    case "deleteNode":
      handleDeleteNode(args.node);
      break;
    //   修改关系
    case "updateRelation":
      handleUpdateRelation(args.node);
      break;
    //   删除关系
    case "deleteRelation":
      handleDeleteRelation(args.node);
      break;
    default:
      break;
  }
};

onMounted(() => {
  executeCypher();
});
</script>

<template>
  <div v-loading="fullscreenLoading" class="network" style="height: 100%">
    <neo-graph
      :data="echartsNode"
      :links="nodesRelation"
      :category="category"
      @add-node="handleAddNode"
      @select-menu="handleSelectMenu"
    />
    <relation-form
      v-if="showForm"
      :id="nodeId"
      :relation="relationInfo"
      :click-action="clickAction"
      :options="records.nodes.filter(item => item.id !== nodeId)"
      @cancel="showForm = false"
      @save-relation="handleSaveRelation"
    />
    <node-form
      v-if="nodeFormShow"
      :node="nodeInfo"
      :click-action="clickAction"
      @cancel="nodeFormShow = false"
      @add-save="handleAddSave"
      @edit-save="handleEditSave"
    />
  </div>
</template>

<style lang="scss" scoped></style>

neo-graph.vue(知识图谱展示)

<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from "vue";
import * as echarts from "echarts";
import ContentMenu from "@/views/data-set/neoGraph/contentMenu.vue";
import NodeDetails from "@/views/data-set/neoGraph/nodeDetails.vue";
import TopTools from "@/views/data-set/neoGraph/topTools.vue";

const props = defineProps({
  id: {
    type: String,
    default: "chart"
  },
  data: {
    type: Array
  },
  links: {
    type: Array
  },
  category: {
    type: Array
  },
  chartsHeight: String
});
const emit = defineEmits(["add-node", "select-menu"]);
const graphRef = ref();
const options = ref({});
const mapcharts = ref<any>(null);
const contextMenuVisible = ref(false);
const contextMenuType = ref("");
const contextMenuLeft = ref(0);
const contextMenuTop = ref(0);
const currentNode = ref(null);
const selectedNode = ref(null);
const highlightedLink = ref(null);

const redrawGraph = () => {
  // 销毁实例
  if (mapcharts.value) {
    mapcharts.value.clear();
  }
  mapcharts.value = echarts.init(graphRef.value);

  options.value = {
    tooltip: {
      show: false
    },
    series: [
      {
        categories: props.category,
        type: "graph",
        layout: "force",
        zoom: 0.6,
        symbolSize: 60,
        draggable: true,
        roam: true,
        legendHoverLink: false,
        nodeScaleRatio: 0.6,

        // 替换弃用属性
        emphasis: {
          focus: "none", // 可选 'none' | 'self' | 'adjacency'
          scale: true // 替代 hoverAnimation
        },

        itemStyle: {
          color: "#67A3FF"
        },
        edgeSymbol: ["", "arrow"],

        // 扁平化 edgeLabel 配置
        edgeLabel: {
          show: true,
          fontSize: 12, // 原 textStyle.fontSize
          formatter(x) {
            return x.data.name;
          }
        },

        // 扁平化 label 配置
        label: {
          show: true,
          fontSize: 12, // 原 textStyle.fontSize
          color: "#f6f6f6", // 原 textStyle.color
          formatter: function (params) {
            // 文本格式化函数保持不变
            var newParamsName = "";
            var paramsNameNumber = params.name.length;
            var provideNumber = 7;
            var rowNumber = Math.ceil(paramsNameNumber / provideNumber);
            if (paramsNameNumber > provideNumber) {
              for (var p = 0; p < rowNumber; p++) {
                var tempStr = "";
                var start = p * provideNumber;
                var end = start + provideNumber;
                if (p == rowNumber - 1) {
                  tempStr = params.name.substring(start, paramsNameNumber);
                } else {
                  tempStr = params.name.substring(start, end) + "\n\n";
                }
                newParamsName += tempStr;
              }
            } else {
              newParamsName = params.name;
            }
            return newParamsName;
          }
        },

        force: {
          repulsion: 200,
          gravity: 0.01,
          edgeLength: 400,
          layoutAnimation: true
        },
        data: props.data,
        links: props.links
      }
    ]
  };
  mapcharts.value.setOption(options.value);
};

const init = () => {
  redrawGraph();
};

const chartEvents = () => {
  // 窗口大小变化时调整图表
  window.addEventListener("resize", () => mapcharts.value.resize());

  // 左键点击事件
  mapcharts.value.on("click", params => {
    // 点击节点
    if (params.componentType === "series") {
      selectedNode.value = { ...params.data, dataType: params.dataType };
      if (params.dataType === "node") {
        resetHighlight();
        highlightNode(params.data.id);
      }
      if (params.dataType === "edge") {
        resetHighlight();
        highlightLink(params.dataIndex);
      }
    }
  });

  // 节点右键点击事件
  mapcharts.value.on("contextmenu", params => {
    if (params.componentType === "series") {
      // 阻止默认右键菜单
      const mouseEvent = event as MouseEvent;
      mouseEvent.preventDefault();

      // 使用指定类型后的 event
      currentNode.value = params.data;
      contextMenuType.value = params.dataType;
      contextMenuLeft.value = mouseEvent.pageX;
      contextMenuTop.value = mouseEvent.pageY;
      contextMenuVisible.value = true;
    }
  });

  // 点击页面其他区域关闭右键菜单
  document.addEventListener("click", handleDocumentClick);
};

// 高亮指定节点
const highlightNode = nodeId => {
  const option = mapcharts.value.getOption();
  const nodes = option.series[0].data;

  nodes.forEach(node => {
    node.itemStyle = node.itemStyle || {};
    node.itemStyle.color = node.id === nodeId ? "#FF7E7E" : "#67A3FF";
  });

  mapcharts.value.setOption(option);
};

// 高亮指定连接线
const highlightLink = linkIndex => {
  const option = mapcharts.value.getOption();
  const links = option.series[0].links;

  links.forEach((link, index) => {
    link.lineStyle = link.lineStyle || {};
    link.lineStyle.width = index === linkIndex ? 3 : 1;
    link.lineStyle.color = index === linkIndex ? "#F5A623" : "#999";
  });

  highlightedLink.value = linkIndex;
  mapcharts.value.setOption(option);
};

// 重置所有高亮状态
const resetHighlight = () => {
  const option = mapcharts.value.getOption();
  const nodes = option.series[0].data;
  const links = option.series[0].links;

  // 重置节点颜色
  nodes.forEach(node => {
    node.itemStyle = node.itemStyle || {};
    node.itemStyle.color = "#67A3FF";
  });

  // 重置连接线样式
  links.forEach(link => {
    link.lineStyle = link.lineStyle || {};
    link.lineStyle.width = 1;
    link.lineStyle.color = "#999";
  });

  highlightedLink.value = null;
  mapcharts.value.setOption(option);
};

// 关闭右键菜单
const handleDocumentClick = () => {
  contextMenuVisible.value = false;
};
// 右键菜单选择事件
const handleSelectMenu = args => {
  emit("select-menu", {
    node: currentNode.value,
    type: args
  });
};
// 新增节点
const handleAddNode = () => {
  emit("add-node");
};
// 缩小
const handleZoomOut = () => {
  if (mapcharts.value) {
    const option = mapcharts.value.getOption();
    const currentZoom = option.series[0].zoom || 1;
    mapcharts.value.setOption({
      series: [
        {
          zoom: Math.max(currentZoom - 0.1, 0.5) // 最小缩小到0.5倍
        }
      ]
    });
  }
};
// 放大
const handleZoomIn = () => {
  if (mapcharts.value) {
    const option = mapcharts.value.getOption();
    const currentZoom = option.series[0].zoom || 1;
    mapcharts.value.setOption({
      series: [
        {
          zoom: Math.min(currentZoom + 0.1, 2) // 最大放大到2倍
        }
      ]
    });
  }
};
// 下载
const handleDownload = () => {
  if (mapcharts.value) {
    const dataURL = mapcharts.value.getDataURL({
      type: "png",
      pixelRatio: 2,
      backgroundColor: "#fff"
    });
    const link = document.createElement("a");
    link.href = dataURL;
    link.download = "知识图谱.png";
    link.click();
    link.remove();
  }
};

watch(
  () => [props.data, props.links, props.category],
  val => {
    console.log("watch", val);
    redrawGraph();
  },
  { deep: true }
);

onMounted(() => {
  init();
  chartEvents();
});
onUnmounted(() => {
  // 清理事件监听和图表实例
  if (mapcharts.value) {
    mapcharts.value.dispose();
    mapcharts.value = null;
  }
  document.removeEventListener("click", handleDocumentClick);
});

defineOptions({
  name: "NeoGraph"
});
</script>

<template>
  <div class="graph-container">
    <div ref="graphRef" style="height: calc(100vh - 140px); width: 100%" />
    <top-tools
      @zoom-out="handleZoomOut"
      @zoom-in="handleZoomIn"
      @download="handleDownload"
    />
    <node-details
      class="graph-details"
      :selectedNode="selectedNode"
      @add-node="handleAddNode"
    />
    <content-menu
      v-show="contextMenuVisible"
      :context-menu-type="contextMenuType"
      :context-menu-left="contextMenuLeft"
      :context-menu-top="contextMenuTop"
      @selectMenu="handleSelectMenu"
    />
  </div>
</template>

<style scoped lang="scss"></style>

toolBox.vue(上方的放大缩小下载工具栏)

<script setup lang="ts">
import { ref } from "vue";
import Add from "@iconify-icons/ri/add-fill";
import Subtract from "@iconify-icons/ri/subtract-line";
import Down from "@iconify-icons/ri/arrow-down-line";
import Fresh from "@iconify-icons/ri/refresh-line";
// import Save from "@iconify-icons/ri/save-line";

const emit = defineEmits(["zoomOut", "zoomIn", "download"]);
defineOptions({
  name: "TopTools"
});
</script>

<template>
  <div class="top-tools">
    <IconifyIconOffline
      v-tippy="{
        content: '缩小'
      }"
      class="tool-icon"
      :icon="Subtract"
      @click="emit('zoomOut')"
    />
    <IconifyIconOffline
      v-tippy="{
        content: '放大'
      }"
      class="tool-icon"
      :icon="Add"
      @click="emit('zoomIn')"
    />
    <IconifyIconOffline
      v-tippy="{
        content: '下载'
      }"
      class="tool-icon"
      :icon="Down"
      @click="emit('download')"
    />
    <!--    <IconifyIconOffline-->
    <!--      v-tippy="{-->
    <!--        content: '刷新'-->
    <!--      }"-->
    <!--      class="tool-icon"-->
    <!--      :icon="Fresh"-->
    <!--      @click="emit('refresh')"-->
    <!--    />-->
    <!--    <IconifyIconOffline-->
    <!--      v-tippy="{-->
    <!--        content: '保存'-->
    <!--      }"-->
    <!--      class="tool-icon"-->
    <!--      :icon="Save"-->
    <!--    />-->
  </div>
</template>

<style scoped lang="scss">
.top-tools {
  position: absolute;
  top: 10px;
  left: 50%;
  transform: translateX(-50%);
  height: 36px;
  width: 150px;
  padding: 0 16px;
  background: rgba(255, 255, 255, 0.6);
  border-radius: 20px;
  border: 2px solid var(--el-bg-color);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
  display: flex;
  justify-content: space-between;
  align-items: center;
  .tool-icon {
    color: var(--el-text-color-secondary);
    cursor: pointer;
    &:hover {
      color: var(--el-primary-color);
    }
  }
}
</style>

nodeDetails.vue(详情展示面板)

<script setup lang="ts">
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import Add from "@iconify-icons/ri/map-pin-add-line";

const props = defineProps({
  selectedNode: {
    type: Object,
    required: true
  }
});
const emit = defineEmits(["add-node"]);
// 新增节点
const handleAddNode = () => {
  emit("add-node");
};
defineOptions({
  name: "NodeDetails"
});
</script>

<template>
  <div class="graph-details">
    <el-scrollbar class="graph-info">
      <el-descriptions
        v-if="selectedNode !== null && selectedNode.dataType === 'node'"
        title="节点详情"
        border
        :column="1"
      >
        <el-descriptions-item label="id">{{
          selectedNode.id
        }}</el-descriptions-item>
        <el-descriptions-item label="name">{{
          selectedNode.name
        }}</el-descriptions-item>
        <el-descriptions-item label="email">{{
          selectedNode.email
        }}</el-descriptions-item>
      </el-descriptions>
      <el-descriptions
        v-if="selectedNode !== null && selectedNode.dataType === 'edge'"
        title="关系详情"
        border
        :column="2"
      >
        <el-descriptions-item label="host">{{
          selectedNode.host
        }}</el-descriptions-item>
        <el-descriptions-item label="name">{{
          selectedNode.name
        }}</el-descriptions-item>
        <el-descriptions-item label="type">{{
          selectedNode.name
        }}</el-descriptions-item>
      </el-descriptions>
    </el-scrollbar>
    <el-button
      color="#626aef"
      plain
      class="w-full"
      :icon="useRenderIcon(Add)"
      @click="handleAddNode"
      >新增节点</el-button
    >
  </div>
</template>

<style scoped lang="scss">
.graph-details {
  position: absolute;
  right: 1%;
  top: 5%;
  width: 24rem;
  height: calc(100vh - 200px);
  border-radius: 8px;
  padding: 6px;
  background: var(--el-bg-color);
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  .graph-info {
    height: calc(100vh - 250px);
    overflow-y: auto;
  }
}
</style>

contentMenu.vue(右键菜单)

<script setup lang="ts">
import { computed } from "vue";
import Add from "@iconify-icons/ri/add-fill";
import EditPen from "@iconify-icons/ep/edit-pen";
import EditLine from "@iconify-icons/ri/edit-2-line";
import Del from "@iconify-icons/ri/close-line";
import DelLine from "@iconify-icons/ri/delete-bin-4-line";

const props = defineProps({
  contextMenuType: {
    type: String,
    required: true
  },
  contextMenuLeft: {
    type: Number,
    required: true
  },
  contextMenuTop: {
    type: Number,
    required: true
  }
});
const emit = defineEmits(["selectMenu"]);
// 是否节点
const isNode = computed(() => {
  return props.contextMenuType === "node";
});
// 菜单点击事件
const handleClick = val => {
  emit("selectMenu", val);
};

defineOptions({
  name: "ContentMenu"
});
</script>

<template>
  <div>
    <ul
      class="graph-menu"
      :style="{ left: contextMenuLeft + 'px', top: contextMenuTop + 'px' }"
    >
      <li v-if="isNode" class="menu-item" @click="handleClick('addRelation')">
        <el-icon>
          <IconifyIconOffline :icon="Add" />
        </el-icon>
        <div class="ml-1">新增关系</div>
      </li>
      <li v-if="isNode" class="menu-item" @click="handleClick('updateNode')">
        <el-icon>
          <IconifyIconOffline :icon="EditLine" />
        </el-icon>
        <div class="ml-1">修改节点</div>
      </li>
      <li v-if="isNode" class="menu-item" @click="handleClick('deleteNode')">
        <el-icon>
          <IconifyIconOffline :icon="Del" />
        </el-icon>
        <div class="ml-1">删除节点</div>
      </li>
      <li
        v-if="!isNode"
        class="menu-item"
        @click="handleClick('updateRelation')"
      >
        <el-icon>
          <IconifyIconOffline :icon="EditPen" />
        </el-icon>
        <div class="ml-1">修改关系</div>
      </li>
      <li
        v-if="!isNode"
        class="menu-item"
        @click="handleClick('deleteRelation')"
      >
        <el-icon>
          <IconifyIconOffline :icon="DelLine" />
        </el-icon>
        <div class="ml-1">删除关系</div>
      </li>
    </ul>
  </div>
</template>

<style scoped lang="scss">
.graph-menu {
  color: var(--el-text-color-regular);
  position: fixed;
  background-color: white;
  border: 1px solid var(--el-fill-color-light);
  border-radius: 4px;
  box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
  z-index: 1000;
  .menu-item {
    padding: 6px 16px;
    cursor: pointer;
    font-size: 14px;
    display: flex;
    align-items: center;
    &:hover {
      color: var(--el-color-primary);
      background-color: var(--el-color-primary-light-9);
    }
  }
}
</style>

form表单的代码可根据需求自行实现。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐