Parcourir la source

feat: 添加帖子树可视化和Tab切换功能

- 新增帖子匹配Tab,支持同时展示人设树、相关图、帖子树
- 新增PostTreeView组件展示帖子图谱树结构
- 支持下拉选择不同帖子查看
- 修改build.py支持内联帖子图谱数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui il y a 3 jours
Parent
commit
8fceb1e4f1

+ 13 - 2
script/visualization/build.py

@@ -31,12 +31,21 @@ def main():
 
     # 1. 确定数据文件路径
     persona_graph_file = config.intermediate_dir / "人设图谱.json"
-    print(f"数据文件: {persona_graph_file}")
+    post_graph_dir = config.intermediate_dir / "post_graph"
+
+    print(f"人设图谱: {persona_graph_file}")
+    print(f"帖子图谱目录: {post_graph_dir}")
 
     if not persona_graph_file.exists():
-        print(f"错误: 数据文件不存在!")
+        print(f"错误: 人设图谱文件不存在!")
         sys.exit(1)
 
+    # 统计帖子图谱文件数
+    post_graph_count = 0
+    if post_graph_dir.exists():
+        post_graph_count = len(list(post_graph_dir.glob("*_帖子图谱.json")))
+    print(f"帖子图谱数量: {post_graph_count}")
+
     # 2. 检查是否需要安装依赖
     node_modules = viz_dir / "node_modules"
     if not node_modules.exists():
@@ -47,6 +56,8 @@ def main():
     print("\n构建 Vue 项目...")
     env = os.environ.copy()
     env["GRAPH_DATA_PATH"] = str(persona_graph_file.absolute())
+    if post_graph_dir.exists():
+        env["POST_GRAPH_DIR"] = str(post_graph_dir.absolute())
 
     subprocess.run(["npm", "run", "build"], cwd=viz_dir, env=env, check=True)
 

+ 29 - 4
script/visualization/src/App.vue

@@ -4,6 +4,20 @@
     <header class="navbar bg-base-200 min-h-0 px-4 py-2 shrink-0">
       <h1 class="text-sm font-medium text-primary">人设图谱</h1>
 
+      <!-- Tab 切换 -->
+      <div class="tabs tabs-boxed ml-4 bg-base-300">
+        <a
+          class="tab tab-sm"
+          :class="{ 'tab-active': activeTab === 'persona' }"
+          @click="activeTab = 'persona'"
+        >人设图谱</a>
+        <a
+          class="tab tab-sm"
+          :class="{ 'tab-active': activeTab === 'match' }"
+          @click="activeTab = 'match'"
+        >帖子匹配</a>
+      </div>
+
       <!-- 节点颜色图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-6 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">节点颜色:</span>
@@ -25,7 +39,7 @@
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">节点形状:</span>
         <div class="flex items-center gap-1">○ 标签</div>
-        <div class="flex items-center gap-1">□ 分类</div>
+        <div class="flex items-center gap-1">□ 分类/点</div>
       </div>
 
       <!-- 边类型图例 -->
@@ -38,26 +52,37 @@
       </div>
     </header>
 
-    <!-- 主内容区 -->
-    <main class="flex flex-1 overflow-hidden">
+    <!-- 主内容区 - 人设图谱 Tab -->
+    <main v-show="activeTab === 'persona'" class="flex flex-1 overflow-hidden">
       <TreeView class="w-[420px] shrink-0 bg-base-200 border-r border-base-300" />
       <GraphView class="flex-1" />
     </main>
 
+    <!-- 主内容区 - 帖子匹配 Tab -->
+    <main v-show="activeTab === 'match'" class="flex flex-1 overflow-hidden">
+      <TreeView class="w-[320px] shrink-0 bg-base-200 border-r border-base-300" />
+      <GraphView class="flex-1 min-w-[300px]" />
+      <PostTreeView class="w-[360px] shrink-0 bg-base-200 border-l border-base-300" />
+    </main>
+
     <!-- 详情面板 -->
     <DetailPanel />
   </div>
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { ref, computed } from 'vue'
 import TreeView from './components/TreeView.vue'
 import GraphView from './components/GraphView.vue'
+import PostTreeView from './components/PostTreeView.vue'
 import DetailPanel from './components/DetailPanel.vue'
 import { useGraphStore } from './stores/graph'
 
 const store = useGraphStore()
 
+// 当前激活的 Tab
+const activeTab = ref('persona')
+
 // 边类型颜色调色板
 const edgeColorPalette = ['#9b59b6', '#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#1abc9c']
 

+ 310 - 0
script/visualization/src/components/PostTreeView.vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="flex flex-col h-full">
+    <!-- 头部 -->
+    <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
+      <span>帖子树</span>
+      <span v-if="store.highlightedPostNodeIds.size > 0" class="text-primary">
+        已高亮 {{ store.highlightedPostNodeIds.size }} 个节点
+      </span>
+    </div>
+
+    <!-- 帖子选择下拉框 -->
+    <div class="px-4 py-2 bg-base-200 border-b border-base-300">
+      <select
+        v-model="selectedPostIdx"
+        @change="onPostChange"
+        class="select select-xs select-bordered w-full"
+      >
+        <option v-if="store.postList.length === 0" :value="-1">暂无帖子数据</option>
+        <option
+          v-for="post in store.postList"
+          :key="post.index"
+          :value="post.index"
+        >
+          {{ formatPostOption(post) }}
+        </option>
+      </select>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
+      <svg ref="svgRef" class="block" @click="handleSvgClick"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+// 当前选中的帖子索引
+const selectedPostIdx = ref(store.selectedPostIndex)
+
+// 帖子选择变化
+function onPostChange() {
+  store.selectPost(selectedPostIdx.value)
+}
+
+// 格式化帖子选项显示
+function formatPostOption(post) {
+  const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
+  const title = post.postTitle || post.postId
+  const shortTitle = title.length > 20 ? title.slice(0, 20) + '...' : title
+  return date ? `${date} ${shortTitle}` : shortTitle
+}
+
+// 维度颜色映射(与人设树保持一致)
+const dimColors = {
+  '帖子': '#e94560',
+  '灵感点': '#f39c12',
+  '目的点': '#3498db',
+  '关键点': '#9b59b6'
+}
+
+// 节点元素映射
+let nodeElements = {}
+let currentRoot = null
+
+// 获取节点颜色
+function getNodeColor(d) {
+  if (d.data.type === '帖子') return dimColors['帖子']
+  return dimColors[d.data.dimension] || '#888'
+}
+
+// 处理节点点击
+function handleNodeClick(event, d) {
+  event.stopPropagation()
+  const nodeId = d.data.id
+  store.selectPostNode(nodeId)
+  updateSelection()
+}
+
+// 渲染树
+function renderTree() {
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+  nodeElements = {}
+
+  const treeData = store.postTreeData
+  if (!treeData || !treeData.id) return
+
+  // 创建层级数据
+  const root = d3.hierarchy(treeData)
+  currentRoot = root
+
+  // 智能计算树的尺寸
+  const allNodes = root.descendants()
+  const maxDepth = d3.max(allNodes, d => d.depth)
+  const leafCount = allNodes.filter(d => !d.children).length
+
+  // 高度:基于叶子节点数量
+  const treeHeight = Math.max(600, leafCount * 20 + 100)
+  // 宽度:根据深度
+  const treeWidth = Math.max(400, (maxDepth + 1) * 120 + 50)
+
+  svg.attr('width', treeWidth).attr('height', treeHeight + 50)
+
+  // 创建树布局
+  const treeLayout = d3.tree()
+    .size([treeHeight - 50, treeWidth - 100])
+    .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
+
+  treeLayout(root)
+
+  // 创建主组
+  const g = svg.append('g')
+    .attr('transform', 'translate(25, 25)')
+
+  // 绘制边
+  g.append('g')
+    .attr('class', 'tree-edges')
+    .selectAll('.tree-link')
+    .data(root.links())
+    .join('path')
+    .attr('class', 'tree-link')
+    .attr('fill', 'none')
+    .attr('stroke', '#3498db')
+    .attr('stroke-opacity', 0.3)
+    .attr('stroke-width', 1)
+    .attr('d', d => {
+      const midX = (d.source.y + d.target.y) / 2
+      return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
+    })
+
+  // 绘制节点
+  const nodes = g.append('g')
+    .attr('class', 'tree-nodes')
+    .selectAll('.tree-node')
+    .data(root.descendants())
+    .join('g')
+    .attr('class', d => {
+      let cls = 'tree-node'
+      if (store.selectedPostNodeId === d.data.id) cls += ' selected'
+      if (store.highlightedPostNodeIds.has(d.data.id)) cls += ' highlighted'
+      return cls
+    })
+    .attr('transform', d => `translate(${d.y},${d.x})`)
+    .style('cursor', 'pointer')
+    .on('click', handleNodeClick)
+
+  // 节点形状
+  nodes.each(function(d) {
+    const el = d3.select(this)
+    const nodeType = d.data.type
+    const nodeColor = getNodeColor(d)
+    const isRoot = d.depth === 0
+    const isDimension = ['灵感点', '目的点', '关键点'].includes(nodeType)
+
+    if (nodeType === '点') {
+      // 点用方形
+      el.append('rect')
+        .attr('class', 'tree-shape')
+        .attr('x', -4)
+        .attr('y', -4)
+        .attr('width', 8)
+        .attr('height', 8)
+        .attr('rx', 1)
+        .attr('fill', nodeColor)
+        .attr('stroke', 'rgba(255,255,255,0.5)')
+        .attr('stroke-width', 1)
+    } else {
+      const radius = isRoot ? 6 : (isDimension ? 5 : 3)
+      el.append('circle')
+        .attr('class', 'tree-shape')
+        .attr('r', radius)
+        .attr('fill', nodeColor)
+        .attr('stroke', 'rgba(255,255,255,0.5)')
+        .attr('stroke-width', 1)
+    }
+
+    nodeElements[d.data.id] = this
+  })
+
+  // 节点标签
+  nodes.append('text')
+    .attr('dy', '0.31em')
+    .attr('x', d => d.children ? -8 : 8)
+    .attr('text-anchor', d => d.children ? 'end' : 'start')
+    .attr('fill', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return (isRoot || isDimension) ? getNodeColor(d) : '#bbb'
+    })
+    .attr('font-size', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return isRoot ? '11px' : (isDimension ? '10px' : '9px')
+    })
+    .attr('font-weight', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return (isRoot || isDimension) ? 'bold' : 'normal'
+    })
+    .text(d => {
+      const name = d.data.name
+      const maxLen = d.children ? 6 : 8
+      return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
+    })
+}
+
+// 滚动到指定节点
+function scrollToNode(nodeId) {
+  const nodeEl = nodeElements[nodeId]
+  if (!nodeEl) return
+
+  const container = containerRef.value
+  const nodeRect = nodeEl.getBoundingClientRect()
+  const containerRect = container.getBoundingClientRect()
+
+  const scrollTop = container.scrollTop + nodeRect.top - containerRect.top - containerRect.height / 2
+  const scrollLeft = container.scrollLeft + nodeRect.left - containerRect.left - containerRect.width / 2
+
+  container.scrollTo({
+    top: Math.max(0, scrollTop),
+    left: Math.max(0, scrollLeft),
+    behavior: 'smooth'
+  })
+}
+
+// 更新选中/高亮状态
+function updateSelection() {
+  const svg = d3.select(svgRef.value)
+  const hasHighlight = store.highlightedPostNodeIds.size > 0
+
+  svg.selectAll('.tree-node')
+    .classed('selected', d => store.selectedPostNodeId === d.data.id)
+    .classed('highlighted', d => store.highlightedPostNodeIds.has(d.data.id))
+    .classed('dimmed', d => hasHighlight && !store.highlightedPostNodeIds.has(d.data.id))
+
+  svg.selectAll('.tree-link')
+    .classed('highlighted', d => {
+      return store.highlightedPostNodeIds.has(d.source.data.id) &&
+             store.highlightedPostNodeIds.has(d.target.data.id)
+    })
+    .classed('dimmed', d => {
+      return hasHighlight && !(store.highlightedPostNodeIds.has(d.source.data.id) &&
+             store.highlightedPostNodeIds.has(d.target.data.id))
+    })
+}
+
+// 清除高亮
+function clearHighlight() {
+  store.clearPostSelection()
+  updateSelection()
+}
+
+// 点击空白取消激活
+function handleSvgClick(event) {
+  if (event.target.tagName === 'svg') {
+    clearHighlight()
+  }
+}
+
+// 滚动到根节点
+function scrollToRoot() {
+  if (!currentRoot) return
+  const container = containerRef.value
+  if (!container) return
+
+  const rootY = currentRoot.x + 25
+  const targetScroll = rootY - container.clientHeight / 2
+  container.scrollTop = Math.max(0, targetScroll)
+}
+
+// 监听选中变化
+watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
+  if (nodeId && nodeId !== oldNodeId) {
+    updateSelection()
+    scrollToNode(nodeId)
+  } else if (!nodeId) {
+    updateSelection()
+  }
+})
+
+// 监听高亮变化
+watch(() => store.highlightedPostNodeIds.size, () => {
+  updateSelection()
+})
+
+// 监听当前帖子变化,重新渲染树
+watch(() => store.currentPostGraph, () => {
+  renderTree()
+  setTimeout(scrollToRoot, 100)
+}, { immediate: false })
+
+// 监听 selectedPostIndex 变化,同步下拉框
+watch(() => store.selectedPostIndex, (newIdx) => {
+  selectedPostIdx.value = newIdx
+})
+
+onMounted(() => {
+  renderTree()
+  setTimeout(scrollToRoot, 100)
+})
+</script>

+ 74 - 5
script/visualization/src/stores/graph.js

@@ -3,15 +3,68 @@ import { ref, computed } from 'vue'
 
 // eslint-disable-next-line no-undef
 const graphDataRaw = __GRAPH_DATA__  // 由 vite.config.js 注入
+// eslint-disable-next-line no-undef
+const postGraphListRaw = __POST_GRAPH_LIST__ || []  // 帖子图谱列表
 
-console.log('graphDataRaw loaded:', !!graphDataRaw)
-console.log('graphDataRaw.tree:', graphDataRaw?.tree)
-console.log('nodes count:', Object.keys(graphDataRaw?.nodes || {}).length)
+console.log('人设图谱 loaded:', !!graphDataRaw)
+console.log('人设节点数:', Object.keys(graphDataRaw?.nodes || {}).length)
+console.log('帖子图谱数:', postGraphListRaw.length)
 
 export const useGraphStore = defineStore('graph', () => {
-  // 原始数据
+  // ==================== 人设图谱数据 ====================
   const graphData = ref(graphDataRaw || { nodes: {}, edges: {}, index: {}, tree: {} })
 
+  // ==================== 帖子图谱数据 ====================
+  const postGraphList = ref(postGraphListRaw)
+  const selectedPostIndex = ref(postGraphListRaw.length > 0 ? 0 : -1)
+
+  // 当前选中的帖子图谱
+  const currentPostGraph = computed(() => {
+    if (selectedPostIndex.value < 0 || selectedPostIndex.value >= postGraphList.value.length) {
+      return null
+    }
+    return postGraphList.value[selectedPostIndex.value]
+  })
+
+  // 帖子列表(用于下拉选择)
+  const postList = computed(() => {
+    return postGraphList.value.map((post, index) => ({
+      index,
+      postId: post.meta?.postId,
+      postTitle: post.meta?.postTitle || post.meta?.postId,
+      createTime: post.meta?.postDetail?.create_time
+    }))
+  })
+
+  // 选择帖子
+  function selectPost(index) {
+    selectedPostIndex.value = index
+    // 清除帖子相关的选中状态
+    selectedPostNodeId.value = null
+    highlightedPostNodeIds.value = new Set()
+  }
+
+  // ==================== 帖子树选中状态 ====================
+  const selectedPostNodeId = ref(null)
+  const highlightedPostNodeIds = ref(new Set())
+
+  // 获取帖子节点
+  function getPostNode(nodeId) {
+    return currentPostGraph.value?.nodes?.[nodeId]
+  }
+
+  // 选中帖子节点
+  function selectPostNode(nodeId) {
+    selectedPostNodeId.value = nodeId
+    highlightedPostNodeIds.value = new Set([nodeId])
+  }
+
+  // 清除帖子选中
+  function clearPostSelection() {
+    selectedPostNodeId.value = null
+    highlightedPostNodeIds.value = new Set()
+  }
+
   // 当前选中的节点
   const selectedNodeId = ref(null)
 
@@ -89,7 +142,11 @@ export const useGraphStore = defineStore('graph', () => {
   // 计算属性:树数据
   const treeData = computed(() => graphData.value.tree)
 
+  // 帖子树数据
+  const postTreeData = computed(() => currentPostGraph.value?.tree)
+
   return {
+    // 人设图谱
     graphData,
     selectedNodeId,
     highlightedNodeIds,
@@ -98,6 +155,18 @@ export const useGraphStore = defineStore('graph', () => {
     getNode,
     getNeighbors,
     selectNode,
-    clearSelection
+    clearSelection,
+    // 帖子图谱
+    postGraphList,
+    postList,
+    selectedPostIndex,
+    currentPostGraph,
+    postTreeData,
+    selectPost,
+    selectedPostNodeId,
+    highlightedPostNodeIds,
+    getPostNode,
+    selectPostNode,
+    clearPostSelection
   }
 })

+ 36 - 9
script/visualization/vite.config.js

@@ -2,25 +2,52 @@ import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import { viteSingleFile } from 'vite-plugin-singlefile'
 import fs from 'fs'
+import path from 'path'
 
-// 数据路径由 Python 通过环境变量传入
-const dataPath = process.env.GRAPH_DATA_PATH
-if (!dataPath) {
+// 人设图谱数据路径由 Python 通过环境变量传入
+const personaDataPath = process.env.GRAPH_DATA_PATH
+if (!personaDataPath) {
   console.error('错误: 请设置 GRAPH_DATA_PATH 环境变量')
   process.exit(1)
 }
-console.log('数据文件:', dataPath)
+console.log('人设图谱数据文件:', personaDataPath)
 
-// 读取 JSON 数据
-const graphData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
-console.log('节点数:', Object.keys(graphData.nodes || {}).length)
-console.log('边数:', (graphData.edges || []).length)
+// 读取人设图谱 JSON 数据
+const personaGraphData = JSON.parse(fs.readFileSync(personaDataPath, 'utf-8'))
+console.log('人设节点数:', Object.keys(personaGraphData.nodes || {}).length)
+
+// 帖子图谱数据路径(可选,目录)
+const postGraphDir = process.env.POST_GRAPH_DIR
+let postGraphList = []
+
+if (postGraphDir && fs.existsSync(postGraphDir)) {
+  console.log('帖子图谱目录:', postGraphDir)
+  const files = fs.readdirSync(postGraphDir).filter(f => f.endsWith('_帖子图谱.json'))
+  console.log('帖子图谱文件数:', files.length)
+
+  // 读取所有帖子图谱
+  for (const file of files) {
+    const filePath = path.join(postGraphDir, file)
+    const postData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+    postGraphList.push(postData)
+  }
+
+  // 按创建时间降序排序
+  postGraphList.sort((a, b) => {
+    const dateA = a.meta?.postDetail?.create_time || 0
+    const dateB = b.meta?.postDetail?.create_time || 0
+    return dateB - dateA
+  })
+} else {
+  console.log('未设置帖子图谱目录或目录不存在')
+}
 
 export default defineConfig({
   plugins: [vue(), viteSingleFile()],
   define: {
     // 将数据注入为全局常量
-    __GRAPH_DATA__: JSON.stringify(graphData)
+    __GRAPH_DATA__: JSON.stringify(personaGraphData),
+    __POST_GRAPH_LIST__: JSON.stringify(postGraphList)
   },
   build: {
     target: 'esnext',