前言

做非遗文化传承项目时,我踩过一个典型坑:在 Neo4j 里建好图谱后,不知道怎么快速做成可交互的 Web 可视化页面。后来用Neo4j Aura(在线图数据库)+ Vue + Tailwind CSS + Vis-Network 搭了一套流程,从图谱构建到导出数据,再到本地可视化,全程零复杂配置,新手也能 10 分钟跑通。

现在把完整流程整理成教程,附免费的可直接下载的项目包 —— 你不仅能复现非遗知识图谱,还能替换成自己的数据集(比如中医药、历史人物图谱),轻松实现 “Neo4j 数据→Web 可视化” 的闭环。

(教程全程从 “用户视角” 出发,跟着步骤点鼠标就能成功运行。不堆砌技术术语,重点说清 “你需要做什么”“怎么做”“遇到问题怎么解决”,哪怕是刚接触 Vue 的新手也能轻松搞定。)

完整项目包下载链接:

https://download.csdn.net/download/2301_79876346/92064569

目录

前言

一、先搞懂:你拿到的项目包是什么?

二、前置准备:3 个必装工具(5 分钟搞定)

1. 工具 1:Node.js(Vue 项目运行环境)

2. 工具 2:VS Code(查看项目 + 执行命令)

3. 工具 3:Neo4j Aura 账号(可选,仅换数据时用)

三、Vue 项目结构与核心代码

3.1 项目目录(精简版)

3.2 核心文件代码解析(关键部分标注释)

1. src/main.js(项目入口,1 行关键代码)

2. src/App.vue(根组件,只做一件事:渲染图谱)

3. src/assets/main.css(Tailwind 入口,3 行核心代码)

4. src/views/KnowledgeGraph.vue(核心中的核心)

四、核心步骤:拿到项目包后,4 步跑通(零代码)

步骤 1:解压项目包,用 VS Code 打开

步骤 2:安装项目依赖(仅需执行 1 条命令)

步骤 3:启动项目(仅需执行 1 条命令)

步骤 4:验证是否成功(看 3 个标志)

项目实际运行效果:

五、关键说明:项目里的 CSV 数据怎么来的?(换数据必看)

现有 CSV 数据的来源(不用你操作,仅了解)

替换成自己的数据(3 步操作,不用改 Vue 代码)

第一步:用 Neo4j Aura 建你的图谱

第二步:导出你的 CSV 文件

第三步:替换项目里的 CSV 并重启

六、常见问题排查(新手必看,少踩坑)

问题 1:页面样式错乱(比如文字挤在一起、没有布局)

问题 2:图谱空白,控制台报 “404 Not Found”(CSV 加载失败)

问题 3:图谱节点重叠在一起,看不清

七、总结


一、先搞懂:你拿到的项目包是什么?

在开始操作前,先简单了解项目包的核心作用 —— 这是一个 “开箱即用” 的知识图谱可视化项目,核心功能是:读取public文件夹里的 CSV 数据(节点 + 关系),用 Vue+Tailwind+Vis-Network 渲染成可交互的图谱(支持拖拽、缩放、节点详情查看)。

项目包里已经包含了:

  • 现成的非遗数据(node-export1.csv节点文件、relationship-export1.csv关系文件);
  • 所有配置好的代码(布局、样式、交互逻辑全写好);
  • 无需额外安装的依赖清单(package.json已配置)。

二、前置准备:3 个必装工具(5 分钟搞定)

在运行项目前,必须先装 3 个基础工具,少一个都跑不起来,按顺序装就行:

1. 工具 1:Node.js(Vue 项目运行环境)

  • 作用:相当于 “Vue 项目的发动机”,没有它无法启动项目;
  • 怎么装
    1. 打开Node.js 官网,下载 “LTS 长期支持版”(推荐 16.x 或 18.x,别下最新的 “Current 版”,可能有兼容性问题);
    2. 安装时 务必勾选 “Add to PATH”(自动配置环境变量,否则终端找不到 Node);
    3. 验证是否装好:打开电脑 “命令提示符”(Win+R 输入cmd),输入node -v,能显示版本号(如 v18.17.0)就是成功。

2. 工具 2:VS Code(查看项目 + 执行命令)

  • 作用:轻量的代码编辑器,用来打开项目文件夹、执行安装 / 启动命令;
  • 怎么装
    1. 打开VS Code 官网,下载对应系统的版本(Win/Mac 都有);
    2. 安装时可勾选 “添加到右键菜单”(后续右键文件夹就能用 VS Code 打开,方便)。

3. 工具 3:Neo4j Aura 账号(可选,仅换数据时用)

  • 作用:如果你想替换成自己的图谱数据(比如中医药、历史人物),需要用它在线建图谱、导出 CSV;
  • 怎么弄
    1. 打开Neo4j Aura 官网,用邮箱注册账号;
    2. 创建 “Free 免费版” 数据库,保存好自动生成的 “用户名(默认 neo4j)” 和 “密码”(丢了会很麻烦)。

三、Vue 项目结构与核心代码

我已经把项目精简到极致,只保留知识图谱相关文件,没有任何冗余代码。先看项目结构,再逐个解析核心文件。

3.1 项目目录(精简版)

下载项目包后,目录长这样(标★的是核心文件,不能删):

非遗知识图谱项目/
├─ public/                  # 静态资源文件夹
│  ├─ node-export1.csv      # ★ Neo4j导出的节点数据
│  ├─ relationship-export1.csv # ★ Neo4j导出的关系数据
│  └─ index.html            # 项目入口HTML(无需修改)
├─ src/
│  ├─ assets/
│  │  └─ main.css           # ★ Tailwind CSS入口(已配置好)
│  ├─ views/
│  │  └─ KnowledgeGraph.vue # ★ 知识图谱核心页面(含布局+交互)
│  ├─ App.vue               # ★ 根组件(直接渲染图谱)
│  └─ main.js               # ★ 项目入口(挂载App+引入样式)
├─ tailwind.config.js       # Tailwind配置(已适配项目)
├─ postcss.config.js        # Tailwind编译依赖(无需修改)
├─ package.json             # 项目依赖清单(已配置好)
└─ README.md                # 简单说明(可忽略)

3.2 核心文件代码解析(关键部分标注释)

1. src/main.js(项目入口,1 行关键代码)

作用:创建 Vue 实例,引入 Tailwind CSS(确保样式生效)

import { createApp } from 'vue';
import App from './App.vue';
import './assets/main.css'; // 关键:引入Tailwind样式,没这行布局会乱

// 直接挂载App,无需路由(单页面更简洁)
createApp(App).mount('#app');
2. src/App.vue(根组件,只做一件事:渲染图谱)

作用:不写多余代码,直接引入KnowledgeGraph组件,避免导航栏等冗余元素

<template>
  <div id="app">
    <!-- 直接渲染知识图谱,无任何多余内容 -->
    <KnowledgeGraph />
  </div>
</template>

<script>
// 仅引入图谱组件,其他组件全删
import KnowledgeGraph from './views/KnowledgeGraph.vue';

export default {
  name: 'App',
  components: {
    KnowledgeGraph // 注册图谱组件
  }
};
</script>

<style>
/* 重置页面边距,避免顶部空白 */
body {
  margin: 0;
  padding: 0;
}
</style>
3. src/assets/main.css(Tailwind 入口,3 行核心代码)

作用:导入 Tailwind 的基础样式、组件和工具类(页面布局全靠它)

/* Tailwind三要素:基础样式+组件+工具类,缺一不可 */
@tailwind base;
@tailwind components;
@tailwind utilities;
4. src/views/KnowledgeGraph.vue(核心中的核心)

这个文件包含 3 部分:页面布局(Tailwind 实现)、CSV 数据加载、Vis-Network 图谱初始化。关键代码已标注释,重点看这 3 处:

  • 布局:用flex/container/lg:flex-row实现 “图谱 + 侧边栏” 的响应式布局;
  • 数据加载:loadCSV函数读取public下的 CSV 文件,路径不用改;
  • 图谱初始化:initNetwork配置 Vis-Network,支持拖拽、缩放、节点高亮。
<template>
  <!-- 最外层容器:新增专属类heritage-graph-app -->
  <div class="heritage-graph-app">
    
    <!-- 顶部装饰纹样 -->
    <div class="decorative-pattern top-pattern"></div>
    
    <!-- 主内容区 -->
    <main class="container mx-auto p-6">
      <!-- 标题区域 -->
      <div class="text-center mb-8">
        <h1 class="text-3xl md:text-4xl font-serif font-bold text-[#8B5A2B] relative inline-block">
          非物质文化遗产知识图谱
          <div class="absolute -bottom-2 left-0 right-0 h-px bg-[#D2B48C]"></div>
        </h1>
        <p class="mt-3 text-[#8B5A2B] font-serif">探索非遗项目的传承与关联</p>
      </div>
      
      <!-- 介绍卡片 -->
      <div class="bg-[#FFFCF7] rounded-2xl shadow-md p-8 mb-6 border border-[#F5F5DC] mx-auto max-w-4xl px-6 intro-card">
        <p class="text-lg font-serif leading-relaxed">
          此可视化展示了不同非物质文化遗产遗产项目之间的关系,包括世界遗产、国家级级非遗等类别,
          帮助您探索非遗项目之间的关联与传承脉络。
        </p>
      </div>
      
      <!-- 图谱和侧边栏水平排列 -->
      <div class="flex flex-col lg:flex-row px-8 gap-6">
        <!-- 知识图谱区域 -->
        <div class="lg:w-[1400px] graph-visualization-container" style="margin-bottom: 2rem;">
          <div style="padding-bottom: 16px;" class="p-4 border-b border-[#F5F5DC] flex justify-between items-center">
            <h2 class="font-serif font-semibold text-lg text-[#8B5A2B]">知识图谱可视化</h2>
            <div class="flex space-x-2">
              <button @click="resetGraph" style="margin-right: 16px;" class="text-sm px-3 py-1 bg-[#FFF8E1] text-[#8B5A2B] rounded border border-[#F5F5DC] hover:bg-[#F5F5DC] transition-colors">
                <i class="fa fa-refresh mr-1"></i>重置视图
              </button>
              <button @click="togglePhysics" class="text-sm px-3 py-1 bg-[#CD853F] text-white rounded border border-[#8B5A2B] hover:bg-[#8B5A2B] transition-colors">
                <i class="fa fa-magic mr-1"></i>{{ physicsEnabled ? '固定布局' : '启用布局' }}
              </button>
            </div>
          </div>
          
          <!-- 图谱容器 -->
          <div class="network-wrapper">
            <div ref="networkContainer" class="network-container"></div>
          </div>
          
          <!-- 加载状态 -->
          <div v-if="loading" class="loading-overlay">
            <div class="loading-spinner">
              <i class="fa fa-circle-o-notch fa-spin mr-2 text-[#CD853F]"></i>
              <span>正在生成知识图谱...</span>
            </div>
          </div>
        </div>
        
        <!-- 侧边栏:右侧布局 -->
        <div class="lg:w-96 flex flex-col gap-6 sidebar-right">
          <!-- 节点详情 -->
          <div class="bg-[#FFFCF7] rounded-xl shadow-md border border-[#F5F5DC] p-5">
            <h3 class="text-lg font-serif font-semibold mb-4 border-b border-[#F5F5DC]" style="padding-bottom: 12px;">
              <i class="fa fa-info-circle text-[#CD853F] mr-2"></i>节点详情
            </h3>
            <div v-if="selectedNode" class="space-y-3">
              <div>
                <h4 class="font-medium text-[#8B5A2B] text-lg font-serif">{{ selectedNode.name }}</h4>
                <p class="text-sm text-gray-600 mt-1">{{ selectedNode.description || '暂无详细描述' }}</p>
              </div>
              <div class="pt-2 border-t border-[#F5F5F5]">
                <span class="text-xs bg-[#FFF8E1] px-2 py-1 rounded border border-[#F5F5DC]">
                  类型: {{ formatNodeType(selectedNode.nodeLabel) }}
                </span>
              </div>
            </div>
            <div v-else class="text-center text-gray-500 py-6">
              <p>点击图谱中的节点查看详情</p>
            </div>
          </div>
          
          <!-- 图例说明 -->
          <div class="bg-[#FFFCF7] rounded-xl shadow-md border border-[#F5F5DC] p-5">
            <h3 class="text-lg font-serif font-semibold mb-4 border-b border-[#F5F5DC]" style="padding-bottom: 12px;">
              <i class="fa fa-map-signs text-[#CD853F] mr-2"></i>图例说明
            </h3>
            <div class="space-y-3">
              <div class="flex items-center">
                <div class="w-5 h-5 rounded-full bg-[#CD853F] border border-[#8B5A2B] mr-3"></div>
                <span>核心节点(Heritage)</span>
              </div>
              <div class="flex items-center">
                <div class="w-5 h-5 rounded-full bg-[#F5F5DC] border border-[#D2B48C] mr-3"></div>
                <span>其他节点(项目/人物/机构等)</span>
              </div>
              <div class="flex items-center">
                <div class="w-10 h-0.5 bg-[#CD853F] mr-3"></div>
                <span>关系连线</span>
              </div>
            </div>
          </div>
          
          <!-- 操作指南 -->
          <div class="bg-[#FFFCF7] rounded-xl shadow-md border border-[#F5F5DC] p-5">
            <h3 class="text-lg font-serif font-semibold mb-4 border-b border-[#F5F5DC]" style="padding-bottom: 12px;">
              <i class="fa fa-hand-pointer-o text-[#CD853F] mr-2"></i>操作指南
            </h3>
            <ul class="space-y-2 text-sm">
              <li class="flex items-start">
                <i class="fa fa-arrows text-[#CD853F] mt-1 mr-2"></i>
                <span>拖拽节点可重新布局</span>
              </li>
              <li class="flex items-start">
                <i class="fa fa-mouse-pointer text-[#CD853F] mt-1 mr-2"></i>
                <span>点击节点可查看详细信息</span>
              </li>
              <li class="flex items-start">
                <i class="fa fa-search-plus text-[#CD853F] mt-1 mr-2"></i>
                <span>鼠标滚轮可缩放视图</span>
              </li>
              <li class="flex items-start">
                <i class="fa fa-expand text-[#CD853F] mt-1 mr-2"></i>
                <span>双击节点可聚焦相关连接</span>
              </li>
              <li class="flex items-start">
                <i class="fa fa-lightbulb-o text-[#CD853F] mt-1 mr-2"></i>
                <span>悬停节点可查看发光效果</span>
              </li>
            </ul>
          </div>
          
          <!-- 统计信息 -->
          <div class="bg-[#FFFCF7] rounded-xl shadow-md border border-[#F5F5DC] p-5">
            <h3 class="text-lg font-serif font-semibold mb-4 border-b border-[#F5F5DC]" style="padding-bottom: 12px;">
              <i class="fa fa-bar-chart text-[#CD853F] mr-2"></i>统计信息
            </h3>
            <div class="space-y-3">
              <div class="flex justify-between">
                <span>核心节点数量</span>
                <span class="font-semibold">{{ heritageCount }}</span>
              </div>
              <div class="flex justify-between">
                <span>其他节点数量</span>
                <span class="font-semibold">{{ otherCount }}</span>
              </div>
              <div class="flex justify-between">
                <span>关系总数</span>
                <span class="font-semibold">{{ relCount }}</span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </main>
    
    <!-- 底部装饰纹样 -->
    <div class="decorative-pattern bottom-pattern"></div>
    
    <!-- 页脚 -->
    <footer class="mt-12 py-6 border-t border-[#F5F5DC] bg-[#FFFCF7]">
      <div class="container mx-auto px-6 text-center text-[#8B5A2B]">
        <p class="font-serif">非物质文化遗产知识图谱可视化工具 &copy; {{ new Date().getFullYear() }}</p>
      </div>
    </footer>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import { Network } from 'vis-network/standalone';
import Papa from 'papaparse';

// 状态管理
const loading = ref(true);
const network = ref(null);
const networkContainer = ref(null);
const physicsEnabled = ref(true);
const selectedNode = ref(null);
const heritageCount = ref(0);
const otherCount = ref(0);
const relCount = ref(0);

// 统一的明亮古风主题配色 - 使用更亮眼的暖棕色系
const THEME_COLORS = {
  primary: "#CD853F",     // 主色调:秘鲁棕色,温暖亮眼
  secondary: "#8B5A2B",   // 次要色: saddle棕色,稍深但不暗沉
  accent: "#D2B48C",      // 强调色:秘鲁棕色浅色调
  background: "#FFFCF7",  // 极浅米色背景
  paper: "#FFFFFF",       // 白色纸张效果
  border: "#F5F5DC",      // 边框色:米色,更柔和
  lightBeige: "#FFF8E1"   // 浅米色,用于高亮元素
};

// 节点配色:与整体色调统一且更亮眼
const NODE_COLORS = {
  "Heritage": {  // 核心节点 - 使用主色调,更亮眼
    background: THEME_COLORS.border,  // #F5F5DC
    border: THEME_COLORS.accent,      // #D2B48C
    font: THEME_COLORS.secondary,   
    highlight: {
      background: THEME_COLORS.secondary,
      border: "#6d4c41"
    },
    hover: {
       background: THEME_COLORS.border,  // #F5F5DC
    border: THEME_COLORS.accent
    }
  },
  "Item": {  // 项目节点 - 明亮的米色,与主色调协调
    background: THEME_COLORS.border,  // #F5F5DC
    border: THEME_COLORS.accent,      // #D2B48C
    font: THEME_COLORS.secondary,     // #8B5A2B
    highlight: {
      background: THEME_COLORS.accent,
      border: THEME_COLORS.secondary
    },
    hover: {
      background: THEME_COLORS.border,
      border: THEME_COLORS.accent
    }
  }
};

// 关系样式:与节点颜色统一
const RELATION_STYLE = {
  color: {
    color: THEME_COLORS.primary,  // 关系线条使用主色调,与节点统一
    highlight: THEME_COLORS.secondary,
    opacity: 0.9  // 稍提高不透明度,更清晰可见
  },
  width: 1,       // 线条稍粗,更明显
  font: {
    size: 10,
    face: "SimSun",
    color: THEME_COLORS.secondary,
    strokeWidth: 2,
    strokeColor: THEME_COLORS.background
  },
  smooth: { enabled: false },
  selectionWidth: 2,
  hoverWidth: 2,
  arrows: { to: { enabled: true, scaleFactor: 0.7 } }
};

// 修复ResizeObserver循环错误
const handleResizeObserverError = () => {
  const originalErrorHandler = window.onerror;
  window.onerror = function(message) {
    if (message.includes('ResizeObserver loop completed with undelivered notifications')) {
      return true; // 标记为已处理
    }
    return originalErrorHandler ? originalErrorHandler.apply(this, arguments) : false;
  };

  const originalRejectionHandler = window.onunhandledrejection;
  window.onunhandledrejection = function(event) {
    if (event.reason && event.reason.message && 
        event.reason.message.includes('ResizeObserver loop completed with undelivered notifications')) {
      event.preventDefault();
      return true;
    }
    return originalRejectionHandler ? originalRejectionHandler.apply(this, arguments) : false;
  };

  if (window.ResizeObserver) {
    const OriginalResizeObserver = window.ResizeObserver;
    window.ResizeObserver = class ResizeObserver extends OriginalResizeObserver {
      constructor(callback) {
        super((entries, observer) => {
          requestAnimationFrame(() => callback(entries, observer));
        });
      }
    };
  }
};

// 加载CSV文件
const loadCSV = async (filename) => {
  try {
    const response = await fetch(filename);
    if (!response.ok) throw new Error(`文件加载失败: ${response.statusText}`);
    
    const text = await response.text();
    return new Promise((resolve) => {
      Papa.parse(text, {
        header: true,
        skipEmptyLines: true,
        complete: (results) => resolve(results.data)
      });
    });
  } catch (error) {
    console.error(`加载${filename}错误:`, error);
    alert(`无法加载${filename},请确认文件在Vue文件同一层级`);
    return [];
  }
};

// 格式化节点类型显示
const formatNodeType = (label) => {
  const typeMap = {
    "Heritage": "核心节点(非遗分类)",
    "Item": "项目节点(戏曲/曲艺等)",
    "Person": "人物节点(传承人/艺术家)",
    "Event": "活动节点(艺术节/文化节)",
    "Institution": "机构节点(高校/院校)",
    "Organization": "组织节点(剧院/剧团)",
    "ArtForm": "艺术形式节点(绘画/工艺等)"
  };
  return typeMap[label] || `其他节点(${label})`;
};

// 初始化网络图谱
const initNetwork = async () => {
  loading.value = true;
  await nextTick();

  // 确保容器尺寸固定
  if (networkContainer.value) {
    networkContainer.value.style.width = '100%';
    networkContainer.value.style.height = '600px';
  }

  // 加载节点和关系数据
  const nodesData = await loadCSV('node-export1.csv');
  const relsData = await loadCSV('relationship-export1.csv');
  
  if (nodesData.length === 0 || relsData.length === 0) {
    loading.value = false;
    return;
  }

  // 处理节点数据
  const nodes = [];
  const nodesMap = {};

  nodesData.forEach(row => {
    const nodeId = row["~id"];
    const nodeName = row["name"] || "未命名节点";
    const nodeDescription = row["description"] || "";
    const nodeLabel = row["~labels"] || "Item";

    // 节点配置:区分核心/非核心
    const nodeConfig = NODE_COLORS[nodeLabel] || NODE_COLORS["Item"];

    nodes.push({
      id: nodeId,
      label: nodeName,
      name: nodeName,
      description: nodeDescription,
      nodeLabel: nodeLabel,
      color: {
        background: nodeConfig.background,
        border: nodeConfig.border,
        highlight: nodeConfig.highlight,
        hover: nodeConfig.hover
      },
      borderWidth: 2,
      font: {
        size: nodeLabel === "Heritage" ? 12 : 10,
        face: "SimSun",
        color: nodeConfig.font,
        strokeWidth: 1,
        strokeColor: "rgba(0,0,0,0.1)"
      },
      shape: "circle",
      size: nodeLabel === "Heritage" ? 40 : 35,
      title: `描述: ${nodeDescription || '暂无描述'}`
    });

    nodesMap[nodeId] = { label: nodeLabel };
  });

  // 处理关系数据
  const edges = [];
  relsData.forEach(row => {
    const sourceId = row["~start_node_id"];
    const targetId = row["~end_node_id"];
    const relType = row["~relationship_type"] || "关联";

    // 只添加存在的节点关系
    if (nodesMap[sourceId] && nodesMap[targetId]) {
      edges.push({
        from: sourceId,
        to: targetId,
        label: relType,
        color: RELATION_STYLE.color,
        width: RELATION_STYLE.width,
        font: RELATION_STYLE.font,
        smooth: RELATION_STYLE.smooth,
        selectionWidth: RELATION_STYLE.selectionWidth,
        hoverWidth: RELATION_STYLE.hoverWidth,
        arrows: RELATION_STYLE.arrows
      });
    }
  });

  // 更新统计信息
  heritageCount.value = nodes.filter(n => n.nodeLabel === "Heritage").length;
  otherCount.value = nodes.length - heritageCount.value;
  relCount.value = edges.length;

  // 网络配置 - 导航按钮颜色设置
  const container = networkContainer.value;
  const data = { nodes, edges };
  const options = {
    physics: {
      enabled: physicsEnabled.value,
      stabilization: { 
        iterations: 500,
        updateInterval: 100,
        onlyDynamicEdges: true,
        fit: true
      },
      barnesHut: {
        gravitationalConstant: -8000,
        centralGravity: 0.3,
        springLength: 150,
        springConstant: 0.1,
        damping: 0.9,
        avoidOverlap: 0.8
      },
      maxVelocity: 30,
      solver: "barnesHut",
      timestep: 0.5
    },
    interaction: {
      hover: true,
      hoverConnectedEdges: true,
      selectConnectedEdges: true,
      navigationButtons: true,
      keyboard: true,
      tooltipDelay: 200
    },
    nodes: {
      scaling: {
        min: 35,
        max: 40,
        label: {
          enabled: true,
          min: 10,
          max: 14,
          maxVisible: 30,
          drawThreshold: 5
        }
      },
      shapeProperties: { interpolation: false },
      shadow: {
        enabled: true,
        color: `rgba(${hexToRgb(THEME_COLORS.primary).r}, ${hexToRgb(THEME_COLORS.primary).g}, ${hexToRgb(THEME_COLORS.primary).b}, 0.2)`,
        size: 10,
        x: 0,
        y: 0
      }
    },
    configure: {
      enabled: false
    },
    
    edges: {
      smooth: { enabled: false },
      scaling: {
        min: 0.5,
        max: 2,
        label: {
          enabled: true,
          min: 10,
          max: 12,
          maxVisible: 20,
          drawThreshold: 1
        }
      },
      font: {
        align: "middle",
        strokeWidth: 2,
        strokeColor: THEME_COLORS.background
      },
      color: { inherit: false },
      selectionWidth: 2,
      arrowStrikethrough: false
    },
    layout: {
      improvedLayout: true,
      hierarchical: { enabled: false }
    },
    // 导航按钮样式设置 - 与主色调统一
    manipulation: {
      enabled: false
    },
    navigation: {
      enabled: true,
      buttonColor: THEME_COLORS.background,  // 按钮背景色
      iconColor: THEME_COLORS.primary,       // 按钮图标色(放大缩小等)
      hoverButtonColor: THEME_COLORS.lightBeige // 悬停时按钮背景色
    }
  };

  // 销毁旧实例避免内存泄漏
  if (network.value) {
    network.value.destroy();
  }

  // 创建网络实例
  requestAnimationFrame(() => {
    // 安全检查:确保容器存在
    if (!container) {
      loading.value = false;
      return;
    }
    
    network.value = new Network(container, data, options);

    // 节点点击事件:显示详情
    network.value.on('click', (params) => {
      if (params.nodes.length > 0) {
        const nodeId = params.nodes[0];
        selectedNode.value = nodes.find(n => n.id === nodeId) || null;
      }
    });

    // 布局稳定后自动禁用物理引擎
    network.value.once('stabilizationIterationsDone', () => {
      // 添加安全检查,确保network实例仍然存在
      if (network.value && physicsEnabled.value) {
        setTimeout(() => {
          // 再次检查network实例是否存在
          if (network.value) {
            network.value.setOptions({ physics: { enabled: false } });
            physicsEnabled.value = false;
          }
        }, 1000);
      }
    });

    loading.value = false;
  });
};

// 辅助函数:十六进制颜色转RGB
function hexToRgb(hex) {
  // 移除#号
  hex = hex.replace(/^#/, '');
  
  // 解析RGB值
  const bigint = parseInt(hex, 16);
  const r = (bigint >> 16) & 255;
  const g = (bigint >> 8) & 255;
  const b = bigint & 255;
  
  return { r, g, b };
}

// 重置图谱视图
const resetGraph = () => {
  // 添加安全检查
  if (!network.value) return;
  
  network.value.fit();
  selectedNode.value = null;
  
  // 临时启用物理引擎重新布局
  network.value.setOptions({
    physics: {
      enabled: true,
      stabilization: { iterations: 500 }
    }
  });
  physicsEnabled.value = true;

  // 布局完成后禁用
  setTimeout(() => {
    if (network.value) {  // 安全检查
      network.value.setOptions({ physics: { enabled: false } });
      physicsEnabled.value = false;
    }
  }, 1500);
};

// 切换物理引擎状态
const togglePhysics = () => {
  // 添加安全检查
  if (!network.value) return;
  
  physicsEnabled.value = !physicsEnabled.value;
  network.value.setOptions({
    physics: {
      enabled: physicsEnabled.value,
      stabilization: { iterations: physicsEnabled.value ? 500 : 0 }
    }
  });
};

// 组件挂载初始化
onMounted(() => {
  // 提前设置容器基础尺寸
  if (networkContainer.value) {
    networkContainer.value.style.width = '100%';
    networkContainer.value.style.height = '600px';
  }

  // 错误处理初始化
  handleResizeObserverError();

  nextTick(() => {
    initNetwork();
  });

});

// 组件卸载清理
onUnmounted(() => {
  if (network.value) {
    network.value.destroy();
    network.value = null;
  }

  // 恢复原生错误处理
  window.onerror = null;
  window.onunhandledrejection = null;
});
</script>

<style scoped>
/* 全局样式重置 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

main {
  flex-grow: 1;
}

/* 页面整体样式 - 明亮浅色古风基调 */
.heritage-graph-app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  background-color: #FFFCF7; /* 极浅米色背景,类似宣纸但更亮 */
  color: #8B5A2B; /* 使用主题次要色 */
  background-image: 
    linear-gradient(rgba(210, 180, 140, 0.1) 1px, transparent 1px),
    linear-gradient(90deg, rgba(210, 180, 140, 0.1) 1px, transparent 1px);
  background-size: 20px 20px; /* 更淡的宣纸纹理效果 */
}

/* 装饰性纹样 - 与主色调统一 */
.decorative-pattern {
  height: 30px;
  background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264.888-.14 1.24.916 1.8 2.376 1.8 3.82 0 3.87-3.42 7-7.628 7C13.282 27 10 23.87 10 20c0-1.444.56-2.904 1.8-3.82.168-.124.53-.015.888.14C16.145 15.248 18.135 15 20 15c1.864 0 3.854.248 5.184 5zM82.5 10c.69 0 1.37-.055 2-.16v1.16c0 4.418-3.582 8-8 8-4.418 0-8-3.582-8-8s3.582-8 8-8zm0-2c-5.523 0-10 4.477-10 10s4.477 10 10 10 10-4.477 10-10-4.477-10-10-10z' fill='%23cd853f' fill-opacity='0.2' fill-rule='evenodd'/%3E%3C/svg%3E");
  opacity: 0.8;
}

.top-pattern {
  margin-bottom: 20px;
}

.bottom-pattern {
  margin-top: 20px;
}

/* 介绍卡片样式 - 明亮浅色 */
.intro-card {
  border-radius: 16px !important;
  margin-left: 2rem !important;
  margin-right: 2rem !important;
  padding-top: 1.5rem !important;
  padding-bottom: 1.5rem !important;
  box-shadow: 0 4px 6px rgba(205, 133, 63, 0.1) !important;
  background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%238b5a2b' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
}

.bg-\[\#FFFCF7\] {
  margin-bottom: 2rem;
  margin-top: 2rem;
}

/* 图谱区域样式 - 更明亮 */
.graph-visualization-container {
  background-color: #FFFCF7;
  border: 1px solid #F5F5DC;
  box-shadow: 0 4px 12px rgba(205, 133, 63, 0.1) !important;
  overflow: hidden;
  max-width: 1400px;
  margin-left: 2rem;
  margin-right: 2rem;
  padding: 1rem;
  border-radius: 8px;
}

.graph-visualization-container button {
  border-radius: 4px !important;
  padding: 6px 12px !important;
  font-size: 14px !important;
  transition: all 0.3s ease;
}

/* 侧边栏样式 - 明亮浅色 */
.sidebar-right {
  margin-right: 2rem;
}

.sidebar-right > div {
  padding: 1.25rem !important;
  border-radius: 8px;
  background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='%238b5a2b' fill-opacity='0.05' fill-rule='evenodd'/%3E%3C/svg%3E");
}

/* 自定义滚动条 - 与主题色统一 */
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}
::-webkit-scrollbar-track {
  background: #f9f9f9;
  border-radius: 4px;
}
::-webkit-scrollbar-thumb {
  background: #D2B48C;
  border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
  background: #CD853F;
}

/* 图谱容器样式 - 更明亮的背景 */
.network-wrapper {
  position: relative;
  width: 100%;
  height: 600px;
  overflow: hidden;
  border: 1px solid #F5F5DC;
  border-radius: 4px;
  background-color: rgba(255, 252, 247, 0.95);
}
.network-container {
  width: 100%;
  height: 100%;
  position: relative;
  min-width: 600px;
  min-height: 600px;
}

/* 加载状态样式 - 与主题色统一 */
.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(255, 252, 247, 0.7);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 10;
}
.loading-spinner {
  background-color: #FFFCF7;
  padding: 12px 20px;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
  display: flex;
  align-items: center;
  border: 1px solid #F5F5DC;
}

/* 悬停提示样式 - 与主题色统一 */
.vis-tooltip {
  border-radius: 8px !important;
  padding: 10px !important;
  box-shadow: 0 4px 8px rgba(158, 201, 209, 0.05) !important;
  background-color: #FFFCF7 !important;
  border: 1px solid #F5F5DC !important;
  color: #44bac0 !important;
  font-size: 12px !important;
  font-family: "SimSun", serif !important;
}

/* 节点交互样式 - 更亮眼的效果 */
.vis-network:focus {
  outline: none !important;
}
.vis-active {
  box-shadow: 0 0 15px rgba(205, 133, 63, 0.5) !important;
}
.vis-node:hover {
  filter: brightness(1.15) !important;
}

/* 导航按钮样式 - 与主题色统一 */
.vis-navigation {
  border-color: #F5F5DC !important;
}
.vis-button {
  background-color: #FFFCF7 !important;
  border-color: #F5F5DC !important;
  color: #CD853F !important;
}
.vis-button:hover {
  background-color: #FFF8E1 !important;
  color: #8B5A2B !important;
}

footer {
  margin-bottom: 2rem;
}

footer .container {
  padding-top: 2rem;
}

/* 响应式调整 */
@media (min-width: 1024px) {
  .flex-col.lg\:flex-row {
    flex-direction: row;
  }
}

/* 标题样式 */
h1, h2, h3, h4 {
  font-family: "STZhongsong", "SimSun", serif;
}

/* 中文字体优化 */
body {
  font-family: "SimSun", "STSong", serif;
}
</style>

四、核心步骤:拿到项目包后,4 步跑通(零代码)

这部分是重点!跟着做,10 分钟内必能运行成功。

步骤 1:解压项目包,用 VS Code 打开

  1. 下载我提供的完整项目包,右键 “解压到当前文件夹”(记住解压后的路径,比如D:\非遗知识图谱项目);
  2. 打开 VS Code,点击顶部菜单栏「文件」→「打开文件夹」,选择解压后的项目文件夹(比如选 “非遗知识图谱项目”);
  3. 此时 VS Code 左侧会显示项目目录,重点确认 2 个文件是否存在(这是图谱的数据来源):
    • public文件夹下的node-export1.csv(节点数据);
    • public文件夹下的relationship-export1.csv(关系数据)。

步骤 2:安装项目依赖(仅需执行 1 条命令)

  1. 在 VS Code 里打开 “终端”:点击顶部菜单栏「终端」→「新建终端」,底部会出现黑色命令框;
  2. 在终端里输入命令npm install(复制粘贴就行,不用手敲);
  3. 等待安装:过程需要 2-3 分钟(取决于网速),看到终端显示 “added XXX packages”(XXX 是数字),没有红色报错,就是安装成功;
    • ❗ 如果报错:比如 “npm 不是内部命令”,是 Node.js 没装好或没勾选 “Add to PATH”,重新装 Node.js 并重启 VS Code;
    • ❗ 如果卡住:按Ctrl+C终止,重新执行npm install

步骤 3:启动项目(仅需执行 1 条命令)

  1. 在终端里继续输入命令npm run serve(复制粘贴);
  2. 等待启动:看到终端显示类似以下内容,就是启动成功:

    plaintext

    App running at:
    - Local:   http://localhost:8080/
    - Network: http://192.168.1.100:8080/
    
  3. 访问图谱:在地址栏输入上述链接(二选一),按回车。

步骤 4:验证是否成功(看 3 个标志)

启动后如果看到以下 3 点,说明运行成功:

  1. 页面直接显示 “非物质文化遗产知识图谱”,没有多余的导航栏、按钮;
  2. 左侧有圆形节点和连线(图谱主体),右侧有 “节点详情”“图例说明” 等侧边栏;
  3. 交互正常:
    • 鼠标拖拽节点能移动;
    • 点击节点,右侧 “节点详情” 会显示节点名称和描述;
    • 鼠标滚轮能缩放图谱;
    • 点击 “重置视图”“固定布局” 按钮,功能能正常触发。

项目实际运行效果:

五、关键说明:项目里的 CSV 数据怎么来的?(换数据必看)

很多小伙伴会问:“我想换成自己的图谱数据,该怎么做?” 先搞懂现有数据的来源,再换数据就简单了。

现有 CSV 数据的来源(不用你操作,仅了解)

项目包里的node-export1.csvrelationship-export1.csv,是我从 Neo4j Aura 导出的,步骤如下:

  1. 在 Neo4j Aura 的「Neo4j Query」里,用Cypher 语句创建非遗相关的节点(如 “黄梅戏”“邢绣娘”)和关系(如 “属于”“借鉴”);
  2. 创建完成后,在 Neo4j Explore里可以查看图谱效果,并且找到Export按钮导出 CSV文件
  3. 把导出的 CSV 文件改名(保持node-export1.csvrelationship-export1.csv),放到项目的public文件夹里。

替换成自己的数据(3 步操作,不用改 Vue 代码)

如果你想做自己的图谱(比如 “中医药知识图谱”“历史人物图谱”),按以下步骤来,Vue 代码不用动:

第一步:用 Neo4j Aura 建你的图谱
  1. 登录 Neo4j Aura,打开「Neo4j Browser」;
  2. Cypher 语句创建你的节点和关系(比如 “中药 - 功效 - 病症” 节点,具体语法可查 Neo4j 官方文档,很简单);
  3. 确认创建成功:左侧「Database Information」能看到节点数和关系数,没有报错。
第二步:导出你的 CSV 文件
  1. 创建完成后,在 Neo4j Explore里可以查看图谱效果,并且找到Export按钮导出 CSV文件
  2. 注意:文件名必须和项目代码里的一致(node-export1.csvrelationship-export1.csv,否则 Vue 读不到数据。
第三步:替换项目里的 CSV 并重启
  1. 找到你导出的 2 个 CSV 文件,复制粘贴到项目的public文件夹里,选择 “替换”(覆盖原来的非遗数据);
  2. 重启 Vue 项目:在 VS Code 终端按Ctrl+C终止当前运行,再执行npm run serve
  3. 打开链接,就能看到你的图谱了!

六、常见问题排查(新手必看,少踩坑)

运行中遇到问题别慌,90% 的问题都是以下几种,按步骤解决就行:

问题 1:页面样式错乱(比如文字挤在一起、没有布局)

  • 原因:Tailwind CSS 样式没生效(项目的布局全靠它);
  • 解决
    1. 检查src/main.js文件:是否有import './assets/main.css'这行代码(没的话加上,再重启);
    2. 检查src/assets/main.css文件:是否有 3 行 Tailwind 代码(@tailwind base; @tailwind components; @tailwind utilities;),没的话加上,再重启。

问题 2:图谱空白,控制台报 “404 Not Found”(CSV 加载失败)

  • 原因:CSV 文件没放在public文件夹里,或文件名错了;
  • 解决
    1. 确认public文件夹下有node-export1.csvrelationship-export1.csv
    2. 确认文件名和代码里的一致(不能多空格、不能改后缀,比如node-export1.csv不能写成node-export.csv);
    3. 重启项目,刷新浏览器。

问题 3:图谱节点重叠在一起,看不清

  • 原因:物理引擎的 “避免重叠” 参数设得太小;
  • 解决
    1. 打开src/views/KnowledgeGraph.vue文件;
    2. 找到initNetwork函数里的options.physics.barnesHut.avoidOverlap,把值从0.8改成1.0
    3. 重启项目,节点就会自动分开。

七、总结

这套项目的核心优势就是 “零门槛”:

  • 不用装复杂的本地数据库(Neo4j Aura 在线用);
  • 不用写 Vue 代码(项目包全配置好);
  • 换数据只需改 Neo4j 和 CSV,不用动前端逻辑。

如果在操作中遇到其他问题,欢迎在评论区留言,我会把常见问题补充到教程里,让更多人少走弯路~ 最后,希望大家都能轻松做出自己的知识图谱可视化页面!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐