Ver código fonte

Merge remote-tracking branch 'origin/how_1202_v1' into how_1202_v2

yangxiaohui 17 horas atrás
pai
commit
5f21628814

+ 174 - 25
script/visualization/src/App.vue

@@ -51,6 +51,55 @@
           <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>
+          <li>
+            <label class="flex items-center gap-2 cursor-pointer">
+              <input type="checkbox" v-model="viewConfig.matchList" 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.detail" class="checkbox checkbox-xs" />
+              <span class="text-xs">详情</span>
+            </label>
+          </li>
+        </ul>
+      </div>
     </header>
 
     <!-- 主内容区 - 人设图谱 Tab -->
@@ -63,15 +112,17 @@
 
     <!-- 主内容区 - 帖子匹配 Tab -->
     <main v-else-if="activeTab === 'match'" class="flex flex-1 overflow-hidden">
-      <!-- 左侧:人设树 + 相关图(上下布局) -->
+      <!-- 左侧:人设树 + 相关图(固定宽度) -->
       <div
-        class="shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
+        v-if="viewConfig.personaTree || viewConfig.graph"
+        class="w-[360px] 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,32 +143,74 @@
           </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 || viewConfig.postTree"
+        class="flex flex-1 min-w-0 transition-all duration-200"
+        :class="getMiddleAreaClass()"
+      >
+        <!-- 推导图谱 -->
+        <div
+          v-if="showDerivation"
+          class="bg-base-200 border-l border-base-300 transition-all duration-200"
+          :class="getDerivationPanelClass()"
+        >
+          <DerivationView class="h-full" />
+        </div>
+        <!-- 待解构帖子 -->
+        <div
+          v-if="viewConfig.postTree"
+          class="bg-base-200 border-l border-base-300 transition-all duration-200"
+          :class="getPostTreePanelClass()"
+        >
+          <PostTreeView
+            class="h-full"
+            :show-expand="true"
+            :show-post-tree="true"
+            :show-match-list="false"
+            :show-detail="false"
+          />
+        </div>
+      </div>
+
+      <!-- 右侧:匹配列表 + 详情(固定宽度) -->
       <div
-        class="bg-base-200 flex flex-col transition-all duration-200"
-        :class="getPostTreeClass()"
+        v-if="viewConfig.matchList || viewConfig.detail"
+        class="w-72 shrink-0 bg-base-200 border-l border-base-300 flex flex-col transition-all duration-200"
+        :class="getRightPanelClass()"
       >
-        <PostTreeView class="flex-1" :show-expand="true" />
+        <PostTreeView
+          class="flex-1"
+          :show-expand="false"
+          :show-post-tree="false"
+          :show-match-list="viewConfig.matchList"
+          :show-detail="viewConfig.detail"
+          :match-list-collapsed="matchListCollapsed"
+          @update:match-list-collapsed="matchListCollapsed = $event"
+        />
       </div>
     </main>
   </div>
 </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'
 import DetailPanel from './components/DetailPanel.vue'
+import DerivationView from './components/DerivationView.vue'
 import { useGraphStore } from './stores/graph'
 import { edgeTypeColors } from './config/edgeStyle'
 
@@ -126,6 +219,29 @@ const store = useGraphStore()
 // 当前激活的 Tab
 const activeTab = ref('match')
 
+// 视图配置(默认全部勾选)
+const viewConfig = reactive({
+  personaTree: true,      // 人设树
+  graph: true,            // 相关图
+  derivation: true,       // 推导图谱
+  postTree: true,         // 待解构帖子
+  matchList: true,        // 匹配列表
+  detail: true            // 详情
+})
+
+// 匹配列表折叠状态
+const matchListCollapsed = ref(false)
+
+// 检测推导图谱是否有数据
+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)
 
@@ -140,33 +256,66 @@ function switchTab(tab) {
 
 
 // ==================== 布局类计算 ====================
+
+// 左侧面板(人设树+相关图):固定宽度,放大时占满
 function getLeftPanelClass() {
   const panel = store.expandedPanel
-  if (panel === 'post-tree') return 'w-0 opacity-0 overflow-hidden'
-  if (panel === 'persona-tree' || panel === 'graph') return 'flex-1'
-  return 'w-[360px]'
+  // 其他面板放大时,左侧隐藏
+  if (panel === 'post-tree' || panel === 'derivation') return '!w-0 opacity-0 overflow-hidden'
+  // 左侧放大时,占满
+  if (panel === 'persona-tree' || panel === 'graph') return '!flex-1 !w-auto'
+  return ''
 }
 
-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'
+  // 如果相关图不显示,人设树占满
+  if (!viewConfig.graph) return 'flex-1'
+  return 'h-1/2 shrink-0'
 }
 
-function getGraphClass() {
+// 中间区域(推导图谱+待解构帖子容器)
+function getMiddleAreaClass() {
   const panel = store.expandedPanel
-  if (panel === 'graph') return 'flex-1'
-  if (store.selectedNodeId) return 'h-[280px] shrink-0'
-  return 'h-10 shrink-0'
+  // 左侧放大时,中间隐藏
+  if (panel === 'persona-tree' || panel === 'graph') return '!w-0 !flex-none opacity-0 overflow-hidden'
+  return ''
 }
 
-function getPostTreeClass() {
+// 推导图谱面板:与待解构帖子平分
+function getDerivationPanelClass() {
   const panel = store.expandedPanel
+  // 推导图谱放大
+  if (panel === 'derivation') return 'flex-1'
+  // 待解构帖子放大,推导隐藏
+  if (panel === 'post-tree') return '!w-0 !flex-none opacity-0 overflow-hidden'
+  // 正常情况:与待解构帖子平分
+  // 如果待解构帖子不显示,推导图谱占满
+  if (!viewConfig.postTree) return 'flex-1'
+  return 'flex-1'
+}
+
+// 待解构帖子面板:与推导图谱平分
+function getPostTreePanelClass() {
+  const panel = store.expandedPanel
+  // 待解构帖子放大
   if (panel === 'post-tree') return 'flex-1'
-  if (panel === 'persona-tree' || panel === 'graph') return 'w-0 opacity-0 overflow-hidden'
+  // 推导图谱放大,待解构帖子隐藏
+  if (panel === 'derivation') return '!w-0 !flex-none opacity-0 overflow-hidden'
+  // 正常情况:与推导图谱平分
+  // 如果推导图谱不显示,待解构帖子占满
+  if (!showDerivation.value) return 'flex-1'
   return 'flex-1'
 }
+
+// 右侧面板(匹配列表+详情):固定宽度
+function getRightPanelClass() {
+  const panel = store.expandedPanel
+  // 任何面板放大时,右侧隐藏
+  if (panel !== 'default') return '!w-0 opacity-0 overflow-hidden'
+  return ''
+}
 </script>

+ 1080 - 0
script/visualization/src/components/DerivationView.vue

@@ -0,0 +1,1080 @@
+<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 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.displayedEdges }}/{{ derivationStats.totalEdges }} 条边
+        </span>
+        <span v-else class="text-base-content/40">暂无推导数据</span>
+        <!-- 放大/恢复按钮 -->
+        <button
+          v-if="store.expandedPanel !== 'derivation'"
+          @click="store.expandPanel('derivation')"
+          class="btn btn-ghost btn-xs"
+          title="放大"
+        >⤢</button>
+        <button
+          v-if="store.expandedPanel !== 'default'"
+          @click="store.resetLayout()"
+          class="btn btn-ghost btn-xs"
+          title="恢复"
+        >⊡</button>
+      </div>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
+      <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
+import { getEdgeStyle } from '../config/edgeStyle'
+import { applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+let simulation = null
+let mainG = null
+let currentZoom = null
+let nodesData = []
+let linksData = []
+
+// D3 选择集(用于联动)
+let nodeSelection = null
+let linkSelection = null
+let linkLabelSelection = null
+
+// 选中的节点(激活状态)
+const selectedNodeId = ref(null)
+// 选中节点的路径(本地状态,不受其他视图 hover 影响)
+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, totalEdges: 0, displayedEdges: 0 }
+
+  // 统计总边数
+  let totalEdges = 0
+  for (const edge of Object.values(postGraph.edges)) {
+    if (edge.type === '推导' || edge.type === '组成') {
+      totalEdges++
+    }
+  }
+
+  // 计算实际显示的边数(基于 topN 路径)
+  const { links } = extractDerivationData()
+
+  return {
+    edges: links.length,
+    totalEdges,
+    displayedEdges: links.length
+  }
+})
+
+// 提取推导图谱数据(对每个目标节点的路径取 topN)
+function extractDerivationData() {
+  const postGraph = store.currentPostGraph
+  if (!postGraph) return { nodes: [], links: [] }
+
+  // 1. 收集所有推导边和组成边
+  const allEdges = []
+  const derivationEdges = []
+
+  for (const edge of Object.values(postGraph.edges)) {
+    if (edge.type === '推导') {
+      derivationEdges.push(edge)
+      allEdges.push(edge)
+    } else if (edge.type === '组成') {
+      allEdges.push(edge)
+    }
+  }
+
+  // 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,
+      type: edge.type,
+      score: edge.score,
+      detail: edge.detail
+    })
+  }
+
+  return {
+    nodes: Array.from(nodesMap.values()),
+    links
+  }
+}
+
+// 基于当前显示的边计算路径(从 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
+
+  const node = d3.select(nodeEl)
+  const textEl = node.select('text')
+  if (textEl.empty()) return
+
+  const nodeData = node.datum()
+  const currentNodeId = nodeData?.id
+  const isThisNodeLocked = store.lockedHoverNodeId === currentNodeId
+
+  let btn = textEl.select('.lock-btn')
+  if (!btn.empty()) {
+    btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    if (!isThisNodeLocked) startBreathingAnimation(btn)
+    return
+  }
+
+  // 清除其他按钮
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').remove()
+  }
+
+  const newBtn = textEl.append('tspan')
+    .attr('class', 'lock-btn')
+    .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    .attr('font-weight', 'bold')
+    .style('cursor', 'pointer')
+    .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+    .on('click', (e) => {
+      e.stopPropagation()
+      handleLockClick()
+    })
+
+  if (!isThisNodeLocked) {
+    startBreathingAnimation(newBtn)
+  }
+}
+
+// 呼吸动画
+function startBreathingAnimation(btn) {
+  function breathe() {
+    if (btn.empty() || !btn.node()) return
+    if (store.lockedHoverNodeId) return
+    btn
+      .transition()
+      .duration(800)
+      .attr('fill', '#90cdf4')
+      .transition()
+      .duration(800)
+      .attr('fill', '#63b3ed')
+      .on('end', breathe)
+  }
+  breathe()
+}
+
+// 隐藏锁定按钮
+function hideLockButton() {
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
+  }
+}
+
+// 抖动按钮
+function shakeLockButton() {
+  d3.selectAll('.lock-btn')
+    .interrupt()
+    .attr('fill', '#fc8181')
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 0)
+    .transition().duration(200).attr('fill', '#f6ad55')
+}
+
+// 处理锁定按钮点击
+function handleLockClick() {
+  const currentHoverNodeId = store.hoverNodeId
+
+  if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
+    // 解锁
+    store.clearLockedHover()
+    if (store.lockedHoverNodeId) {
+      d3.selectAll('.lock-btn')
+        .interrupt()
+        .text(' 🔓解锁')
+        .attr('fill', '#f6ad55')
+    } else {
+      d3.selectAll('.lock-btn').interrupt().remove()
+    }
+  } else if (currentHoverNodeId) {
+    // 锁定
+    store.lockCurrentHover()
+    d3.selectAll('.lock-btn')
+      .interrupt()
+      .text(' 🔓解锁')
+      .attr('fill', '#f6ad55')
+  }
+}
+
+// hover 节点处理(使用本地路径计算,基于过滤后的边)
+function handleNodeHover(event, d) {
+  // 只有在有选中节点时才触发路径高亮
+  if (!selectedNodeId.value) return
+
+  // 不处理选中节点自身
+  if (d.id === selectedNodeId.value) return
+
+  // 使用本地路径计算(基于当前显示的边)
+  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 (pathNodes.size > 0) {
+    if (store.lockedHoverNodeId) {
+      nodeSelection.each(function(nd) {
+        if (nd.id === store.lockedHoverNodeId) {
+          showLockButton(this, true)
+        }
+      })
+    } else {
+      showLockButton(this)
+    }
+  }
+}
+
+// hover 离开处理
+function handleNodeHoverOut() {
+  // 只有在有选中节点时才处理
+  if (!selectedNodeId.value) return
+
+  store.clearHover()
+
+  // 恢复到选中节点的路径高亮(使用本地路径计算)
+  if (selectedNodeId.value && !store.lockedHoverNodeId) {
+    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
+    store.hoverPathNodes = pathNodes
+    store.hoverPathEdges = pathEdges
+    store.hoverSource = 'derivation'
+    applyDerivationHighlight()
+  }
+
+  if (store.lockedHoverNodeId) {
+    // 恢复锁定状态,在锁定节点上显示按钮
+    nodeSelection.each(function(d) {
+      if (d.id === store.lockedHoverNodeId) {
+        showLockButton(this, true)
+      }
+    })
+  } else {
+    hideLockButton()
+  }
+}
+
+// 点击节点处理(联动 store)
+function handleNodeClick(event, d) {
+  event.stopPropagation()
+
+  // 锁定状态下点击节点提醒
+  if (store.lockedHoverNodeId) {
+    shakeLockButton()
+    return
+  }
+
+  // 设置/切换选中状态
+  if (selectedNodeId.value === d.id) {
+    // 再次点击取消选中
+    selectedNodeId.value = null
+    clearHighlightState()
+  } else {
+    // 选中新节点
+    selectedNodeId.value = d.id
+    applySelectedHighlight()
+  }
+
+  // 联动 store(传完整节点对象,以便详情面板显示入边/出边)
+  store.selectNode(d)
+}
+
+// 应用选中状态的高亮(只高亮选中节点的入边路径,基于当前显示的边)
+function applySelectedHighlight() {
+  if (!selectedNodeId.value || !nodeSelection || !linkSelection) return
+
+  // 使用本地路径计算(基于当前显示的边)
+  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()
+}
+
+// 清除高亮状态
+function clearHighlightState() {
+  selectedPathNodes.value = new Set()
+  selectedPathEdges.value = new Set()
+  store.clearHover()
+  store.clearAllLocked()
+  if (nodeSelection && linkSelection) {
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    // 恢复箭头(根据边类型)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
+  }
+  hideLockButton()
+}
+
+// 点击空白处
+function handleSvgClick(event) {
+  // 锁定状态下点击空白无效
+  if (store.lockedHoverNodeId) return
+
+  if (event.target === svgRef.value) {
+    selectedNodeId.value = null
+    clearHighlightState()
+    store.clearSelection()
+  }
+}
+
+// 应用 hover 高亮效果(基于推导图谱的边)
+function applyDerivationHighlight() {
+  if (!nodeSelection || !linkSelection) return
+
+  // 优先使用 store 的 hover 路径(推导图谱内部 hover),否则使用本地状态
+  const storePathNodes = store.hoverSource === 'derivation' ? store.hoverPathNodes : new Set()
+  const storePathEdges = store.hoverSource === 'derivation' ? store.hoverPathEdges : new Set()
+  const pathNodes = storePathNodes.size > 0 ? storePathNodes : selectedPathNodes.value
+  const pathEdges = storePathEdges.size > 0 ? storePathEdges : selectedPathEdges.value
+  const lockedPathNodes = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+  const hasHighlight = pathNodes.size > 0
+
+  if (!hasHighlight) {
+    clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
+    // 恢复箭头(根据边类型)
+    linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
+    return
+  }
+
+  // 合并路径节点
+  const allPathNodes = new Set([...pathNodes])
+  if (lockedPathNodes) {
+    for (const id of lockedPathNodes) {
+      allPathNodes.add(id)
+    }
+  }
+
+  // 节点高亮
+  nodeSelection
+    .classed('dimmed', d => !allPathNodes.has(d.id))
+    .classed('locked-path', d => lockedPathNodes && lockedPathNodes.has(d.id) && !pathNodes.has(d.id))
+    .classed('selected', d => d.id === store.hoverNodeId)
+
+  // 边高亮(使用 pathEdges 判断,而不是节点)
+  linkSelection.each(function(d) {
+    const srcId = typeof d.source === 'object' ? d.source.id : d.source
+    const tgtId = typeof d.target === 'object' ? d.target.id : d.target
+    const edgeKey = `${srcId}->${tgtId}`
+
+    const inPath = pathEdges.has(edgeKey)
+    // TODO: 如果需要支持锁定路径的边,也需要存储 lockedPathEdges
+    const isLockedPath = false
+
+    d3.select(this)
+      .classed('dimmed', !inPath)
+      .classed('locked-path', isLockedPath)
+      .classed('highlighted', inPath)
+
+    // 切换箭头(根据边类型选择对应颜色)
+    const edgeType = d.type
+    let markerUrl = `url(#arrow-${edgeType})`
+    if (!inPath) {
+      markerUrl = `url(#arrow-${edgeType}-dimmed)`
+    } else if (isLockedPath) {
+      markerUrl = `url(#arrow-${edgeType}-locked)`
+    }
+    d3.select(this).attr('marker-end', markerUrl)
+  })
+
+  // 标签高亮
+  if (linkLabelSelection) {
+    linkLabelSelection.each(function(d) {
+      const srcId = typeof d.source === 'object' ? d.source.id : d.source
+      const tgtId = typeof d.target === 'object' ? d.target.id : d.target
+      const edgeKey = `${srcId}->${tgtId}`
+
+      const inPath = pathEdges.has(edgeKey)
+
+      d3.select(this)
+        .classed('dimmed', !inPath)
+        .classed('locked-path', false)
+    })
+  }
+}
+
+// 渲染力导向图
+function render() {
+  if (!svgRef.value || !containerRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+
+  const { nodes, links } = extractDerivationData()
+  nodesData = nodes
+  linksData = links
+
+  if (nodes.length === 0) return
+
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  // 定义箭头标记(为每种边类型创建对应颜色的箭头)
+  const defs = svg.append('defs')
+  const arrowColors = {
+    '推导': '#00bcd4',
+    '组成': '#8bc34a'
+  }
+
+  // 为每种边类型创建正常、置灰、锁定三种状态的箭头
+  for (const [type, color] of Object.entries(arrowColors)) {
+    // 正常箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+
+    // 置灰箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}-dimmed`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+      .attr('fill-opacity', 0.1)
+
+    // 锁定路径箭头
+    defs.append('marker')
+      .attr('id', `arrow-${type}-locked`)
+      .attr('viewBox', '0 -2 4 4')
+      .attr('refX', 10)
+      .attr('refY', 0)
+      .attr('markerWidth', 3)
+      .attr('markerHeight', 3)
+      .attr('orient', 'auto')
+      .append('path')
+      .attr('d', 'M0,-2L4,0L0,2')
+      .attr('fill', color)
+      .attr('fill-opacity', 0.4)
+  }
+
+  mainG = svg.append('g')
+
+  currentZoom = d3.zoom()
+    .scaleExtent([0.1, 4])
+    .on('zoom', (event) => {
+      mainG.attr('transform', event.transform)
+    })
+
+  svg.call(currentZoom)
+
+  simulation = d3.forceSimulation(nodes)
+    .force('link', d3.forceLink(links).id(d => d.id).distance(100))
+    .force('charge', d3.forceManyBody().strength(-200))
+    .force('center', d3.forceCenter(width / 2, height / 2))
+    .force('collision', d3.forceCollide().radius(30))
+
+  // 绘制边
+  linkSelection = mainG.append('g')
+    .attr('class', 'links')
+    .selectAll('line')
+    .data(links)
+    .join('line')
+    .attr('class', 'graph-link')
+    .attr('stroke', d => getEdgeStyle(d).color)
+    .attr('stroke-opacity', d => getEdgeStyle(d).opacity)
+    .attr('stroke-width', d => getEdgeStyle(d).strokeWidth)
+    .attr('stroke-dasharray', d => getEdgeStyle(d).strokeDasharray)
+    .attr('marker-end', d => `url(#arrow-${d.type})`)
+    .style('cursor', 'pointer')
+    .on('mouseenter', (e, d) => {
+      // 激活状态下 hover 边,显示边详情
+      if (selectedNodeId.value) {
+        store.setHoverEdge(d)
+      }
+    })
+    .on('mouseleave', () => {
+      store.clearHoverEdge()
+    })
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      store.selectEdge(d)
+    })
+
+  // 边标签
+  linkLabelSelection = mainG.append('g')
+    .attr('class', 'link-labels')
+    .selectAll('g')
+    .data(links.filter(d => d.score > 0))
+    .join('g')
+    .attr('class', 'graph-link-label')
+
+  linkLabelSelection.append('rect')
+    .attr('x', -14)
+    .attr('y', -6)
+    .attr('width', 28)
+    .attr('height', 12)
+    .attr('rx', 2)
+    .attr('fill', '#1d232a')
+    .attr('opacity', 0.9)
+
+  linkLabelSelection.append('text')
+    .attr('text-anchor', 'middle')
+    .attr('dy', '0.35em')
+    .attr('fill', d => getEdgeStyle(d).color)
+    .attr('font-size', '8px')
+    .text(d => d.score.toFixed(2))
+
+  // 绘制节点
+  nodeSelection = mainG.append('g')
+    .attr('class', 'nodes')
+    .selectAll('g')
+    .data(nodes)
+    .join('g')
+    .attr('class', 'graph-node')
+    .style('cursor', 'pointer')
+    .on('mouseenter', handleNodeHover)
+    .on('mouseleave', handleNodeHoverOut)
+    .on('click', handleNodeClick)
+    .call(d3.drag()
+      .on('start', dragstarted)
+      .on('drag', dragged)
+      .on('end', dragended))
+
+  // 节点形状
+  nodeSelection.each(function(d) {
+    const style = getNodeStyle(d)
+    applyNodeShape(d3.select(this), style)
+  })
+
+  // 节点标签(组合节点不显示名称)
+  nodeSelection.append('text')
+    .attr('dy', 20)
+    .attr('text-anchor', 'middle')
+    .text(d => {
+      // 组合节点(type为"组合")不显示名称
+      if (d.type === '组合') return ''
+      const name = d.name || ''
+      return name.length > 8 ? name.slice(0, 8) + '…' : name
+    })
+
+  // 更新位置
+  simulation.on('tick', () => {
+    linkSelection
+      .attr('x1', d => d.source.x)
+      .attr('y1', d => d.source.y)
+      .attr('x2', d => d.target.x)
+      .attr('y2', d => d.target.y)
+
+    linkLabelSelection.attr('transform', d => {
+      const x = (d.source.x + d.target.x) / 2
+      const y = (d.source.y + d.target.y) / 2 - 10
+      return `translate(${x},${y})`
+    })
+
+    nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
+  })
+
+  nextTick(() => {
+    setTimeout(() => fitToView(), 500)
+  })
+}
+
+// 适应视图
+function fitToView() {
+  if (!mainG || !svgRef.value || !containerRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  const width = containerRef.value.clientWidth
+  const height = containerRef.value.clientHeight
+
+  try {
+    const bounds = mainG.node().getBBox()
+    if (bounds.width === 0 || bounds.height === 0) return
+
+    const scale = Math.min(
+      width / (bounds.width + 80),
+      height / (bounds.height + 80),
+      1.5
+    )
+    const tx = (width - bounds.width * scale) / 2 - bounds.x * scale
+    const ty = (height - bounds.height * scale) / 2 - bounds.y * scale
+    svg.call(currentZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale))
+  } catch (e) {
+    // getBBox 可能在元素不可见时失败
+  }
+}
+
+// 拖拽函数
+function dragstarted(event, d) {
+  if (!event.active) simulation.alphaTarget(0.3).restart()
+  d.fx = d.x
+  d.fy = d.y
+}
+
+function dragged(event, d) {
+  d.fx = event.x
+  d.fy = event.y
+}
+
+function dragended(event, d) {
+  if (!event.active) simulation.alphaTarget(0)
+  d.fx = null
+  d.fy = null
+}
+
+// 监听帖子切换
+watch(() => store.currentPostGraph, () => {
+  nextTick(() => render())
+}, { immediate: true })
+
+// 监听初始化配置变化,重新渲染
+watch(initConfig, () => {
+  nextTick(() => render())
+}, { deep: true })
+
+// 监听面板展开状态变化
+watch(() => store.expandedPanel, () => {
+  nextTick(() => {
+    if (containerRef.value) {
+      setTimeout(() => fitToView(), 300)
+    }
+  })
+})
+
+// 监听 hover 状态变化(联动)
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hoverSource], () => {
+  if (!nodeSelection || !linkSelection) return
+
+  // 只处理来自推导图谱的 hover
+  if (store.hoverSource === 'derivation') {
+    applyDerivationHighlight()
+  } else if (store.hoverSource !== 'derivation' && selectedNodeId.value) {
+    // 其他视图的 hover 不影响推导图谱的激活状态,保持当前激活节点的高亮
+    // 不做任何处理,保持现有高亮
+  } else if (store.hoverPathNodes.size === 0 && !selectedNodeId.value) {
+    // 没有激活节点且 hover 清空时,清除高亮
+    applyDerivationHighlight()
+  }
+})
+
+// 监听外部节点选中(联动:PostTreeView 点击节点时同步激活推导图谱)
+watch(() => store.selectedNodeId, (newId) => {
+  if (!nodeSelection || !linkSelection) return
+
+  // 检查该节点是否在推导图谱中
+  const nodeInGraph = nodesData.find(n => n.id === newId)
+
+  if (nodeInGraph) {
+    // 节点在推导图谱中,激活并显示高亮(使用本地路径计算)
+    selectedNodeId.value = newId
+    const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(newId)
+    selectedPathNodes.value = pathNodes
+    selectedPathEdges.value = pathEdges
+    store.hoverPathNodes = pathNodes
+    store.hoverPathEdges = pathEdges
+    store.hoverSource = 'derivation'
+    applyDerivationHighlight()
+  } else {
+    // 节点不在推导图谱中,清除选中状态
+    selectedNodeId.value = null
+    selectedPathNodes.value = new Set()
+    selectedPathEdges.value = new Set()
+    clearHighlightState()
+  }
+})
+
+// 窗口大小变化时适应视图
+let resizeObserver = null
+onMounted(() => {
+  resizeObserver = new ResizeObserver(() => {
+    if (simulation) {
+      fitToView()
+    }
+  })
+  if (containerRef.value) {
+    resizeObserver.observe(containerRef.value)
+  }
+})
+
+onUnmounted(() => {
+  if (simulation) simulation.stop()
+  if (resizeObserver) resizeObserver.disconnect()
+})
+</script>

+ 271 - 21
script/visualization/src/components/PostTreeView.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="flex h-full">
-    <!-- 左侧主区域 -->
-    <div class="flex flex-col flex-1 min-w-0">
+    <!-- 左侧主区域:待解构帖子 -->
+    <div v-if="showPostTree" class="flex flex-col flex-1 min-w-0">
       <!-- 头部 -->
       <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
         <span>待解构帖子</span>
@@ -48,14 +48,28 @@
     </div>
 
     <!-- 右侧:匹配列表 + 详情 -->
-    <div class="w-72 shrink-0 bg-base-200 border-l border-base-300 flex flex-col text-xs">
-      <!-- 匹配列表(上半部分) -->
-      <div class="flex flex-col h-1/2 border-b border-base-300">
-        <div class="px-3 py-2 bg-base-300 text-base-content/60 flex items-center justify-between shrink-0">
-          <span>匹配列表</span>
+    <div
+      v-if="showMatchList || showDetail"
+      class="shrink-0 bg-base-200 border-l border-base-300 flex flex-col text-xs transition-all duration-200"
+      :class="showPostTree ? 'w-72' : 'flex-1'"
+    >
+      <!-- 匹配列表(1/3高度) -->
+      <div
+        v-if="showMatchList"
+        class="flex flex-col border-b border-base-300 transition-all duration-200"
+        :class="matchListCollapsed ? 'h-8' : (showDetail ? 'h-1/3' : 'flex-1')"
+      >
+        <div
+          class="px-3 py-2 bg-base-300 text-base-content/60 flex items-center justify-between shrink-0 cursor-pointer"
+          @click="emit('update:matchListCollapsed', !matchListCollapsed)"
+        >
+          <div class="flex items-center gap-2">
+            <span class="transition-transform" :class="{ '-rotate-90': matchListCollapsed }">▼</span>
+            <span>匹配列表</span>
+          </div>
           <span class="text-base-content/40">{{ sortedMatchEdges.length }}</span>
         </div>
-        <div class="flex-1 overflow-y-auto">
+        <div v-show="!matchListCollapsed" class="flex-1 overflow-y-auto">
           <div
             v-for="(edge, idx) in sortedMatchEdges"
             :key="idx"
@@ -89,8 +103,8 @@
         </div>
       </div>
 
-      <!-- 详情(下半部分) -->
-      <div class="flex-1 flex flex-col min-h-0">
+      <!-- 详情 -->
+      <div v-if="showDetail" class="flex-1 flex flex-col min-h-0">
         <div class="px-3 py-2 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between">
           <span>详情</span>
           <label class="swap swap-flip text-[10px]">
@@ -133,7 +147,7 @@
               </div>
               <div class="space-y-1.5 text-[11px]">
                 <template v-for="(value, key) in displayNode" :key="key">
-                  <template v-if="key !== 'name' && value !== null && value !== undefined && value !== ''">
+                  <template v-if="!hiddenNodeFields.includes(key) && key !== 'name' && 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">{{ formatValue(value) }}</span>
@@ -152,16 +166,54 @@
                   </template>
                 </template>
               </div>
+              <!-- 入边列表 -->
+              <div v-if="nodeInEdges.length > 0" class="mt-3 pt-2 border-t border-base-content/10">
+                <div
+                  class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
+                  @click="openEdgeListModal('in', nodeInEdges)"
+                >入边 ({{ nodeInEdges.length }}) ›</div>
+                <div class="space-y-1 max-h-24 overflow-y-auto">
+                  <div
+                    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="openEdgeModal(edge)"
+                  >
+                    <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
+                    <span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
+                    <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
+                  </div>
+                </div>
+              </div>
+              <!-- 出边列表 -->
+              <div v-if="nodeOutEdges.length > 0" class="mt-2 pt-2 border-t border-base-content/10">
+                <div
+                  class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
+                  @click="openEdgeListModal('out', nodeOutEdges)"
+                >出边 ({{ nodeOutEdges.length }}) ›</div>
+                <div class="space-y-1 max-h-24 overflow-y-auto">
+                  <div
+                    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="openEdgeModal(edge)"
+                  >
+                    <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
+                    <span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
+                    <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
+                  </div>
+                </div>
+              </div>
             </template>
-            <!-- 边详情 -->
-            <template v-else-if="store.selectedEdge">
+            <!-- 边详情(hover 优先于 selected) -->
+            <template v-else-if="displayEdge">
               <div class="flex items-center gap-2">
-                <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: selectedEdgeColor }"></span>
-                <span class="text-secondary font-medium">{{ store.selectedEdge.type }} 边</span>
+                <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
+                <span class="text-secondary font-medium">{{ displayEdge.type }} 边</span>
               </div>
               <div class="space-y-1.5 text-[11px]">
-                <template v-for="(value, key) in store.selectedEdge" :key="key">
-                  <template v-if="value !== null && value !== undefined && value !== ''">
+                <template v-for="(value, key) in displayEdge" :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>
@@ -189,6 +241,91 @@
         </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>
+
+    <!-- 边列表模态框 -->
+    <dialog v-if="edgeListModal.show" class="modal modal-open">
+      <div class="modal-box max-w-2xl max-h-[80vh]">
+        <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="closeEdgeListModal">✕</button>
+        <h3 class="font-bold text-lg">{{ edgeListModal.type === 'in' ? '入边' : '出边' }}列表 ({{ edgeListModal.edges.length }})</h3>
+        <div class="py-4 overflow-y-auto max-h-[60vh]">
+          <div class="space-y-3">
+            <div
+              v-for="(edge, idx) in edgeListModal.edges"
+              :key="idx"
+              class="p-3 bg-base-200 rounded-lg"
+            >
+              <div class="flex items-center gap-2 mb-2 pb-2 border-b border-base-300">
+                <span class="w-4 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
+                <span class="font-medium">{{ edge.type }}</span>
+                <span class="text-base-content/50 text-sm">
+                  {{ edgeListModal.type === 'in' ? getNodeName(edge.source) : getNodeName(edge.target) }}
+                </span>
+                <span v-if="edge.score != null" class="ml-auto text-primary">{{ edge.score.toFixed(2) }}</span>
+              </div>
+              <div class="space-y-1 text-sm">
+                <template v-for="(value, key) in edge" :key="key">
+                  <template v-if="!hiddenNodeFields.includes(key) && key !== 'type' && 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="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>
+          </div>
+        </div>
+      </div>
+      <form method="dialog" class="modal-backdrop">
+        <button @click="closeEdgeListModal">close</button>
+      </form>
+    </dialog>
   </div>
 </template>
 
@@ -204,9 +341,30 @@ const props = defineProps({
   showExpand: {
     type: Boolean,
     default: false
+  },
+  showPostTree: {
+    type: Boolean,
+    default: true
+  },
+  showMatchList: {
+    type: Boolean,
+    default: true
+  },
+  showDetail: {
+    type: Boolean,
+    default: true
+  },
+  matchListCollapsed: {
+    type: Boolean,
+    default: false
   }
 })
 
+const emit = defineEmits(['update:matchListCollapsed'])
+
+// 不需要显示的节点字段
+const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']
+
 const store = useGraphStore()
 
 const containerRef = ref(null)
@@ -216,6 +374,40 @@ 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
+}
+
+// 边列表模态框
+const edgeListModal = ref({
+  show: false,
+  type: 'in',  // 'in' or 'out'
+  edges: []
+})
+
+function openEdgeListModal(type, edges) {
+  edgeListModal.value = {
+    show: true,
+    type,
+    edges
+  }
+}
+
+function closeEdgeListModal() {
+  edgeListModal.value = {
+    show: false,
+    type: 'in',
+    edges: []
+  }
+}
+
 // 复制JSON到剪贴板(详情)
 function copyJson() {
   const data = store.selectedNode || store.selectedEdge
@@ -311,6 +503,65 @@ const selectedEdgeColor = computed(() => {
   return edgeTypeColors[store.selectedEdge.type] || '#888'
 })
 
+// 显示的边(hover 优先于 selected)
+const displayEdge = computed(() => {
+  return store.hoverEdgeData || store.selectedEdge
+})
+
+// 显示的边颜色
+const displayEdgeColor = computed(() => {
+  if (!displayEdge.value) return '#888'
+  return edgeTypeColors[displayEdge.value.type] || '#888'
+})
+
+// 节点的入边列表(按分数降序)
+const nodeInEdges = computed(() => {
+  if (!displayNode.value) return []
+  const nodeId = displayNode.value.id || displayNode.value.data?.id
+  if (!nodeId) return []
+
+  const postGraph = store.currentPostGraph
+  if (!postGraph?.edges) return []
+
+  return Object.values(postGraph.edges)
+    .filter(e => e.target === nodeId)
+    .sort((a, b) => (b.score || 0) - (a.score || 0))
+})
+
+// 节点的出边列表(按分数降序)
+const nodeOutEdges = computed(() => {
+  if (!displayNode.value) return []
+  const nodeId = displayNode.value.id || displayNode.value.data?.id
+  if (!nodeId) return []
+
+  const postGraph = store.currentPostGraph
+  if (!postGraph?.edges) return []
+
+  return Object.values(postGraph.edges)
+    .filter(e => e.source === nodeId)
+    .sort((a, b) => (b.score || 0) - (a.score || 0))
+})
+
+// 获取节点名称(根据节点ID)
+function getNodeName(nodeId) {
+  if (!nodeId) return '-'
+
+  // 先从当前帖子图中查找
+  const postGraph = store.currentPostGraph
+  if (postGraph?.nodes?.[nodeId]) {
+    return postGraph.nodes[nodeId].name || nodeId.split(':').pop()
+  }
+
+  // 再从人设节点中查找
+  const personaNode = store.getNode(nodeId)
+  if (personaNode) {
+    return personaNode.name || nodeId.split(':').pop()
+  }
+
+  // 回退到从ID提取名称
+  return nodeId.split(':').pop() || nodeId
+}
+
 // 获取边ID
 function getEdgeId(edge) {
   return `${edge.source}|${edge.type}|${edge.target}`
@@ -1091,6 +1342,7 @@ function setupHoverHandlers() {
       if (nodeId === startNodeId) return
 
       store.computeHoverPath(startNodeId, nodeId, 'post-tree')
+      store.setHoverNode(d)  // 设置 hover 节点数据用于详情显示
 
       // 显示锁定按钮(在当前hover节点上)
       if (store.hoverPathNodes.size > 0) {
@@ -1198,10 +1450,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)
 }

+ 18 - 3
script/visualization/src/config/edgeStyle.js

@@ -6,7 +6,9 @@ export const edgeTypeColors = {
   '包含': '#3498db',
   '标签共现': '#2ecc71',
   '分类共现': '#f39c12',
-  '匹配': '#e94560'
+  '匹配': '#e94560',
+  '推导': '#00bcd4',  // 青色 - 推导关系
+  '组成': '#8bc34a'   // 浅绿色 - 组合成员
 }
 
 // 获取边样式(统一入口)
@@ -22,11 +24,24 @@ export function getEdgeStyle(edge) {
     strokeDasharray = (score >= 0.8) ? 'none' : '4,2'
   }
 
+  // 推导边:使用箭头,根据分数调整透明度
+  // 组成边:实线样式(和推导边一样)
+
+  // 不同边类型的透明度计算
+  let opacity = 0.3
+  if (type === '匹配') {
+    opacity = Math.max(0.3, score * 0.7)
+  } else if (type === '推导') {
+    opacity = Math.max(0.4, score * 0.8)
+  } else if (type === '组成') {
+    opacity = 0.6
+  }
+
   return {
     color,
-    strokeWidth: 1.5,
+    strokeWidth: type === '推导' ? 2 : 1.5,
     strokeDasharray,
-    opacity: type === '匹配' ? Math.max(0.3, score * 0.7) : 0.3,
+    opacity,
     scoreText: score !== undefined ? score.toFixed(2) : ''
   }
 }

+ 221 - 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 = []
@@ -655,7 +668,10 @@ export const useGraphStore = defineStore('graph', () => {
   // ==================== Hover 状态(左右联动) ====================
   const hoverNodeId = ref(null)  // 当前 hover 的节点 ID
   const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
+  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}, ...]
@@ -747,25 +763,54 @@ 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]
       hoverNodeId.value = top.nodeId
       hoverPathNodes.value = new Set(top.pathNodes)
+      hoverPathEdges.value = new Set(top.pathEdges || [])
       hoverSource.value = null
     } else {
       hoverNodeId.value = null
       hoverPathNodes.value = new Set()
+      hoverPathEdges.value = new Set()
       hoverSource.value = null
     }
   }
 
+  // 设置 hover 的边数据(用于详情显示)
+  function setHoverEdge(edgeData) {
+    hoverEdgeData.value = edgeData
+  }
+
+  // 清除 hover 的边数据
+  function clearHoverEdge() {
+    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) {
       lockedStack.value.push({
         nodeId: hoverNodeId.value,
         pathNodes: new Set(hoverPathNodes.value),
+        pathEdges: new Set(hoverPathEdges.value),
         startId: lockedHoverStartId.value || startId  // 继承之前的起点
       })
     }
@@ -781,6 +826,7 @@ export const useGraphStore = defineStore('graph', () => {
       // 栈空,完全清除
       hoverNodeId.value = null
       hoverPathNodes.value = new Set()
+      hoverPathEdges.value = new Set()
       hoverSource.value = null
     }
   }
@@ -790,9 +836,170 @@ export const useGraphStore = defineStore('graph', () => {
     lockedStack.value = []
     hoverNodeId.value = null
     hoverPathNodes.value = new Set()
+    hoverPathEdges.value = new Set()
     hoverSource.value = null
   }
 
+  // ==================== 推导图谱 Hover ====================
+  // 计算推导图谱的入边路径(从 targetId 回溯到非组合节点)- 用于激活节点时显示完整入边树
+  function computeDerivationHoverPath(targetId, source = 'derivation') {
+    if (!targetId) {
+      clearHover()
+      return
+    }
+
+    const postGraph = currentPostGraph.value
+    if (!postGraph) return
+
+    // 构建入边索引(只考虑推导边和组成边)
+    const inEdges = new Map()
+    for (const edge of Object.values(postGraph.edges || {})) {
+      if (edge.type !== '推导' && edge.type !== '组成') continue
+      if (!inEdges.has(edge.target)) {
+        inEdges.set(edge.target, [])
+      }
+      inEdges.get(edge.target).push(edge)
+    }
+
+    // 构建节点索引
+    const nodeDataMap = new Map()
+    for (const [nodeId, node] of Object.entries(postGraph.nodes || {})) {
+      nodeDataMap.set(nodeId, node)
+    }
+
+    // BFS 回溯,遇到非组合节点停止
+    const pathNodes = new Set([targetId])
+    const pathEdges = new Set()
+    const queue = [targetId]
+    const visited = new Set([targetId])
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()
+      const nodeData = nodeDataMap.get(nodeId)
+
+      // 如果当前节点是非组合节点且不是起始节点,不再继续回溯
+      if (nodeId !== targetId && nodeData && nodeData.type !== '组合') {
+        continue
+      }
+
+      const incoming = inEdges.get(nodeId) || []
+      for (const edge of incoming) {
+        pathEdges.add(`${edge.source}->${edge.target}`)
+        pathNodes.add(edge.source)
+
+        if (!visited.has(edge.source)) {
+          visited.add(edge.source)
+          queue.push(edge.source)
+        }
+      }
+    }
+
+    if (pathNodes.size > 0) {
+      hoverNodeId.value = targetId
+      hoverPathNodes.value = pathNodes
+      hoverPathEdges.value = pathEdges
+      hoverSource.value = source
+    }
+  }
+
+  // 计算从 fromId 到 toId 的路径(沿出边方向)- 用于 hover 时显示到激活节点的路径
+  // 路径应该包含至少一个推导边,如果直接路径没有推导边,从 fromId 继续往前找
+  // 返回路径上的节点和边(边用 "source->target" 格式存储)
+  function computeDerivationPathTo(fromId, toId, source = 'derivation') {
+    if (!fromId || !toId) {
+      clearHover()
+      return
+    }
+
+    const postGraph = currentPostGraph.value
+    if (!postGraph) return
+
+    // 构建出边索引和入边索引
+    const outEdges = new Map()
+    const inEdges = new Map()
+    for (const edge of Object.values(postGraph.edges || {})) {
+      if (edge.type !== '推导' && edge.type !== '组成') continue
+      if (!outEdges.has(edge.source)) {
+        outEdges.set(edge.source, [])
+      }
+      outEdges.get(edge.source).push(edge)
+      if (!inEdges.has(edge.target)) {
+        inEdges.set(edge.target, [])
+      }
+      inEdges.get(edge.target).push(edge)
+    }
+
+    // BFS 从 fromId 沿出边方向查找到 toId 的路径,同时记录边
+    const parent = new Map()  // nodeId -> { parentId, edgeType }
+    const queue = [fromId]
+    const visited = new Set([fromId])
+
+    while (queue.length > 0) {
+      const nodeId = queue.shift()
+      if (nodeId === toId) break
+
+      const outgoing = outEdges.get(nodeId) || []
+      for (const edge of outgoing) {
+        if (!visited.has(edge.target)) {
+          visited.add(edge.target)
+          parent.set(edge.target, { parentId: nodeId, edgeType: edge.type })
+          queue.push(edge.target)
+        }
+      }
+    }
+
+    // 回溯路径,记录节点和边
+    const pathNodes = new Set()
+    const pathEdges = new Set()  // 存储路径上的边 "source->target"
+    let hasDerivationEdge = false
+    if (visited.has(toId)) {
+      let curr = toId
+      while (curr) {
+        pathNodes.add(curr)
+        const parentInfo = parent.get(curr)
+        if (parentInfo) {
+          // 记录路径上的边
+          pathEdges.add(`${parentInfo.parentId}->${curr}`)
+          if (parentInfo.edgeType === '推导') {
+            hasDerivationEdge = true
+          }
+          curr = parentInfo.parentId
+        } else {
+          curr = null
+        }
+      }
+    }
+
+    // 如果没有推导边,从 fromId 沿入边方向继续往前找,直到找到推导边
+    if (!hasDerivationEdge && pathNodes.size > 0) {
+      const queue2 = [fromId]
+      const visited2 = new Set([fromId])
+      while (queue2.length > 0 && !hasDerivationEdge) {
+        const nodeId = queue2.shift()
+        const incoming = inEdges.get(nodeId) || []
+        for (const edge of incoming) {
+          pathNodes.add(edge.source)
+          pathEdges.add(`${edge.source}->${nodeId}`)
+          if (edge.type === '推导') {
+            hasDerivationEdge = true
+            break
+          }
+          if (!visited2.has(edge.source)) {
+            visited2.add(edge.source)
+            queue2.push(edge.source)
+          }
+        }
+      }
+    }
+
+    if (pathNodes.size > 0) {
+      hoverNodeId.value = fromId
+      hoverPathNodes.value = pathNodes
+      hoverPathEdges.value = pathEdges
+      hoverSource.value = source
+    }
+  }
+
   // 清除游走结果(双击空白时调用)
   function clearWalk() {
     selectedNodeId.value = null
@@ -808,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
   })
 
@@ -884,12 +1094,20 @@ export const useGraphStore = defineStore('graph', () => {
     // Hover 联动
     hoverNodeId,
     hoverPathNodes,
+    hoverPathEdges,
     hoverSource,
+    hoverEdgeData,
+    setHoverEdge,
+    clearHoverEdge,
+    setHoverNode,
+    clearHoverNode,
     lockedStack,
     lockedHoverNodeId,
     lockedHoverPathNodes,
     lockedHoverStartId,
     computeHoverPath,
+    computeDerivationHoverPath,
+    computeDerivationPathTo,
     clearHover,
     lockCurrentHover,
     clearLockedHover,

+ 71 - 8
script/visualization/src/style.css

@@ -88,9 +88,18 @@
   }
 
   .tree-node.highlighted circle,
-  .tree-node.highlighted rect {
-    stroke: #2ecc71;
-    stroke-width: 2;
+  .tree-node.highlighted rect,
+  .match-node.highlighted circle,
+  .match-node.highlighted rect,
+  .walked-node.highlighted circle,
+  .walked-node.highlighted rect {
+    filter: brightness(1.3);
+  }
+
+  .tree-node.highlighted text,
+  .match-node.highlighted text,
+  .walked-node.highlighted text {
+    fill: oklch(var(--p));
   }
 
   /* ========== 统一的置灰样式 ========== */
@@ -203,12 +212,15 @@
   }
 
   .graph-node:hover circle,
-  .graph-node:hover rect {
-    filter: brightness(1.2);
+  .graph-node:hover rect,
+  .graph-node.highlighted:hover circle,
+  .graph-node.highlighted:hover rect {
+    filter: brightness(1.4) !important;
   }
 
-  .graph-node:hover text {
-    fill: oklch(var(--p));
+  .graph-node:hover text,
+  .graph-node.highlighted:hover text {
+    fill: oklch(var(--p)) !important;
   }
 
   .graph-node text {
@@ -239,7 +251,8 @@
   /* 分数标签不阻挡边的点击 */
   .match-score,
   .walked-score,
-  .graph-link-label {
+  .graph-link-label,
+  .derivation-score {
     pointer-events: none;
   }
 
@@ -247,4 +260,54 @@
   .graph-link-label.dimmed {
     opacity: 0.15;
   }
+
+  /* ========== 推导层样式 ========== */
+  /* 推导边 */
+  .derivation-link {
+    fill: none;
+    transition: stroke-opacity 0.2s;
+  }
+
+  .derivation-link:hover {
+    stroke-opacity: 1 !important;
+    stroke-width: 3 !important;
+  }
+
+  .derivation-link.highlighted {
+    stroke-opacity: 0.8 !important;
+    stroke-width: 3 !important;
+  }
+
+  /* 组合节点 */
+  .combo-node {
+    cursor: pointer;
+  }
+
+  .combo-node polygon {
+    transition: all 0.2s;
+  }
+
+  .combo-node:hover polygon {
+    filter: brightness(1.2);
+  }
+
+  .combo-node:hover text {
+    fill: oklch(var(--p));
+  }
+
+  .combo-node text {
+    @apply text-xs;
+    fill: oklch(var(--bc));
+  }
+
+  /* 推导节点高亮 */
+  .derivation-node.highlighted circle,
+  .combo-node.highlighted polygon {
+    filter: brightness(1.3);
+  }
+
+  .derivation-node.highlighted text,
+  .combo-node.highlighted text {
+    fill: oklch(var(--p));
+  }
 }

+ 13 - 3
script/visualization/src/utils/highlight.js

@@ -132,10 +132,20 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
  * 清除 hover 效果
  */
 export function clearHoverHighlight(nodeSelection, linkSelection, labelSelection) {
-  nodeSelection.classed('dimmed', false).classed('locked-path', false)
-  linkSelection.classed('dimmed', false).classed('locked-path', false)
+  nodeSelection
+    .classed('dimmed', false)
+    .classed('locked-path', false)
+    .classed('highlighted', false)
+    .classed('selected', false)
+  linkSelection
+    .classed('dimmed', false)
+    .classed('locked-path', false)
+    .classed('highlighted', false)
   if (labelSelection) {
-    labelSelection.classed('dimmed', false).classed('locked-path', false)
+    labelSelection
+      .classed('dimmed', false)
+      .classed('locked-path', false)
+      .classed('highlighted', false)
   }
 }
 

+ 41 - 0
script/visualization/vite.config.js

@@ -20,15 +20,56 @@ console.log('人设节点数:', Object.keys(personaGraphData.nodes || {}).length
 const postGraphDir = process.env.POST_GRAPH_DIR
 let postGraphList = []
 
+// 推导图谱目录(与 post_graph 同级的 node_origin_analysis 目录)
+const derivationGraphDir = postGraphDir ? path.join(path.dirname(postGraphDir), 'node_origin_analysis') : null
+
 if (postGraphDir && fs.existsSync(postGraphDir)) {
   console.log('帖子图谱目录:', postGraphDir)
   const files = fs.readdirSync(postGraphDir).filter(f => f.endsWith('_帖子图谱.json'))
   console.log('帖子图谱文件数:', files.length)
 
+  // 读取推导图谱(如果存在)
+  const derivationGraphs = new Map()
+  if (derivationGraphDir && fs.existsSync(derivationGraphDir)) {
+    console.log('推导图谱目录:', derivationGraphDir)
+    const derivationFiles = fs.readdirSync(derivationGraphDir).filter(f => f.endsWith('_推导图谱.json'))
+    console.log('推导图谱文件数:', derivationFiles.length)
+    for (const file of derivationFiles) {
+      const filePath = path.join(derivationGraphDir, file)
+      const derivationData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+      const postId = derivationData.meta?.postId
+      if (postId) {
+        derivationGraphs.set(postId, derivationData)
+      }
+    }
+  }
+
   // 读取所有帖子图谱
   for (const file of files) {
     const filePath = path.join(postGraphDir, file)
     const postData = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
+
+    // 合并推导图谱(如果存在)
+    const postId = postData.meta?.postId
+    if (postId && derivationGraphs.has(postId)) {
+      const derivation = derivationGraphs.get(postId)
+      console.log(`合并推导图谱到帖子 ${postId}:`, derivation.meta?.stats)
+
+      // 合并节点(组合节点)
+      for (const [nodeId, node] of Object.entries(derivation.nodes || {})) {
+        if (!postData.nodes[nodeId]) {
+          postData.nodes[nodeId] = node
+        }
+      }
+
+      // 合并边(推导边和组成边)
+      for (const [edgeId, edge] of Object.entries(derivation.edges || {})) {
+        if (!postData.edges[edgeId]) {
+          postData.edges[edgeId] = edge
+        }
+      }
+    }
+
     postGraphList.push(postData)
   }