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

feat: 统一节点样式和高亮逻辑,添加复制JSON功能

- 节点样式统一:帖子域空心,人设域实心(通过 applyNodeShape 复用)
- 添加复制 JSON 按钮:人设树、相关图、待解构帖子、匹配列表、详情面板
- 匹配列表优化:单行显示(节点1 [分数] 节点2),宽度加宽到 w-72
- 聚焦逻辑统一:store 中添加 focusNodeId/focusEdgeEndpoints,各视图 watch 执行定位
- 高亮逻辑统一:GraphView 也使用 applyHighlight,点击边时两端节点都高亮
- 点击节点只高亮被点击节点,点击边高亮边的两端节点
- 步数限制从 5 改为 10

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 день тому
батько
коміт
33364d2631

+ 13 - 14
script/visualization/src/App.vue

@@ -2,25 +2,23 @@
   <div data-theme="dark" class="flex flex-col h-screen">
     <!-- 顶部栏 -->
     <header class="navbar bg-base-200 min-h-0 px-4 py-2 shrink-0">
-      <h1 class="text-sm font-medium text-primary">人设图谱</h1>
-
       <!-- Tab 切换 -->
-      <div class="tabs tabs-boxed ml-4 bg-base-300">
+      <div class="tabs tabs-boxed bg-base-300">
         <a
           class="tab tab-sm"
           :class="{ 'tab-active': activeTab === 'persona' }"
           @click="switchTab('persona')"
-        >人设图谱</a>
+        >人设</a>
         <a
           class="tab tab-sm"
           :class="{ 'tab-active': activeTab === 'match' }"
           @click="switchTab('match')"
-        >帖子匹配</a>
+        >待解构帖子</a>
       </div>
 
-      <!-- 节点颜色图例 -->
+      <!-- 图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-6 border-l border-base-content/20 pl-4">
-        <span class="text-base-content font-medium">节点颜色:</span>
+        <span class="text-base-content font-medium">颜色:</span>
         <div class="flex items-center gap-1">
           <span class="w-2.5 h-2.5 rounded-full bg-dim-persona"></span>人设
         </div>
@@ -34,15 +32,18 @@
           <span class="w-2.5 h-2.5 rounded-full bg-dim-key"></span>关键点
         </div>
       </div>
-
-      <!-- 节点形状图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
-        <span class="text-base-content font-medium">节点形状:</span>
+        <span class="text-base-content font-medium">形状:</span>
         <div class="flex items-center gap-1">○ 标签</div>
         <div class="flex items-center gap-1">□ 分类/点</div>
       </div>
+      <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
+        <span class="text-base-content font-medium">填充:</span>
+        <div class="flex items-center gap-1">◯ 帖子</div>
+        <div class="flex items-center gap-1">● 人设</div>
+      </div>
 
-      <!-- 边类型图例 -->
+      <!-- 边图例 -->
       <div class="flex gap-3 text-xs text-base-content/60 ml-4 border-l border-base-content/20 pl-4">
         <span class="text-base-content font-medium">边:</span>
         <div v-for="(color, type) in edgeTypeColors" :key="type" class="flex items-center gap-1">
@@ -100,15 +101,13 @@
           <GraphView class="h-full" :show-expand="true" />
         </div>
       </div>
-      <!-- 中间:帖子树 -->
+      <!-- 中间:帖子树(含右侧匹配列表+详情) -->
       <div
         class="bg-base-200 flex flex-col transition-all duration-200"
         :class="getPostTreeClass()"
       >
         <PostTreeView class="flex-1" :show-expand="true" />
       </div>
-      <!-- 详情侧边栏 -->
-      <DetailPanel class="w-56 shrink-0 transition-all duration-200" :class="{ 'w-0 overflow-hidden': !hasSelection }" />
     </main>
   </div>
 </template>

+ 45 - 21
script/visualization/src/components/GraphView.vue

@@ -6,7 +6,7 @@
       <span v-if="store.selectedNodeId" class="text-primary font-medium">{{ currentNodeName }}</span>
       <div class="flex-1"></div>
       <button v-if="store.selectedNodeId" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
-        {{ showConfig ? '隐藏配置' : '游走配置' }}
+        {{ showConfig ? '隐藏筛选' : '筛选' }}
       </button>
       <template v-if="showExpand && store.selectedNodeId">
         <button
@@ -22,14 +22,23 @@
           title="恢复"
         >⊡</button>
       </template>
+      <button
+        v-if="store.selectedNodeId"
+        @click="copyGraphJson"
+        class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
+        :title="copySuccess ? '已复制' : '复制JSON'"
+      >
+        <span v-if="copySuccess">✓</span>
+        <span v-else>📋</span>
+      </button>
     </div>
 
-    <!-- 人设节点游走配置 -->
+    <!-- 人设节点筛选配置 -->
     <div v-show="showConfig && isPersonaWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
       <!-- 步数设置 -->
       <div class="flex items-center gap-2">
-        <span class="text-base-content/60 w-16">游走步数:</span>
-        <input type="number" :min="1" :max="5" v-model.number="store.walkSteps" class="input input-xs input-bordered w-16 text-center" />
+        <span class="text-base-content/60 w-16">筛选步数:</span>
+        <input type="number" :min="1" :max="10" v-model.number="store.walkSteps" class="input input-xs input-bordered w-16 text-center" />
       </div>
 
       <!-- 分步设置 -->
@@ -56,7 +65,7 @@
       </div>
     </div>
 
-    <!-- 帖子标签节点游走配置 -->
+    <!-- 帖子标签节点筛选配置 -->
     <div v-show="showConfig && isPostWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-2">
       <div class="flex items-center gap-2">
         <span class="text-base-content/60 w-20">最大步数:</span>
@@ -89,8 +98,9 @@
 import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { getNodeStyle } from '../config/nodeStyle'
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
 import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
+import { applyHighlight } from '../utils/highlight'
 
 const props = defineProps({
   collapsed: { type: Boolean, default: false },
@@ -102,6 +112,20 @@ const containerRef = ref(null)
 const svgRef = ref(null)
 const showConfig = ref(false)
 
+// 复制 JSON 功能
+const copySuccess = ref(false)
+function copyGraphJson() {
+  const data = {
+    selectedNodeId: store.selectedNodeId,
+    walkedEdges: store.walkedEdges,
+    walkedNodes: store.walkedNodes
+  }
+  navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
+    copySuccess.value = true
+    setTimeout(() => { copySuccess.value = false }, 1500)
+  })
+}
+
 let simulation = null
 
 // 游走配置操作(直接操作 store)
@@ -301,7 +325,7 @@ function renderGraph() {
     .selectAll('g')
     .data(nodes)
     .join('g')
-    .attr('class', d => `graph-node ${d.isCenter ? 'selected' : ''}`)
+    .attr('class', 'graph-node')
     .call(d3.drag()
       .on('start', (e, d) => {
         if (!e.active) simulation.alphaTarget(0.3).restart()
@@ -351,20 +375,7 @@ function renderGraph() {
   node.each(function(d) {
     const el = d3.select(this)
     const style = getNodeStyle(d, { isCenter: d.isCenter })
-
-    if (style.shape === 'rect') {
-      el.append('rect')
-        .attr('x', -style.size/2)
-        .attr('y', -style.size/2)
-        .attr('width', style.size)
-        .attr('height', style.size)
-        .attr('rx', 1)
-        .attr('fill', style.color)
-    } else {
-      el.append('circle')
-        .attr('r', style.size/2)
-        .attr('fill', style.color)
-    }
+    applyNodeShape(el, style)
   })
 
   // 节点标签
@@ -390,6 +401,9 @@ function renderGraph() {
 
     node.attr('transform', d => `translate(${d.x},${d.y})`)
   })
+
+  // 应用初始高亮状态
+  nextTick(updateHighlight)
 }
 
 // 点击空白取消激活
@@ -399,6 +413,12 @@ function handleSvgClick(event) {
   }
 }
 
+// 统一高亮更新
+function updateHighlight() {
+  const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet
+  applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
+}
+
 // 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
 watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
   nextTick(renderGraph)
@@ -407,8 +427,12 @@ watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () =
 // 监听边选中变化
 watch(() => store.selectedEdgeId, () => {
   nextTick(renderGraph)
+  nextTick(updateHighlight)
 })
 
+// 监听高亮节点集合变化
+watch(() => store.highlightedNodeIds.size, updateHighlight)
+
 // 监听配置变化,重新选中触发游走
 watch([() => store.walkSteps, () => store.stepConfigs], () => {
   if (store.selectedNodeId && isPersonaWalk.value) {

+ 443 - 107
script/visualization/src/components/PostTreeView.vue

@@ -1,60 +1,219 @@
 <template>
-  <div class="flex flex-col h-full">
-    <!-- 头部 -->
-    <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
-      <span>帖子树</span>
-      <div class="flex items-center gap-2">
-        <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
-          已高亮 {{ store.highlightedNodeIds.size }} 个节点
-        </span>
-        <template v-if="showExpand">
+  <div class="flex h-full">
+    <!-- 左侧主区域 -->
+    <div 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>
+        <div class="flex items-center gap-2">
+          <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
+            已高亮 {{ store.highlightedNodeIds.size }} 个节点
+          </span>
+          <template v-if="showExpand">
+            <button
+              v-if="store.expandedPanel !== 'post-tree'"
+              @click="store.expandPanel('post-tree')"
+              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>
+          </template>
           <button
-            v-if="store.expandedPanel !== 'post-tree'"
-            @click="store.expandPanel('post-tree')"
-            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>
-        </template>
+            @click="copyPostTreeJson"
+            class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
+            :title="copyPostTreeSuccess ? '已复制' : '复制JSON'"
+          >
+            <span v-if="copyPostTreeSuccess">✓</span>
+            <span v-else>📋</span>
+          </button>
+        </div>
       </div>
-    </div>
 
-    <!-- 帖子选择下拉框 -->
-    <div class="px-4 py-2 bg-base-200 border-b border-base-300">
-      <select
-        v-model="selectedPostIdx"
-        @change="onPostChange"
-        class="select select-xs select-bordered w-full"
-      >
-        <option v-if="store.postList.length === 0" :value="-1">暂无帖子数据</option>
-        <option
+      <!-- 帖子筛选列表 -->
+      <div class="px-2 py-1.5 bg-base-200 border-b border-base-300 flex gap-1 overflow-x-auto">
+        <button
           v-for="post in store.postList"
           :key="post.index"
-          :value="post.index"
+          @click="selectPost(post.index)"
+          class="btn btn-xs shrink-0 transition-colors"
+          :class="selectedPostIdx === post.index ? 'btn-primary' : 'btn-ghost'"
+          :title="post.postTitle"
         >
-          {{ formatPostOption(post) }}
-        </option>
-      </select>
+          {{ formatPostTitle(post) }}
+        </button>
+        <span v-if="store.postList.length === 0" class="text-xs text-base-content/40 px-2">暂无帖子</span>
+      </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>
 
-    <!-- SVG 容器 -->
-    <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
-      <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
+    <!-- 右侧:匹配列表 + 详情 -->
+    <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 class="flex items-center gap-2">
+            <span class="text-base-content/40">{{ sortedMatchEdges.length }}</span>
+            <button
+              @click="copyMatchListJson"
+              class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
+              :title="copyMatchListSuccess ? '已复制' : '复制JSON'"
+            >
+              <span v-if="copyMatchListSuccess">✓</span>
+              <span v-else>📋</span>
+            </button>
+          </div>
+        </div>
+        <div class="flex-1 overflow-y-auto">
+          <div
+            v-for="(edge, idx) in sortedMatchEdges"
+            :key="idx"
+            class="px-3 py-1.5 hover:bg-base-300 cursor-pointer border-b border-base-300/50 transition-colors"
+            :class="{ 'bg-primary/10': store.selectedEdgeId === getEdgeId(edge) }"
+            @click="onMatchClick(edge)"
+          >
+            <div class="flex items-center gap-1.5">
+              <!-- 源节点样式(帖子域-空心) -->
+              <span
+                class="w-2 h-2 shrink-0 rounded-full border-2"
+                :style="{ borderColor: getSourceNodeColor(edge), backgroundColor: 'transparent' }"
+              ></span>
+              <span class="truncate text-base-content/80" :title="edge.sourceName">{{ edge.sourceName }}</span>
+              <!-- 分数(带边颜色) -->
+              <span
+                class="px-1 text-[10px] font-medium shrink-0 border-t border-b"
+                :style="{ color: getScoreColor(edge.score), borderColor: getScoreColor(edge.score) }"
+              >{{ edge.score != null ? edge.score.toFixed(2) : '-' }}</span>
+              <!-- 目标节点样式(人设域-实心) -->
+              <span
+                class="w-2 h-2 shrink-0 rounded-full"
+                :style="{ backgroundColor: getTargetNodeColor(edge) }"
+              ></span>
+              <span class="truncate text-base-content/60" :title="edge.targetName">{{ edge.targetName }}</span>
+            </div>
+          </div>
+          <div v-if="sortedMatchEdges.length === 0" class="px-3 py-4 text-base-content/40 text-center">
+            暂无匹配
+          </div>
+        </div>
+      </div>
+
+      <!-- 详情(下半部分) -->
+      <div 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]">
+            <input type="checkbox" v-model="showRawData" />
+            <span class="swap-on">JSON</span>
+            <span class="swap-off">渲染</span>
+          </label>
+        </div>
+        <div class="flex-1 overflow-y-auto p-3 space-y-3">
+          <!-- 原始JSON模式 -->
+          <template v-if="showRawData && (store.selectedNode || store.selectedEdge)">
+            <div class="relative">
+              <button
+                @click="copyJson"
+                class="absolute top-1 right-1 btn btn-ghost btn-xs opacity-60 hover:opacity-100"
+                :title="copySuccess ? '已复制' : '复制'"
+              >
+                <span v-if="copySuccess">✓</span>
+                <span v-else>📋</span>
+              </button>
+              <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(store.selectedNode || store.selectedEdge, null, 2) }}</pre>
+            </div>
+          </template>
+          <!-- 渲染模式 -->
+          <template v-else-if="!showRawData">
+            <!-- 节点详情 -->
+            <template v-if="store.selectedNode">
+              <div class="flex items-center gap-2">
+                <!-- 节点样式:空心(帖子域)或实心(人设域) -->
+                <span
+                  class="w-2.5 h-2.5 shrink-0"
+                  :class="selectedNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
+                  :style="selectedNodeStyle.hollow
+                    ? { backgroundColor: 'transparent', border: '2px solid ' + selectedNodeStyle.color }
+                    : { backgroundColor: selectedNodeStyle.color }"
+                ></span>
+                <span class="text-primary font-medium truncate">{{ store.selectedNode.name }}</span>
+              </div>
+              <div class="space-y-1.5 text-[11px]">
+                <template v-for="(value, key) in store.selectedNode" :key="key">
+                  <template v-if="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>
+                    </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-2 border-l border-base-content/20 space-y-1">
+                        <template v-for="(v, k) in value" :key="k">
+                          <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
+                            <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>
+            </template>
+            <!-- 边详情 -->
+            <template v-else-if="store.selectedEdge">
+              <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>
+              </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 !== ''">
+                    <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-2 border-l border-base-content/20 space-y-1">
+                        <template v-for="(v, k) in value" :key="k">
+                          <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
+                            <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>
+            </template>
+            <!-- 无选中 -->
+            <div v-else class="text-base-content/40 text-center py-4">
+              点击节点或边查看详情
+            </div>
+          </template>
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { getNodeStyle } from '../config/nodeStyle'
-import { getEdgeStyle } from '../config/edgeStyle'
+import { getNodeStyle, applyNodeShape, dimColors } from '../config/nodeStyle'
+import { getEdgeStyle, edgeTypeColors } from '../config/edgeStyle'
 import { applyHighlight } from '../utils/highlight'
 
 const props = defineProps({
@@ -69,21 +228,145 @@ const store = useGraphStore()
 const containerRef = ref(null)
 const svgRef = ref(null)
 
+// 详情显示模式:原始JSON / 渲染
+const showRawData = ref(false)
+const copySuccess = ref(false)
+
+// 复制JSON到剪贴板(详情)
+function copyJson() {
+  const data = store.selectedNode || store.selectedEdge
+  if (!data) return
+  navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
+    copySuccess.value = true
+    setTimeout(() => { copySuccess.value = false }, 1500)
+  })
+}
+
+// 复制帖子树JSON
+const copyPostTreeSuccess = ref(false)
+function copyPostTreeJson() {
+  const data = store.currentPostGraph
+  if (!data) return
+  navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
+    copyPostTreeSuccess.value = true
+    setTimeout(() => { copyPostTreeSuccess.value = false }, 1500)
+  })
+}
+
+// 复制匹配列表JSON
+const copyMatchListSuccess = ref(false)
+function copyMatchListJson() {
+  const data = sortedMatchEdges.value
+  if (!data || data.length === 0) return
+  navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
+    copyMatchListSuccess.value = true
+    setTimeout(() => { copyMatchListSuccess.value = false }, 1500)
+  })
+}
+
 // 当前选中的帖子索引
 const selectedPostIdx = ref(store.selectedPostIndex)
 
+// 匹配边列表(按分数从高到低排序,无分数的放最后)
+const sortedMatchEdges = computed(() => {
+  const postGraph = store.currentPostGraph
+  if (!postGraph?.edges) return []
+
+  const matchEdges = []
+  for (const [edgeId, edge] of Object.entries(postGraph.edges)) {
+    if (edge.type === '匹配') {
+      // 获取源节点和目标节点名称
+      const sourceNode = postGraph.nodes?.[edge.source]
+      const targetNode = store.getNode(edge.target)  // 目标是人设节点
+      matchEdges.push({
+        ...edge,
+        sourceName: sourceNode?.name || edge.source.split(':').pop(),
+        targetName: targetNode?.name || edge.target.split(':').pop()
+      })
+    }
+  }
+
+  // 按分数从高到低排序,无分数的放最后
+  return matchEdges.sort((a, b) => {
+    const aScore = a.score ?? -Infinity
+    const bScore = b.score ?? -Infinity
+    return bScore - aScore
+  })
+})
+
+// 获取匹配边的分数颜色
+function getScoreColor(score) {
+  return getEdgeStyle({ type: '匹配', score }).color
+}
+
+// 获取源节点颜色(帖子域节点)
+function getSourceNodeColor(edge) {
+  const postGraph = store.currentPostGraph
+  const sourceNode = postGraph?.nodes?.[edge.source]
+  if (sourceNode?.dimension) {
+    return dimColors[sourceNode.dimension] || '#888'
+  }
+  return '#888'
+}
+
+// 获取目标节点颜色(人设域节点)
+function getTargetNodeColor(edge) {
+  const targetNode = store.getNode(edge.target)
+  if (targetNode?.dimension) {
+    return dimColors[targetNode.dimension] || '#888'
+  }
+  return '#888'
+}
+
+// 选中节点的样式
+const selectedNodeStyle = computed(() => {
+  if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
+  return getNodeStyle(store.selectedNode)
+})
+
+// 选中节点的颜色(兼容)
+const selectedNodeColor = computed(() => selectedNodeStyle.value.color)
+
+// 选中边的颜色
+const selectedEdgeColor = computed(() => {
+  if (!store.selectedEdge) return '#888'
+  return edgeTypeColors[store.selectedEdge.type] || '#888'
+})
+
+// 获取边ID
+function getEdgeId(edge) {
+  return `${edge.source}|${edge.type}|${edge.target}`
+}
+
+// 点击匹配项
+function onMatchClick(edge) {
+  store.selectEdge({
+    source: edge.source,
+    target: edge.target,
+    type: edge.type,
+    score: edge.score
+  })
+}
+
 // zoom 实例和主 g 元素
 let zoom = null
 let mainG = null
 let treeWidth = 0
 let treeHeight = 0
 
-// 帖子选择变化
-function onPostChange() {
-  store.selectPost(selectedPostIdx.value)
+// 选择帖子
+function selectPost(index) {
+  selectedPostIdx.value = index
+  store.selectPost(index)
+}
+
+// 格式化帖子标题(简短显示)
+function formatPostTitle(post) {
+  const title = post.postTitle || post.postId
+  return title.length > 10 ? title.slice(0, 10) + '…' : title
 }
 
-// 格式化帖子选项显示
+// 格式化帖子选项显示(完整)
 function formatPostOption(post) {
   const date = post.createTime ? new Date(post.createTime * 1000).toLocaleDateString() : ''
   const title = post.postTitle || post.postId
@@ -205,29 +488,10 @@ function renderTree() {
   // 节点形状(使用统一配置)
   nodes.each(function(d) {
     const el = d3.select(this)
+    // 帖子树节点属于帖子域(空心)
+    d.data.domain = '帖子'
     const style = getNodeStyle(d)
-    const halfSize = style.size / 2
-
-    if (style.shape === 'rect') {
-      el.append('rect')
-        .attr('class', 'tree-shape')
-        .attr('x', -halfSize)
-        .attr('y', -halfSize)
-        .attr('width', style.size)
-        .attr('height', style.size)
-        .attr('rx', 1)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    } else {
-      el.append('circle')
-        .attr('class', 'tree-shape')
-        .attr('r', halfSize)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    }
-
+    applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.data.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
 
@@ -281,6 +545,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
         name: name,
         dimension: dimension,
         type: type,
+        domain: '人设', // 人设节点:实心
         sourceEdges: [] // 连接的帖子节点
       })
     }
@@ -398,29 +663,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
   matchNodes.each(function(d) {
     const el = d3.select(this)
     const style = getNodeStyle(d, { isMatch: true })
-    const halfSize = style.size / 2
-
-    if (style.shape === 'rect') {
-      el.append('rect')
-        .attr('class', 'tree-shape')
-        .attr('x', -halfSize)
-        .attr('y', -halfSize)
-        .attr('width', style.size)
-        .attr('height', style.size)
-        .attr('rx', 1)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    } else {
-      el.append('circle')
-        .attr('class', 'tree-shape')
-        .attr('r', halfSize)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    }
-
-    // 保存匹配节点位置(统一存入 nodeElements)
+    applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
 
@@ -688,23 +931,7 @@ function renderWalkedLayer() {
   walkedNodeGroups.each(function(d) {
     const el = d3.select(this)
     const style = getNodeStyle(d)
-    const halfSize = style.size / 2
-
-    if (style.shape === 'rect') {
-      el.append('rect')
-        .attr('class', 'walked-shape')
-        .attr('x', -halfSize).attr('y', -halfSize)
-        .attr('width', style.size).attr('height', style.size)
-        .attr('rx', 1).attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
-    } else {
-      el.append('circle')
-        .attr('class', 'walked-shape')
-        .attr('r', halfSize).attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)').attr('stroke-width', 1)
-    }
-
-    // 保存节点位置
+    applyNodeShape(el, style).attr('class', 'walked-shape')
     nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
   })
 
@@ -733,6 +960,72 @@ function handleMatchNodeClick(event, d) {
   store.selectNode(d)
 }
 
+// ========== 详情显示格式化函数 ==========
+
+// 格式化字段名(camelCase/snake_case -> 中文/可读)
+function formatKey(key) {
+  const keyMap = {
+    'id': 'ID',
+    'name': '名称',
+    'type': '类型',
+    'dimension': '维度',
+    'source': '源节点',
+    'target': '目标节点',
+    'score': '分数',
+    'detail': '详情',
+    'postId': '帖子ID',
+    'postTitle': '帖子标题',
+    'createTime': '创建时间',
+    'parentId': '父节点',
+    'children': '子节点',
+    'description': '描述',
+    'content': '内容',
+    'tags': '标签',
+    'category': '分类',
+    'level': '层级',
+    'depth': '深度',
+    'weight': '权重',
+    'count': '数量',
+    'status': '状态',
+    'reason': '原因',
+    'explanation': '说明',
+    'matchReason': '匹配原因',
+    'similarity': '相似度',
+    'confidence': '置信度'
+  }
+  return keyMap[key] || key
+}
+
+// 格式化普通值
+function formatValue(value) {
+  if (value === null || value === undefined) return '-'
+  if (typeof value === 'boolean') return value ? '是' : '否'
+  if (typeof value === 'number') {
+    // 如果是小数,保留2位
+    if (!Number.isInteger(value)) return value.toFixed(2)
+    return value.toString()
+  }
+  if (Array.isArray(value)) {
+    if (value.length === 0) return '-'
+    return value.join(', ')
+  }
+  if (typeof value === 'object') {
+    return JSON.stringify(value)
+  }
+  return String(value)
+}
+
+// 格式化边的值(特殊处理 source/target 显示名称)
+function formatEdgeValue(key, value) {
+  if (key === 'source' || key === 'target') {
+    // 提取ID最后部分作为显示名称
+    if (typeof value === 'string' && value.includes(':')) {
+      return value.split(':').pop()
+    }
+  }
+  return formatValue(value)
+}
+
 // 适应视图(自动缩放以显示全部内容)
 function fitToView() {
   if (!zoom || !mainG || !containerRef.value) return
@@ -775,6 +1068,38 @@ function zoomToNode(nodeId) {
   )
 }
 
+// 定位到指定边(显示边的两个端点)
+function zoomToEdge(sourceId, targetId) {
+  const sourceInfo = nodeElements[sourceId]
+  const targetInfo = nodeElements[targetId]
+  if (!sourceInfo || !targetInfo || !zoom || !containerRef.value) return
+
+  const container = containerRef.value
+  const width = container.clientWidth
+  const height = container.clientHeight
+
+  // 计算边的中心点和范围
+  const centerX = (sourceInfo.x + targetInfo.x) / 2
+  const centerY = (sourceInfo.y + targetInfo.y) / 2
+  const edgeWidth = Math.abs(sourceInfo.x - targetInfo.x) + 100
+  const edgeHeight = Math.abs(sourceInfo.y - targetInfo.y) + 100
+
+  // 计算合适的缩放比例(让边的两端都能显示)
+  const scaleX = width / edgeWidth
+  const scaleY = height / edgeHeight
+  const scale = Math.min(scaleX, scaleY, 1.2) // 最大缩放1.2
+
+  // 计算平移使边居中
+  const translateX = width / 2 - centerX * scale
+  const translateY = height / 2 - centerY * scale
+
+  const svg = d3.select(svgRef.value)
+  svg.transition().duration(300).call(
+    zoom.transform,
+    d3.zoomIdentity.translate(translateX, translateY).scale(scale)
+  )
+}
+
 // 更新高亮/置灰状态
 function updateHighlight() {
   // 使用帖子游走的边集合(如果有),否则用人设游走的边集合
@@ -798,6 +1123,17 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   }
 })
 
+watch(() => store.selectedEdgeId, updateHighlight)
+
+// 监听聚焦边端点变化(由 store 统一管理)
+watch(() => store.focusEdgeEndpoints, (endpoints) => {
+  if (endpoints) {
+    nextTick(() => {
+      zoomToEdge(endpoints.source, endpoints.target)
+    })
+  }
+})
+
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
 // 监听帖子游走结果变化,渲染游走层

+ 36 - 26
script/visualization/src/components/TreeView.vue

@@ -3,9 +3,19 @@
     <!-- 头部 -->
     <div v-if="!hideHeader" class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
       <span>人设树</span>
-      <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
-        已高亮 {{ store.highlightedNodeIds.size }} 个节点
-      </span>
+      <div class="flex items-center gap-2">
+        <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
+          已高亮 {{ store.highlightedNodeIds.size }} 个节点
+        </span>
+        <button
+          @click="copyTreeJson"
+          class="btn btn-ghost btn-xs opacity-60 hover:opacity-100"
+          :title="copySuccess ? '已复制' : '复制JSON'"
+        >
+          <span v-if="copySuccess">✓</span>
+          <span v-else>📋</span>
+        </button>
+      </div>
     </div>
 
     <!-- 搜索框 -->
@@ -65,7 +75,7 @@
 import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
-import { dimColors, getNodeStyle } from '../config/nodeStyle'
+import { dimColors, getNodeStyle, applyNodeShape } from '../config/nodeStyle'
 import { applyHighlight } from '../utils/highlight'
 
 const props = defineProps({
@@ -91,6 +101,17 @@ const searchQuery = ref('')
 const showSuggestions = ref(false)
 const suggestionIndex = ref(0)
 
+// 复制 JSON 功能
+const copySuccess = ref(false)
+function copyTreeJson() {
+  const data = store.treeData
+  if (!data) return
+  navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
+    copySuccess.value = true
+    setTimeout(() => { copySuccess.value = false }, 1500)
+  })
+}
+
 // 获取所有节点列表
 const allNodes = computed(() => {
   const nodes = store.graphData.nodes || {}
@@ -236,28 +257,7 @@ function renderTree() {
   nodes.each(function(d) {
     const el = d3.select(this)
     const style = getNodeStyle(d)
-
-    if (style.shape === 'rect') {
-      el.append('rect')
-        .attr('class', 'tree-shape')
-        .attr('x', -4)
-        .attr('y', -4)
-        .attr('width', 8)
-        .attr('height', 8)
-        .attr('rx', 1)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    } else {
-      el.append('circle')
-        .attr('class', 'tree-shape')
-        .attr('r', style.size / 2)
-        .attr('fill', style.color)
-        .attr('stroke', 'rgba(255,255,255,0.5)')
-        .attr('stroke-width', 1)
-    }
-
-    // 记录节点位置(用于 zoomToNode)
+    applyNodeShape(el, style).attr('class', 'tree-shape')
     nodeElements[d.data.id] = { element: this, x: d.y + 25, y: d.x + 25 }
   })
 
@@ -341,6 +341,16 @@ watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
   }
 })
 
+// 监听聚焦节点变化(由 store 统一管理)
+watch(() => store.focusNodeId, (nodeId) => {
+  if (nodeId) {
+    zoomToNode(nodeId)
+  }
+})
+
+// 监听选中边变化,更新高亮
+watch(() => store.selectedEdgeId, updateSelection)
+
 watch(() => store.highlightedNodeIds.size, updateSelection)
 
 // 监听布局变化,过渡结束后重新适应视图

+ 38 - 3
script/visualization/src/config/nodeStyle.js

@@ -27,12 +27,15 @@ export function getNodeStyle(node, options = {}) {
   // 形状(分类/点 用方形,其他用圆形)
   const shape = (data.type === '分类' || data.type === '点') ? 'rect' : 'circle'
 
-  // 大小
+  // domain 区分:帖子域用空心,人设域用实心
+  const domain = data.domain || '人设'
+  const hollow = domain === '帖子'
+
+  // 大小(统一,不因 isCenter 改变)
   const isRoot = node.depth === 0
   const isDimension = ['灵感点', '目的点', '关键点'].includes(data.type)
   let size = 10
-  if (isCenter) size = 20
-  else if (isRoot) size = 16
+  if (isRoot) size = 16
   else if (isDimension) size = 14
 
   // 文字样式
@@ -43,10 +46,42 @@ export function getNodeStyle(node, options = {}) {
     fontWeight: isHighlight ? 'bold' : 'normal'
   }
 
+  // SVG 属性(fill/stroke)
+  const fill = hollow ? 'transparent' : color
+  const stroke = hollow ? color : 'none'
+  const strokeWidth = hollow ? 2 : 0
+
   return {
     color,
     shape,
     size,
+    hollow,  // true=空心(帖子域),false=实心(人设域)
+    fill,
+    stroke,
+    strokeWidth,
     text
   }
 }
+
+// 应用节点样式到 D3 选择器(复用)
+export function applyNodeShape(el, style) {
+  const halfSize = style.size / 2
+
+  if (style.shape === 'rect') {
+    return el.append('rect')
+      .attr('x', -halfSize)
+      .attr('y', -halfSize)
+      .attr('width', style.size)
+      .attr('height', style.size)
+      .attr('rx', 1)
+      .attr('fill', style.fill)
+      .attr('stroke', style.stroke)
+      .attr('stroke-width', style.strokeWidth)
+  } else {
+    return el.append('circle')
+      .attr('r', halfSize)
+      .attr('fill', style.fill)
+      .attr('stroke', style.stroke)
+      .attr('stroke-width', style.strokeWidth)
+  }
+}

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

@@ -346,6 +346,10 @@ export const useGraphStore = defineStore('graph', () => {
   const selectedNodeId = ref(null)
   const selectedEdgeId = ref(null)
   const highlightedNodeIds = ref(new Set())
+  // 需要聚焦的节点(用于各视图统一定位)
+  const focusNodeId = ref(null)
+  // 需要聚焦的边端点(source, target)
+  const focusEdgeEndpoints = ref(null)
 
   // 获取节点
   function getNode(nodeId) {
@@ -455,9 +459,28 @@ export const useGraphStore = defineStore('graph', () => {
 
     // 只高亮边的两端节点
     highlightedNodeIds.value = new Set([edge.source, edge.target])
-    // 只保留这条边
-    walkedEdges.value = [edge]
-    postWalkedEdges.value = []
+
+    // 判断是帖子图谱的边还是人设图谱的边
+    const isPostEdge = edge.source?.startsWith('帖子:') || edge.target?.startsWith('帖子:')
+    if (isPostEdge) {
+      postWalkedEdges.value = [edge]
+      walkedEdges.value = [edge]  // 同时设置,GraphView 也需要
+    } else {
+      walkedEdges.value = [edge]
+      postWalkedEdges.value = []
+    }
+
+    // 设置聚焦状态(用于各视图统一定位)
+    // 人设树聚焦到人设节点
+    if (edge.target?.startsWith('人设:')) {
+      focusNodeId.value = edge.target
+    } else if (edge.source?.startsWith('人设:')) {
+      focusNodeId.value = edge.source
+    } else {
+      focusNodeId.value = null
+    }
+    // 帖子树聚焦到边的两端
+    focusEdgeEndpoints.value = { source: edge.source, target: edge.target }
   }
 
   // 清除选中
@@ -469,6 +492,8 @@ export const useGraphStore = defineStore('graph', () => {
     postWalkedPaths.value = []
     postWalkedNodes.value = []
     postWalkedEdges.value = []
+    focusNodeId.value = null
+    focusEdgeEndpoints.value = null
   }
 
   // 计算属性:当前选中节点的数据
@@ -529,6 +554,8 @@ export const useGraphStore = defineStore('graph', () => {
     selectedNodeId,
     selectedEdgeId,
     highlightedNodeIds,
+    focusNodeId,
+    focusEdgeEndpoints,
     selectedNode,
     selectedEdge,
     getNode,

+ 0 - 3
script/visualization/src/style.css

@@ -32,7 +32,6 @@
 
   .tree-node circle,
   .tree-node rect {
-    stroke-width: 2;
     transition: all 0.2s;
   }
 
@@ -177,8 +176,6 @@
 
   .graph-node circle,
   .graph-node rect {
-    stroke: #333;
-    stroke-width: 2;
     transition: all 0.2s;
   }
 

+ 15 - 2
script/visualization/src/utils/highlight.js

@@ -41,12 +41,25 @@ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, sele
   const hasHighlight = highlightedIds.size > 0
 
   // 所有节点:在 highlightedIds 中的保持,否则置灰
+  // selected 逻辑:
+  // - 点击节点时(selectedId 有值):只有 selectedId 是 selected
+  // - 点击边时(selectedId 为空但 highlightedIds 有值):highlightedIds 中的节点(边的两端)都是 selected
   svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node')
-    .classed('selected', d => getNodeId(d) === selectedId)
+    .classed('selected', d => {
+      const nodeId = getNodeId(d)
+      if (selectedId) {
+        // 点击节点:只有被点击的节点是 selected
+        return nodeId === selectedId
+      } else if (hasHighlight) {
+        // 点击边:边的两端节点都是 selected
+        return highlightedIds.has(nodeId)
+      }
+      return false
+    })
     .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d)))
 
   // 所有边:在 walkedEdgeSet 中的保持,否则置灰
-  svg.selectAll('.tree-link, .graph-link, .match-link, .match-score, .walked-link, .walked-score')
+  svg.selectAll('.tree-link, .graph-link, .graph-link-label, .match-link, .match-score, .walked-link, .walked-score')
     .classed('dimmed', function(d) {
       if (!hasHighlight) return false
       if (d && walkedEdgeSet) {