Переглянути джерело

feat: 添加推导图谱配置和视图控制功能

- 添加统一的路径查找函数 findPaths,支持方向、推导边数等配置
- 添加构建配置和点击配置下拉菜单(方向、TopN、推导边数、起点类型)
- 添加顶部视图配置下拉,可控制各面板显示/隐藏
- 推导图谱无数据时自动隐藏
- 修复节点详情中入边/出边的显示
- 添加边详情模态框

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 18 годин тому
батько
коміт
473794e792

+ 72 - 19
script/visualization/src/App.vue

@@ -51,6 +51,43 @@
           <span>{{ type }}</span>
         </div>
       </div>
+
+      <!-- 右侧:视图配置下拉 -->
+      <div class="flex-1"></div>
+      <div v-if="activeTab === 'match'" class="dropdown dropdown-end">
+        <label tabindex="0" class="btn btn-ghost btn-sm gap-1">
+          <span class="text-xs">视图配置</span>
+          <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+          </svg>
+        </label>
+        <ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-40 z-50">
+          <li>
+            <label class="flex items-center gap-2 cursor-pointer">
+              <input type="checkbox" v-model="viewConfig.personaTree" class="checkbox checkbox-xs" />
+              <span class="text-xs">人设树</span>
+            </label>
+          </li>
+          <li>
+            <label class="flex items-center gap-2 cursor-pointer">
+              <input type="checkbox" v-model="viewConfig.graph" class="checkbox checkbox-xs" />
+              <span class="text-xs">相关图</span>
+            </label>
+          </li>
+          <li v-if="hasDerivationData">
+            <label class="flex items-center gap-2 cursor-pointer">
+              <input type="checkbox" v-model="viewConfig.derivation" class="checkbox checkbox-xs" />
+              <span class="text-xs">推导图谱</span>
+            </label>
+          </li>
+          <li>
+            <label class="flex items-center gap-2 cursor-pointer">
+              <input type="checkbox" v-model="viewConfig.postTree" class="checkbox checkbox-xs" />
+              <span class="text-xs">帖子树</span>
+            </label>
+          </li>
+        </ul>
+      </div>
     </header>
 
     <!-- 主内容区 - 人设图谱 Tab -->
@@ -65,13 +102,15 @@
     <main v-else-if="activeTab === 'match'" class="flex flex-1 overflow-hidden">
       <!-- 左侧:人设树 + 相关图(上下布局) -->
       <div
+        v-if="viewConfig.personaTree || viewConfig.graph"
         class="shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
         :class="getLeftPanelClass()"
       >
-        <!-- 人设树(上部,占50%) -->
+        <!-- 人设树(上部) -->
         <div
-          class="flex flex-col transition-all duration-200 h-1/2 shrink-0"
-          :class="{ 'h-full': store.expandedPanel === 'persona-tree', 'h-10': store.expandedPanel === 'graph' }"
+          v-if="viewConfig.personaTree"
+          class="flex flex-col transition-all duration-200"
+          :class="getPersonaTreeInnerClass()"
         >
           <div class="flex items-center justify-between px-4 py-1 bg-base-300 text-xs text-base-content/60 shrink-0 border-b border-base-300">
             <span>人设树</span>
@@ -92,17 +131,19 @@
           </div>
           <TreeView class="flex-1 min-h-0 overflow-auto" :hide-header="true" />
         </div>
-        <!-- 相关图(下部,占50%) -->
+        <!-- 相关图(下部) -->
         <div
+          v-if="viewConfig.graph"
           v-show="store.expandedPanel !== 'persona-tree'"
           class="flex-1 border-t border-base-300"
-          :class="{ 'h-full': store.expandedPanel === 'graph' }"
+          :class="{ 'h-full': store.expandedPanel === 'graph' || !viewConfig.personaTree }"
         >
           <GraphView class="h-full" :show-expand="true" />
         </div>
       </div>
-      <!-- 中间:推导图谱 -->
+      <!-- 中间:推导图谱(有数据且配置显示时才显示) -->
       <div
+        v-if="showDerivation"
         class="shrink-0 bg-base-200 border-l border-base-300 transition-all duration-200"
         :class="getDerivationPanelClass()"
       >
@@ -110,6 +151,7 @@
       </div>
       <!-- 右侧:帖子树(含匹配列表+详情) -->
       <div
+        v-if="viewConfig.postTree"
         class="bg-base-200 border-l border-base-300 flex flex-col transition-all duration-200"
         :class="getPostTreeClass()"
       >
@@ -120,7 +162,7 @@
 </template>
 
 <script setup>
-import { ref, computed } from 'vue'
+import { ref, computed, reactive } from 'vue'
 import TreeView from './components/TreeView.vue'
 import GraphView from './components/GraphView.vue'
 import PostTreeView from './components/PostTreeView.vue'
@@ -134,6 +176,24 @@ const store = useGraphStore()
 // 当前激活的 Tab
 const activeTab = ref('match')
 
+// 视图配置(默认全部勾选)
+const viewConfig = reactive({
+  personaTree: true,
+  graph: true,
+  derivation: true,
+  postTree: true
+})
+
+// 检测推导图谱是否有数据
+const hasDerivationData = computed(() => {
+  const postGraph = store.currentPostGraph
+  if (!postGraph || !postGraph.edges) return false
+  return Object.values(postGraph.edges).some(e => e.type === '推导' || e.type === '组成')
+})
+
+// 推导图谱是否显示(有数据且配置为显示)
+const showDerivation = computed(() => hasDerivationData.value && viewConfig.derivation)
+
 // 是否有选中内容(用于控制侧边栏显示)
 const hasSelection = computed(() => store.selectedNode || store.selectedEdge)
 
@@ -150,25 +210,18 @@ function switchTab(tab) {
 // ==================== 布局类计算 ====================
 function getLeftPanelClass() {
   const panel = store.expandedPanel
-  if (panel === 'post-tree') return 'w-0 opacity-0 overflow-hidden'
+  if (panel === 'post-tree' || panel === 'derivation') return 'w-0 opacity-0 overflow-hidden'
   if (panel === 'persona-tree' || panel === 'graph') return 'flex-1'
   return 'w-[360px]'
 }
 
-function getPersonaTreeClass() {
+function getPersonaTreeInnerClass() {
   const panel = store.expandedPanel
   if (panel === 'persona-tree') return 'flex-1'
   if (panel === 'graph') return 'h-10 shrink-0'
-  // 默认:根据相关图状态调整
-  if (store.selectedNodeId) return 'flex-1'
-  return 'flex-1'
-}
-
-function getGraphClass() {
-  const panel = store.expandedPanel
-  if (panel === 'graph') return 'flex-1'
-  if (store.selectedNodeId) return 'h-[280px] shrink-0'
-  return 'h-10 shrink-0'
+  // 如果相关图不显示,人设树占满
+  if (!viewConfig.graph) return 'flex-1'
+  return 'h-1/2 shrink-0'
 }
 
 function getPostTreeClass() {

+ 406 - 50
script/visualization/src/components/DerivationView.vue

@@ -4,8 +4,118 @@
     <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
       <span>推导图谱</span>
       <div class="flex items-center gap-2">
+        <!-- 初始化配置下拉 -->
+        <div class="dropdown dropdown-end">
+          <label tabindex="0" class="btn btn-ghost btn-xs gap-1">
+            <span>构建配置</span>
+            <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+            </svg>
+          </label>
+          <div tabindex="0" class="dropdown-content p-3 shadow bg-base-200 rounded-box w-52 z-50">
+            <div class="flex flex-col gap-2">
+              <label class="flex items-center gap-2">
+                <span class="w-20">方向:</span>
+                <select v-model="initConfig.direction" class="select select-xs bg-base-300 flex-1">
+                  <option value="backward">反向</option>
+                  <option value="forward">正向</option>
+                </select>
+              </label>
+              <label class="flex items-center gap-2">
+                <span class="w-20">Top N:</span>
+                <input
+                  type="number"
+                  v-model.number="initConfig.topN"
+                  min="1"
+                  max="100"
+                  class="input input-xs w-16 text-center bg-base-300"
+                />
+              </label>
+              <label class="flex items-center gap-2">
+                <span class="w-20">推导边数:</span>
+                <input
+                  type="number"
+                  v-model.number="initConfig.maxDerivationEdges"
+                  min="1"
+                  max="10"
+                  class="input input-xs w-16 text-center bg-base-300"
+                />
+              </label>
+              <div class="flex items-start gap-2">
+                <span class="w-20 shrink-0">起点类型:</span>
+                <div class="flex flex-wrap gap-1">
+                  <label v-for="t in nodeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      :value="t"
+                      v-model="initConfig.startNodeTypes"
+                      class="checkbox checkbox-xs"
+                    />
+                    <span class="text-xs">{{ t }}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 点击配置下拉 -->
+        <div class="dropdown dropdown-end">
+          <label tabindex="0" class="btn btn-ghost btn-xs gap-1">
+            <span>点击配置</span>
+            <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
+            </svg>
+          </label>
+          <div tabindex="0" class="dropdown-content p-3 shadow bg-base-200 rounded-box w-52 z-50">
+            <div class="flex flex-col gap-2">
+              <label class="flex items-center gap-2">
+                <span class="w-20">方向:</span>
+                <select v-model="clickConfig.direction" class="select select-xs bg-base-300 flex-1">
+                  <option value="backward">反向</option>
+                  <option value="forward">正向</option>
+                </select>
+              </label>
+              <label class="flex items-center gap-2">
+                <span class="w-20">Top N:</span>
+                <input
+                  type="number"
+                  v-model.number="clickConfig.topN"
+                  min="1"
+                  max="100"
+                  class="input input-xs w-16 text-center bg-base-300"
+                />
+              </label>
+              <label class="flex items-center gap-2">
+                <span class="w-20">推导边数:</span>
+                <input
+                  type="number"
+                  v-model.number="clickConfig.maxDerivationEdges"
+                  min="1"
+                  max="10"
+                  class="input input-xs w-16 text-center bg-base-300"
+                />
+              </label>
+              <div class="flex items-start gap-2">
+                <span class="w-20 shrink-0">起点类型:</span>
+                <div class="flex flex-wrap gap-1">
+                  <label v-for="t in nodeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      :value="t"
+                      v-model="clickConfig.startNodeTypes"
+                      class="checkbox checkbox-xs"
+                    />
+                    <span class="text-xs">{{ t }}</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+
         <span v-if="derivationStats.edges > 0" class="text-primary">
-          {{ derivationStats.edges }} 条推导边
+          {{ derivationStats.displayedEdges }}/{{ derivationStats.totalEdges }} 条边
         </span>
         <span v-else class="text-base-content/40">暂无推导数据</span>
         <!-- 放大/恢复按钮 -->
@@ -61,59 +171,209 @@ const selectedNodeId = ref(null)
 const selectedPathNodes = ref(new Set())
 const selectedPathEdges = ref(new Set())
 
+// ==================== 路径查找配置 ====================
+// 节点类型选项
+const nodeTypeOptions = ['标签', '分类', '点']
+
+// 初始化配置(用于过滤显示的边和节点)
+const initConfig = ref({
+  direction: 'backward',           // 从人设节点反向找
+  maxDerivationEdges: 1,           // 路径中最多1条推导边
+  topN: 1,                         // 每个目标节点取前N条路径
+  startNodeTypes: ['标签']         // 起点节点类型(默认标签)
+})
+
+// 点击配置(用于高亮路径)
+const clickConfig = ref({
+  direction: 'backward',           // 默认反向找(找入边路径)
+  maxDerivationEdges: 1,           // 路径中最多1条推导边
+  topN: 1,                         // 每个起点取前N条路径
+  startNodeTypes: ['标签']         // 起点节点类型(默认标签)
+})
+
+// ==================== 统一路径查找函数 ====================
+/**
+ * 统一的路径查找函数
+ * @param {Array} edges - 边列表
+ * @param {Object} config - 配置
+ *   - startNodes: 起点节点ID列表(可选)
+ *   - direction: 'forward' | 'backward'
+ *   - maxDerivationEdges: 路径中最大推导边数
+ * @returns {Object} { nodes: Set<nodeId>, edges: Set<edgeKey> }
+ */
+function findPaths(edges, config) {
+  const { startNodes, direction = 'forward', maxDerivationEdges = 1 } = config
+
+  if (!edges || edges.length === 0) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表
+  const adj = new Map()
+  for (const edge of edges) {
+    const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+    const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+
+    if (direction === 'forward') {
+      // 正向:source -> target
+      if (!adj.has(srcId)) adj.set(srcId, [])
+      adj.get(srcId).push({ neighbor: tgtId, edge })
+    } else {
+      // 反向:target -> source
+      if (!adj.has(tgtId)) adj.set(tgtId, [])
+      adj.get(tgtId).push({ neighbor: srcId, edge })
+    }
+  }
+
+  // BFS 追踪路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  // 起点列表
+  const starts = startNodes || Array.from(adj.keys())
+
+  for (const startId of starts) {
+    // 每个起点独立追踪,记录每个节点到达时已经过的推导边数
+    const queue = [{ nodeId: startId, derivationCount: 0 }]
+    const visited = new Map() // nodeId -> 到达时的最小推导边数
+    visited.set(startId, 0)
+    pathNodes.add(startId)
+
+    while (queue.length > 0) {
+      const { nodeId: curr, derivationCount } = queue.shift()
+
+      for (const { neighbor, edge } of (adj.get(curr) || [])) {
+        const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+        const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+        const edgeKey = `${srcId}->${tgtId}`
+
+        // 计算经过这条边后的推导边数
+        const newDerivationCount = derivationCount + (edge.type === '推导' ? 1 : 0)
+
+        // 如果已超过最大推导边数,不添加这条边
+        if (newDerivationCount > maxDerivationEdges) continue
+
+        // 添加边
+        pathEdges.add(edgeKey)
+        pathNodes.add(neighbor)
+
+        // 只有未访问或以更少推导边数到达时才继续追踪
+        const prevCount = visited.get(neighbor)
+        if (prevCount === undefined || newDerivationCount < prevCount) {
+          visited.set(neighbor, newDerivationCount)
+          // 继续追踪(推导边数未超限就可以继续)
+          queue.push({ nodeId: neighbor, derivationCount: newDerivationCount })
+        }
+      }
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
 // 计算推导统计
 const derivationStats = computed(() => {
   const postGraph = store.currentPostGraph
-  if (!postGraph || !postGraph.edges) return { edges: 0, nodes: 0 }
+  if (!postGraph || !postGraph.edges) return { edges: 0, totalEdges: 0, displayedEdges: 0 }
 
-  let edgeCount = 0
+  // 统计总边数
+  let totalEdges = 0
   for (const edge of Object.values(postGraph.edges)) {
     if (edge.type === '推导' || edge.type === '组成') {
-      edgeCount++
+      totalEdges++
     }
   }
-  return { edges: edgeCount }
+
+  // 计算实际显示的边数(基于 topN 路径)
+  const { links } = extractDerivationData()
+
+  return {
+    edges: links.length,
+    totalEdges,
+    displayedEdges: links.length
+  }
 })
 
-// 提取推导图谱数据
+// 提取推导图谱数据(对每个目标节点的路径取 topN)
 function extractDerivationData() {
   const postGraph = store.currentPostGraph
   if (!postGraph) return { nodes: [], links: [] }
 
-  const nodesMap = new Map()
-  const links = []
+  // 1. 收集所有推导边和组成边
+  const allEdges = []
+  const derivationEdges = []
 
   for (const edge of Object.values(postGraph.edges)) {
-    if (edge.type !== '推导' && edge.type !== '组成') continue
-
-    if (!nodesMap.has(edge.source)) {
-      const nodeData = postGraph.nodes[edge.source]
-      if (nodeData) {
-        nodesMap.set(edge.source, {
-          id: edge.source,
-          name: nodeData.name,
-          dimension: nodeData.dimension,
-          type: nodeData.type,
-          domain: nodeData.domain || '帖子',
-          ...nodeData
-        })
-      }
+    if (edge.type === '推导') {
+      derivationEdges.push(edge)
+      allEdges.push(edge)
+    } else if (edge.type === '组成') {
+      allEdges.push(edge)
     }
+  }
 
-    if (!nodesMap.has(edge.target)) {
-      const nodeData = postGraph.nodes[edge.target]
-      if (nodeData) {
-        nodesMap.set(edge.target, {
-          id: edge.target,
-          name: nodeData.name,
-          dimension: nodeData.dimension,
-          type: nodeData.type,
-          domain: nodeData.domain || '帖子',
-          ...nodeData
-        })
-      }
+  // 2. 按目标节点(人设节点)分组推导边,取 topN
+  const targetToEdges = new Map()
+  for (const edge of derivationEdges) {
+    if (!targetToEdges.has(edge.target)) {
+      targetToEdges.set(edge.target, [])
+    }
+    targetToEdges.get(edge.target).push(edge)
+  }
+
+  // 选出的推导边(每个目标节点取 topN)
+  const selectedDerivationEdges = []
+  for (const [targetId, edges] of targetToEdges) {
+    edges.sort((a, b) => (b.score || 0) - (a.score || 0))
+    selectedDerivationEdges.push(...edges.slice(0, initConfig.value.topN))
+  }
+
+  // 3. 从每个选中的推导边的 source 出发,反向找组成边路径
+  const startNodes = selectedDerivationEdges.map(e => e.source)
+  const compositionEdges = allEdges.filter(e => e.type === '组成')
+
+  const { nodes: compositionNodeIds, edges: compositionEdgeKeys } = findPaths(compositionEdges, {
+    startNodes,
+    direction: 'backward',
+    maxDerivationEdges: 0  // 组成边路径中不应有推导边
+  })
+
+  // 4. 收集所有涉及的节点和边
+  const includedNodes = new Set(compositionNodeIds)
+  const selectedEdges = []
+
+  // 添加推导边涉及的节点
+  for (const edge of selectedDerivationEdges) {
+    includedNodes.add(edge.source)
+    includedNodes.add(edge.target)
+    selectedEdges.push(edge)
+  }
+
+  // 添加组成边
+  for (const edge of compositionEdges) {
+    const edgeKey = `${edge.source}->${edge.target}`
+    if (compositionEdgeKeys.has(edgeKey)) {
+      selectedEdges.push(edge)
+    }
+  }
+
+  // 5. 构建最终的节点和边数据
+  const nodesMap = new Map()
+  const links = []
+
+  for (const nodeId of includedNodes) {
+    const nodeData = postGraph.nodes[nodeId]
+    if (nodeData) {
+      nodesMap.set(nodeId, {
+        id: nodeId,
+        name: nodeData.name,
+        dimension: nodeData.dimension,
+        type: nodeData.type,
+        domain: nodeData.domain || '帖子',
+        ...nodeData
+      })
     }
+  }
 
+  for (const edge of selectedEdges) {
     links.push({
       source: edge.source,
       target: edge.target,
@@ -129,6 +389,76 @@ function extractDerivationData() {
   }
 }
 
+// 基于当前显示的边计算路径(从 fromId 到 toId)
+function computeLocalPath(fromId, toId) {
+  if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
+
+  // 构建邻接表(双向)
+  const adj = new Map()
+  for (const link of linksData) {
+    const srcId = typeof link.source === 'object' ? link.source.id : link.source
+    const tgtId = typeof link.target === 'object' ? link.target.id : link.target
+
+    if (!adj.has(srcId)) adj.set(srcId, [])
+    if (!adj.has(tgtId)) adj.set(tgtId, [])
+    adj.get(srcId).push({ neighbor: tgtId, edge: link })
+    adj.get(tgtId).push({ neighbor: srcId, edge: link })
+  }
+
+  // BFS 找路径
+  const visited = new Set([fromId])
+  const parent = new Map()
+  const parentEdge = new Map()
+  const queue = [fromId]
+
+  while (queue.length > 0) {
+    const curr = queue.shift()
+    if (curr === toId) break
+
+    for (const { neighbor, edge } of (adj.get(curr) || [])) {
+      if (!visited.has(neighbor)) {
+        visited.add(neighbor)
+        parent.set(neighbor, curr)
+        parentEdge.set(neighbor, edge)
+        queue.push(neighbor)
+      }
+    }
+  }
+
+  // 回溯路径
+  const pathNodes = new Set()
+  const pathEdges = new Set()
+
+  if (visited.has(toId)) {
+    let curr = toId
+    while (curr) {
+      pathNodes.add(curr)
+      const edge = parentEdge.get(curr)
+      if (edge) {
+        const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
+        const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
+        pathEdges.add(`${srcId}->${tgtId}`)
+        pathEdges.add(`${tgtId}->${srcId}`)  // 双向
+      }
+      curr = parent.get(curr)
+    }
+  }
+
+  return { nodes: pathNodes, edges: pathEdges }
+}
+
+// 基于当前显示的边计算从某节点出发的路径(使用统一的 findPaths)
+function computeLocalHoverPath(nodeId) {
+  if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
+
+  // 使用统一的 findPaths 函数,配置来自 clickConfig
+  return findPaths(linksData, {
+    startNodes: [nodeId],
+    direction: clickConfig.value.direction,
+    maxDerivationEdges: clickConfig.value.maxDerivationEdges
+  })
+}
+
 // 显示锁定按钮
 function showLockButton(nodeEl, isLocked = false) {
   if (!nodeEl) return
@@ -232,7 +562,7 @@ function handleLockClick() {
   }
 }
 
-// hover 节点处理(使用 store 统一机制
+// hover 节点处理(使用本地路径计算,基于过滤后的边
 function handleNodeHover(event, d) {
   // 只有在有选中节点时才触发路径高亮
   if (!selectedNodeId.value) return
@@ -240,13 +570,22 @@ function handleNodeHover(event, d) {
   // 不处理选中节点自身
   if (d.id === selectedNodeId.value) return
 
-  // 计算从当前 hover 节点到激活节点的路径
-  store.computeDerivationPathTo(d.id, selectedNodeId.value, 'derivation')
+  // 使用本地路径计算(基于当前显示的边)
+  const { nodes: pathNodes, edges: pathEdges } = computeLocalPath(d.id, selectedNodeId.value)
+
+  // 更新 store 状态用于联动
+  store.hoverNodeId = d.id
+  store.hoverPathNodes = pathNodes
+  store.hoverPathEdges = pathEdges
+  store.hoverSource = 'derivation'
+  store.setHoverNode(d)
+
+  // 应用高亮
+  applyDerivationHighlight()
 
   // 显示锁定按钮
-  if (store.hoverPathNodes.size > 0) {
+  if (pathNodes.size > 0) {
     if (store.lockedHoverNodeId) {
-      // 已锁定,在锁定节点上显示按钮
       nodeSelection.each(function(nd) {
         if (nd.id === store.lockedHoverNodeId) {
           showLockButton(this, true)
@@ -265,9 +604,13 @@ function handleNodeHoverOut() {
 
   store.clearHover()
 
-  // 恢复到选中节点的路径高亮(而不是清除所有高亮
+  // 恢复到选中节点的路径高亮(使用本地路径计算
   if (selectedNodeId.value && !store.lockedHoverNodeId) {
-    store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
+    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
+    store.hoverPathNodes = pathNodes
+    store.hoverPathEdges = pathEdges
+    store.hoverSource = 'derivation'
+    applyDerivationHighlight()
   }
 
   if (store.lockedHoverNodeId) {
@@ -307,14 +650,19 @@ function handleNodeClick(event, d) {
   store.selectNode(d.id)
 }
 
-// 应用选中状态的高亮(只高亮选中节点的入边路径)
+// 应用选中状态的高亮(只高亮选中节点的入边路径,基于当前显示的边
 function applySelectedHighlight() {
   if (!selectedNodeId.value || !nodeSelection || !linkSelection) return
 
-  // 计算选中节点的入边路径并存储到本地
-  store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
-  selectedPathNodes.value = new Set(store.hoverPathNodes)
-  selectedPathEdges.value = new Set(store.hoverPathEdges)
+  // 使用本地路径计算(基于当前显示的边)
+  const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
+  selectedPathNodes.value = pathNodes
+  selectedPathEdges.value = pathEdges
+
+  // 更新 store 状态
+  store.hoverPathNodes = pathNodes
+  store.hoverPathEdges = pathEdges
+  store.hoverSource = 'derivation'
   applyDerivationHighlight()
 }
 
@@ -654,6 +1002,11 @@ watch(() => store.currentPostGraph, () => {
   nextTick(() => render())
 }, { immediate: true })
 
+// 监听初始化配置变化,重新渲染
+watch(initConfig, () => {
+  nextTick(() => render())
+}, { deep: true })
+
 // 监听面板展开状态变化
 watch(() => store.expandedPanel, () => {
   nextTick(() => {
@@ -687,11 +1040,14 @@ watch(() => store.selectedNodeId, (newId) => {
   const nodeInGraph = nodesData.find(n => n.id === newId)
 
   if (nodeInGraph) {
-    // 节点在推导图谱中,激活并显示高亮
+    // 节点在推导图谱中,激活并显示高亮(使用本地路径计算)
     selectedNodeId.value = newId
-    store.computeDerivationHoverPath(newId, 'derivation')
-    selectedPathNodes.value = new Set(store.hoverPathNodes)
-    selectedPathEdges.value = new Set(store.hoverPathEdges)
+    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(newId)
+    selectedPathNodes.value = pathNodes
+    selectedPathEdges.value = pathEdges
+    store.hoverPathNodes = pathNodes
+    store.hoverPathEdges = pathEdges
+    store.hoverSource = 'derivation'
     applyDerivationHighlight()
   } else {
     // 节点不在推导图谱中,清除选中状态

+ 54 - 9
script/visualization/src/components/PostTreeView.vue

@@ -160,7 +160,7 @@
                     v-for="edge in nodeInEdges"
                     :key="`in-${edge.source}-${edge.type}`"
                     class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
-                    @click="store.selectEdge(edge)"
+                    @click="openEdgeModal(edge)"
                   >
                     <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
                     <span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
@@ -176,7 +176,7 @@
                     v-for="edge in nodeOutEdges"
                     :key="`out-${edge.target}-${edge.type}`"
                     class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
-                    @click="store.selectEdge(edge)"
+                    @click="openEdgeModal(edge)"
                   >
                     <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
                     <span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
@@ -193,7 +193,7 @@
               </div>
               <div class="space-y-1.5 text-[11px]">
                 <template v-for="(value, key) in displayEdge" :key="key">
-                  <template v-if="value !== null && value !== undefined && value !== ''">
+                  <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
                     <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
                       <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
                       <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
@@ -221,6 +221,41 @@
         </div>
       </div>
     </div>
+
+    <!-- 边详情模态框 -->
+    <dialog v-if="modalEdge" class="modal modal-open">
+      <div class="modal-box max-w-md">
+        <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeModal">✕</button>
+        <h3 class="font-bold text-lg flex items-center gap-2">
+          <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[modalEdge.type] }"></span>
+          {{ modalEdge.type }} 边
+        </h3>
+        <div class="py-4 space-y-2 text-sm">
+          <template v-for="(value, key) in modalEdge" :key="key">
+            <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
+              <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
+                <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
+                <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
+              </div>
+              <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
+                <span class="text-base-content/50">{{ formatKey(key) }}</span>
+                <div class="pl-3 border-l border-base-content/20 space-y-1 text-xs">
+                  <template v-for="(v, k) in value" :key="k">
+                    <div v-if="k !== 'index' && v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2">
+                      <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
+                      <span class="text-right break-all">{{ formatValue(v) }}</span>
+                    </div>
+                  </template>
+                </div>
+              </div>
+            </template>
+          </template>
+        </div>
+      </div>
+      <form method="dialog" class="modal-backdrop">
+        <button @click="closeEdgeModal">close</button>
+      </form>
+    </dialog>
   </div>
 </template>
 
@@ -248,6 +283,17 @@ const svgRef = ref(null)
 const showRawData = ref(false)
 const copySuccess = ref(false)
 
+// 边详情模态框
+const modalEdge = ref(null)
+
+function openEdgeModal(edge) {
+  modalEdge.value = edge
+}
+
+function closeEdgeModal() {
+  modalEdge.value = null
+}
+
 // 复制JSON到剪贴板(详情)
 function copyJson() {
   const data = store.selectedNode || store.selectedEdge
@@ -364,7 +410,7 @@ const nodeInEdges = computed(() => {
   if (!postGraph?.edges) return []
 
   return Object.values(postGraph.edges)
-    .filter(e => e.target === nodeId && (e.type === '推导' || e.type === '组成'))
+    .filter(e => e.target === nodeId)
     .sort((a, b) => (b.score || 0) - (a.score || 0))
 })
 
@@ -378,7 +424,7 @@ const nodeOutEdges = computed(() => {
   if (!postGraph?.edges) return []
 
   return Object.values(postGraph.edges)
-    .filter(e => e.source === nodeId && (e.type === '推导' || e.type === '组成'))
+    .filter(e => e.source === nodeId)
     .sort((a, b) => (b.score || 0) - (a.score || 0))
 })
 
@@ -1182,6 +1228,7 @@ function setupHoverHandlers() {
       if (nodeId === startNodeId) return
 
       store.computeHoverPath(startNodeId, nodeId, 'post-tree')
+      store.setHoverNode(d)  // 设置 hover 节点数据用于详情显示
 
       // 显示锁定按钮(在当前hover节点上)
       if (store.hoverPathNodes.size > 0) {
@@ -1289,10 +1336,8 @@ function formatValue(value) {
 // 格式化边的值(特殊处理 source/target 显示名称)
 function formatEdgeValue(key, value) {
   if (key === 'source' || key === 'target') {
-    // 提取ID最后部分作为显示名称
-    if (typeof value === 'string' && value.includes(':')) {
-      return value.split(':').pop()
-    }
+    // 使用 getNodeName 获取节点名称
+    return getNodeName(value)
   }
   return formatValue(value)
 }

+ 38 - 3
script/visualization/src/stores/graph.js

@@ -499,7 +499,9 @@ export const useGraphStore = defineStore('graph', () => {
 
   // ==================== 统一的选中/高亮状态 ====================
   const selectedNodeId = ref(null)
+  const selectedNodeData = ref(null)  // 选中节点的完整数据
   const selectedEdgeId = ref(null)
+  const selectedEdgeData = ref(null)  // 选中边的完整数据
   const highlightedNodeIds = ref(new Set())
   // 需要聚焦的节点(用于各视图统一定位)
   const focusNodeId = ref(null)
@@ -567,6 +569,13 @@ export const useGraphStore = defineStore('graph', () => {
     selectedNodeId.value = nodeId
     selectedEdgeId.value = null  // 清除边选中
 
+    // 保存完整节点数据(处理 d3.hierarchy 节点)
+    if (typeof nodeOrId === 'object') {
+      selectedNodeData.value = nodeOrId.data || nodeOrId
+    } else {
+      selectedNodeData.value = null  // 只有ID时,依赖 getNode
+    }
+
     // 清空之前的游走结果
     walkedEdges.value = []
     postWalkedPaths.value = []
@@ -610,7 +619,9 @@ export const useGraphStore = defineStore('graph', () => {
     if (!edge) return
 
     selectedEdgeId.value = edgeId
+    selectedEdgeData.value = edge  // 保存完整边数据
     selectedNodeId.value = null  // 清除节点选中
+    selectedNodeData.value = null  // 清除节点数据
 
     // 只高亮边的两端节点
     highlightedNodeIds.value = new Set([edge.source, edge.target])
@@ -641,7 +652,9 @@ export const useGraphStore = defineStore('graph', () => {
   // 清除选中
   function clearSelection() {
     selectedNodeId.value = null
+    selectedNodeData.value = null
     selectedEdgeId.value = null
+    selectedEdgeData.value = null
     highlightedNodeIds.value = new Set()
     walkedEdges.value = []
     postWalkedPaths.value = []
@@ -658,6 +671,7 @@ export const useGraphStore = defineStore('graph', () => {
   const hoverPathEdges = ref(new Set())  // hover 路径上的边集合 "source->target"
   const hoverSource = ref(null)  // hover 来源: 'graph' | 'post-tree'
   const hoverEdgeData = ref(null)  // 当前 hover 的边数据(用于详情显示)
+  const hoverNodeData = ref(null)  // 当前 hover 的节点完整数据(用于详情显示)
 
   // 锁定栈(支持嵌套锁定)
   const lockedStack = ref([])  // [{nodeId, pathNodes, startId}, ...]
@@ -749,6 +763,7 @@ export const useGraphStore = defineStore('graph', () => {
 
   // 清除 hover 状态(恢复到栈顶锁定状态)
   function clearHover() {
+    hoverNodeData.value = null  // 清除 hover 节点数据
     if (lockedStack.value.length > 0) {
       // 恢复到栈顶锁定状态
       const top = lockedStack.value[lockedStack.value.length - 1]
@@ -774,6 +789,21 @@ export const useGraphStore = defineStore('graph', () => {
     hoverEdgeData.value = null
   }
 
+  // 设置 hover 的节点数据(用于详情显示)
+  function setHoverNode(nodeData) {
+    // 处理 d3.hierarchy 节点
+    if (nodeData?.data) {
+      hoverNodeData.value = nodeData.data
+    } else {
+      hoverNodeData.value = nodeData
+    }
+  }
+
+  // 清除 hover 的节点数据
+  function clearHoverNode() {
+    hoverNodeData.value = null
+  }
+
   // 锁定当前 hover 状态(压入栈)
   function lockCurrentHover(startId) {
     if (hoverNodeId.value && hoverPathNodes.value.size > 0) {
@@ -985,18 +1015,21 @@ export const useGraphStore = defineStore('graph', () => {
     clearAllLocked()
   }
 
-  // 计算属性:当前选中节点的数据
+  // 计算属性:当前选中节点的数据(优先使用直接设置的数据)
   const selectedNode = computed(() => {
+    if (selectedNodeData.value) return selectedNodeData.value
     return selectedNodeId.value ? getNode(selectedNodeId.value) : null
   })
 
-  // 计算属性:当前 hover 节点的数据
+  // 计算属性:当前 hover 节点的数据(优先使用直接设置的数据)
   const hoverNode = computed(() => {
+    if (hoverNodeData.value) return hoverNodeData.value
     return hoverNodeId.value ? getNode(hoverNodeId.value) : null
   })
 
-  // 计算属性:当前选中边的数据
+  // 计算属性:当前选中边的数据(优先使用直接设置的数据)
   const selectedEdge = computed(() => {
+    if (selectedEdgeData.value) return selectedEdgeData.value
     return selectedEdgeId.value ? getEdge(selectedEdgeId.value) : null
   })
 
@@ -1066,6 +1099,8 @@ export const useGraphStore = defineStore('graph', () => {
     hoverEdgeData,
     setHoverEdge,
     clearHoverEdge,
+    setHoverNode,
+    clearHoverNode,
     lockedStack,
     lockedHoverNodeId,
     lockedHoverPathNodes,