ソースを参照

feat: 详情面板改为侧边栏,支持节点和边详情

- Store 添加边选中状态 (selectedEdgeId, selectEdge, selectedEdge)
- DetailPanel 改为右侧边栏布局
- 节点详情:类型、维度、帖子数、父路径、来源点
- 边详情:类型、分数、源/目标节点、详细信息
- App.vue 布局调整,侧边栏随选中状态显示/隐藏
- nodeStyle: 形状支持"点"类型(帖子图谱),节点尺寸调大
- GraphView: rect 的 rx 改为 1 避免变成圆形

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 1 日 前
コミット
d25f10751f

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

@@ -56,6 +56,8 @@
     <main v-if="activeTab === 'persona'" class="flex flex-1 overflow-hidden">
       <TreeView class="w-[420px] shrink-0 bg-base-200 border-r border-base-300" />
       <GraphView class="flex-1" />
+      <!-- 详情侧边栏 -->
+      <DetailPanel class="w-56 shrink-0 transition-all duration-200" :class="{ 'w-0 overflow-hidden': !hasSelection }" />
     </main>
 
     <!-- 主内容区 - 帖子匹配 Tab -->
@@ -98,17 +100,16 @@
           <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>
-
-    <!-- 详情面板 -->
-    <DetailPanel />
   </div>
 </template>
 
@@ -126,6 +127,9 @@ const store = useGraphStore()
 // 当前激活的 Tab
 const activeTab = ref('match')
 
+// 是否有选中内容(用于控制侧边栏显示)
+const hasSelection = computed(() => store.selectedNode || store.selectedEdge)
+
 // 切换 Tab 时清除选中状态,避免干扰
 function switchTab(tab) {
   if (activeTab.value !== tab) {

+ 174 - 21
script/visualization/src/components/DetailPanel.vue

@@ -1,45 +1,198 @@
 <template>
   <div
-    v-if="store.selectedNode"
-    class="card bg-base-200/95 fixed bottom-4 right-4 w-72 shadow-xl"
+    v-if="hasSelection"
+    class="h-full bg-base-200 border-l border-base-300 flex flex-col text-xs overflow-hidden"
   >
-    <div class="card-body p-4 text-xs">
-      <h4 class="card-title text-primary text-sm">{{ store.selectedNode.name }}</h4>
+    <!-- 头部 -->
+    <div class="flex items-center justify-between px-3 py-2 bg-base-300 shrink-0">
+      <span class="text-base-content/60">详情</span>
+      <button
+        @click="store.clearSelection"
+        class="btn btn-circle btn-ghost btn-xs"
+      >✕</button>
+    </div>
 
-      <div class="space-y-2 text-base-content/80">
-        <div class="flex">
-          <span class="text-base-content/50 w-14 shrink-0">类型</span>
-          <span>{{ store.selectedNode.type }}</span>
+    <!-- 内容区 -->
+    <div class="flex-1 overflow-y-auto p-3 space-y-4">
+      <!-- 节点详情 -->
+      <div v-if="store.selectedNode" class="space-y-3">
+        <div class="flex items-center gap-2">
+          <span
+            class="w-2.5 h-2.5 rounded-full shrink-0"
+            :style="{ backgroundColor: nodeColor }"
+          ></span>
+          <h4 class="text-primary font-medium truncate">{{ store.selectedNode.name }}</h4>
         </div>
-        <div class="flex">
-          <span class="text-base-content/50 w-14 shrink-0">维度</span>
-          <span>{{ store.selectedNode.dimension }}</span>
+
+        <!-- 基础信息 -->
+        <div class="space-y-1.5">
+          <div class="flex">
+            <span class="text-base-content/50 w-14 shrink-0">类型</span>
+            <span class="badge badge-xs badge-outline">{{ store.selectedNode.type }}</span>
+          </div>
+          <div class="flex">
+            <span class="text-base-content/50 w-14 shrink-0">维度</span>
+            <span>{{ store.selectedNode.dimension }}</span>
+          </div>
         </div>
 
+        <!-- 详情字段 -->
         <template v-if="store.selectedNode.detail">
           <div v-if="store.selectedNode.detail.postCount !== undefined" class="flex">
             <span class="text-base-content/50 w-14 shrink-0">帖子数</span>
-            <span>{{ store.selectedNode.detail.postCount }}</span>
+            <span class="text-success">{{ store.selectedNode.detail.postCount }}</span>
           </div>
-          <div v-if="store.selectedNode.detail.parentPath?.length" class="flex">
-            <span class="text-base-content/50 w-14 shrink-0">路径</span>
-            <span class="break-all">{{ store.selectedNode.detail.parentPath.join(' > ') }}</span>
+
+          <div v-if="store.selectedNode.detail.parentPath?.length" class="space-y-1">
+            <span class="text-base-content/50">父路径</span>
+            <div class="text-base-content/80 break-all pl-2 border-l-2 border-base-content/20">
+              {{ store.selectedNode.detail.parentPath.join(' > ') }}
+            </div>
+          </div>
+
+          <!-- 来源点列表 -->
+          <div v-if="store.selectedNode.detail.sources?.length" class="space-y-1">
+            <div class="flex items-center justify-between">
+              <span class="text-base-content/50">来源点</span>
+              <span class="text-base-content/30">{{ store.selectedNode.detail.sources.length }}个</span>
+            </div>
+            <div class="max-h-32 overflow-y-auto space-y-1">
+              <div
+                v-for="(source, idx) in store.selectedNode.detail.sources.slice(0, 10)"
+                :key="idx"
+                class="text-base-content/70 pl-2 border-l border-base-content/20 text-[10px]"
+              >
+                <div class="font-medium">{{ source.pointName }}</div>
+                <div v-if="source.pointDesc" class="text-base-content/50 truncate">{{ source.pointDesc }}</div>
+              </div>
+              <div v-if="store.selectedNode.detail.sources.length > 10" class="text-base-content/40 pl-2">
+                ...还有 {{ store.selectedNode.detail.sources.length - 10 }} 个
+              </div>
+            </div>
           </div>
         </template>
       </div>
 
-      <button
-        @click="store.clearSelection"
-        class="btn btn-circle btn-ghost btn-xs absolute top-3 right-3"
-      >
-        ✕
-      </button>
+      <!-- 分隔线 -->
+      <div v-if="store.selectedNode && store.selectedEdge" class="divider my-2"></div>
+
+      <!-- 边详情 -->
+      <div v-if="store.selectedEdge" class="space-y-3">
+        <div class="flex items-center gap-2">
+          <span
+            class="w-4 h-0.5 shrink-0"
+            :style="{ backgroundColor: edgeColor }"
+          ></span>
+          <h4 class="text-secondary font-medium">{{ store.selectedEdge.type }}</h4>
+        </div>
+
+        <!-- 基础信息 -->
+        <div class="space-y-1.5">
+          <div v-if="store.selectedEdge.score !== null && store.selectedEdge.score !== undefined" class="flex">
+            <span class="text-base-content/50 w-14 shrink-0">分数</span>
+            <span class="text-warning">{{ formatScore(store.selectedEdge.score) }}</span>
+          </div>
+          <div class="space-y-1">
+            <span class="text-base-content/50">源节点</span>
+            <div class="text-base-content/80 break-all pl-2 border-l-2 border-base-content/20 text-[10px]">
+              {{ formatNodeId(store.selectedEdge.source) }}
+            </div>
+          </div>
+          <div class="space-y-1">
+            <span class="text-base-content/50">目标节点</span>
+            <div class="text-base-content/80 break-all pl-2 border-l-2 border-base-content/20 text-[10px]">
+              {{ formatNodeId(store.selectedEdge.target) }}
+            </div>
+          </div>
+        </div>
+
+        <!-- 边详情字段 -->
+        <template v-if="store.selectedEdge.detail && Object.keys(store.selectedEdge.detail).length">
+          <div class="space-y-1.5">
+            <span class="text-base-content/50">详细信息</span>
+            <div class="space-y-1 pl-2 border-l-2 border-base-content/20">
+              <template v-for="(value, key) in store.selectedEdge.detail" :key="key">
+                <!-- 数组类型:共现帖子等 -->
+                <div v-if="Array.isArray(value)" class="space-y-0.5">
+                  <div class="flex items-center justify-between">
+                    <span class="text-base-content/60">{{ formatDetailKey(key) }}</span>
+                    <span class="text-base-content/40">{{ value.length }}项</span>
+                  </div>
+                  <div v-if="value.length && value.length <= 5" class="text-[10px] text-base-content/50 pl-2">
+                    {{ value.join(', ') }}
+                  </div>
+                </div>
+                <!-- 数值/字符串类型 -->
+                <div v-else class="flex justify-between">
+                  <span class="text-base-content/60">{{ formatDetailKey(key) }}</span>
+                  <span class="text-base-content/80">{{ formatDetailValue(value) }}</span>
+                </div>
+              </template>
+            </div>
+          </div>
+        </template>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup>
+import { computed } from 'vue'
 import { useGraphStore } from '../stores/graph'
+import { dimColors } from '../config/nodeStyle'
+import { edgeTypeColors } from '../config/edgeStyle'
 
 const store = useGraphStore()
+
+// 是否有选中内容
+const hasSelection = computed(() => store.selectedNode || store.selectedEdge)
+
+// 节点颜色
+const nodeColor = computed(() => {
+  if (!store.selectedNode) return '#888'
+  const dim = store.selectedNode.dimension
+  return dimColors[dim] || '#888'
+})
+
+// 边颜色
+const edgeColor = computed(() => {
+  if (!store.selectedEdge) return '#888'
+  return edgeTypeColors[store.selectedEdge.type] || '#888'
+})
+
+// 格式化分数
+function formatScore(score) {
+  if (typeof score === 'number') {
+    return score < 1 ? score.toFixed(2) : score
+  }
+  return score
+}
+
+// 格式化节点ID(提取最后部分作为名称)
+function formatNodeId(nodeId) {
+  if (!nodeId) return ''
+  const parts = nodeId.split(':')
+  return parts[parts.length - 1] || nodeId
+}
+
+// 格式化详情字段名
+function formatDetailKey(key) {
+  const keyMap = {
+    'jaccard': 'Jaccard',
+    'overlapCoef': '重叠系数',
+    'cooccurCount': '共现次数',
+    'cooccurPosts': '共现帖子',
+    'pointCount': '点数',
+    'pointNames': '点名称'
+  }
+  return keyMap[key] || key
+}
+
+// 格式化详情值
+function formatDetailValue(value) {
+  if (typeof value === 'number') {
+    return value < 1 && value > 0 ? value.toFixed(3) : value
+  }
+  return value
+}
 </script>

+ 1 - 1
script/visualization/src/components/GraphView.vue

@@ -292,7 +292,7 @@ function renderGraph() {
         .attr('y', -style.size/2)
         .attr('width', style.size)
         .attr('height', style.size)
-        .attr('rx', 3)
+        .attr('rx', 1)
         .attr('fill', style.color)
     } else {
       el.append('circle')

+ 6 - 6
script/visualization/src/config/nodeStyle.js

@@ -24,16 +24,16 @@ export function getNodeStyle(node, options = {}) {
     color = dimColors[data.dimension]
   }
 
-  // 形状
-  const shape = data.type === '分类' ? 'rect' : 'circle'
+  // 形状(分类/点 用方形,其他用圆形)
+  const shape = (data.type === '分类' || data.type === '点') ? 'rect' : 'circle'
 
   // 大小
   const isRoot = node.depth === 0
   const isDimension = ['灵感点', '目的点', '关键点'].includes(data.type)
-  let size = 6
-  if (isCenter) size = 16
-  else if (isRoot) size = 12
-  else if (isDimension) size = 10
+  let size = 10
+  if (isCenter) size = 20
+  else if (isRoot) size = 16
+  else if (isDimension) size = 14
 
   // 文字样式
   const isHighlight = isRoot || isDimension

+ 23 - 0
script/visualization/src/stores/graph.js

@@ -344,6 +344,7 @@ export const useGraphStore = defineStore('graph', () => {
 
   // ==================== 统一的选中/高亮状态 ====================
   const selectedNodeId = ref(null)
+  const selectedEdgeId = ref(null)
   const highlightedNodeIds = ref(new Set())
 
   // 获取节点
@@ -351,6 +352,11 @@ export const useGraphStore = defineStore('graph', () => {
     return graphData.value.nodes[nodeId] || currentPostGraph.value?.nodes?.[nodeId]
   }
 
+  // 获取边
+  function getEdge(edgeId) {
+    return graphData.value.edges?.[edgeId] || currentPostGraph.value?.edges?.[edgeId]
+  }
+
   // 根据配置获取过滤后的邻居(沿出边游走)
   function getFilteredNeighbors(nodeId, config) {
     const neighbors = []
@@ -400,6 +406,7 @@ export const useGraphStore = defineStore('graph', () => {
   function selectNode(nodeOrId) {
     const nodeId = typeof nodeOrId === 'string' ? nodeOrId : (nodeOrId.data?.id || nodeOrId.id)
     selectedNodeId.value = nodeId
+    selectedEdgeId.value = null  // 清除边选中
 
     // 清空之前的游走结果
     walkedEdges.value = []
@@ -419,9 +426,16 @@ export const useGraphStore = defineStore('graph', () => {
     }
   }
 
+  // 选中边
+  function selectEdge(edgeId) {
+    selectedEdgeId.value = edgeId
+    // 不清除节点选中,边详情作为补充信息
+  }
+
   // 清除选中
   function clearSelection() {
     selectedNodeId.value = null
+    selectedEdgeId.value = null
     highlightedNodeIds.value = new Set()
     walkedEdges.value = []
     postWalkedPaths.value = []
@@ -434,6 +448,11 @@ export const useGraphStore = defineStore('graph', () => {
     return selectedNodeId.value ? getNode(selectedNodeId.value) : null
   })
 
+  // 计算属性:当前选中边的数据
+  const selectedEdge = computed(() => {
+    return selectedEdgeId.value ? getEdge(selectedEdgeId.value) : null
+  })
+
   // 计算属性:树数据
   const treeData = computed(() => graphData.value.tree)
 
@@ -480,10 +499,14 @@ export const useGraphStore = defineStore('graph', () => {
     shouldPostWalk,
     // 选中/高亮
     selectedNodeId,
+    selectedEdgeId,
     highlightedNodeIds,
     selectedNode,
+    selectedEdge,
     getNode,
+    getEdge,
     selectNode,
+    selectEdge,
     clearSelection,
     // 布局
     expandedPanel,