Explorar o código

feat: 边可点击查看详情,详情面板动态展示所有字段

- 添加边的点击事件和hover样式
- DetailPanel改为递归组件动态展示所有字段(包括嵌套对象)

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui hai 1 día
pai
achega
a206735d3a

+ 125 - 114
script/visualization/src/components/DetailPanel.vue

@@ -24,53 +24,14 @@
           <h4 class="text-primary font-medium truncate">{{ store.selectedNode.name }}</h4>
         </div>
 
-        <!-- 基础信息 -->
-        <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 class="space-y-2">
+          <template v-for="(value, key) in store.selectedNode" :key="key">
+            <template v-if="key !== 'name'">
+              <FieldDisplay :label="formatKey(key)" :value="value" />
+            </template>
+          </template>
         </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 class="text-success">{{ store.selectedNode.detail.postCount }}</span>
-          </div>
-
-          <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>
 
       <!-- 分隔线 -->
@@ -83,61 +44,22 @@
             class="w-4 h-0.5 shrink-0"
             :style="{ backgroundColor: edgeColor }"
           ></span>
-          <h4 class="text-secondary font-medium">{{ store.selectedEdge.type }}</h4>
+          <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 class="space-y-2">
+          <template v-for="(value, key) in store.selectedEdge" :key="key">
+            <FieldDisplay :label="formatKey(key)" :value="value" />
+          </template>
         </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 { computed, h } from 'vue'
 import { useGraphStore } from '../stores/graph'
 import { dimColors } from '../config/nodeStyle'
 import { edgeTypeColors } from '../config/edgeStyle'
@@ -160,24 +82,20 @@ const edgeColor = computed(() => {
   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) {
+// 格式化字段名
+function formatKey(key) {
   const keyMap = {
+    'name': '名称',
+    'type': '类型',
+    'dimension': '维度',
+    'domain': '域',
+    'detail': '详情',
+    'source': '源节点',
+    'target': '目标节点',
+    'score': '分数',
+    'parentPath': '父路径',
+    'postCount': '帖子数',
+    'sources': '来源点',
     'jaccard': 'Jaccard',
     'overlapCoef': '重叠系数',
     'cooccurCount': '共现次数',
@@ -187,12 +105,105 @@ function formatDetailKey(key) {
   }
   return keyMap[key] || key
 }
+</script>
 
-// 格式化详情值
-function formatDetailValue(value) {
-  if (typeof value === 'number') {
-    return value < 1 && value > 0 ? value.toFixed(3) : value
+<!-- 字段显示组件 -->
+<script>
+const FieldDisplay = {
+  name: 'FieldDisplay',
+  props: {
+    label: String,
+    value: [String, Number, Boolean, Array, Object]
+  },
+  setup(props) {
+    // 格式化值显示
+    function formatValue(val) {
+      if (val === null || val === undefined) return '-'
+      if (typeof val === 'number') {
+        return val < 1 && val > 0 ? val.toFixed(3) : val
+      }
+      if (typeof val === 'boolean') return val ? '是' : '否'
+      return val
+    }
+
+    // 格式化节点ID(提取最后部分)
+    function formatNodeId(nodeId) {
+      if (!nodeId || typeof nodeId !== 'string') return nodeId
+      if (nodeId.includes(':')) {
+        const parts = nodeId.split(':')
+        return parts[parts.length - 1]
+      }
+      return nodeId
+    }
+
+    return () => {
+      const { label, value } = props
+
+      // 空值
+      if (value === null || value === undefined || value === '') {
+        return null
+      }
+
+      // 数组类型
+      if (Array.isArray(value)) {
+        if (value.length === 0) return null
+
+        // 简单数组(字符串/数字)
+        if (typeof value[0] !== 'object') {
+          return h('div', { class: 'space-y-1' }, [
+            h('div', { class: 'flex items-center justify-between' }, [
+              h('span', { class: 'text-base-content/50' }, label),
+              h('span', { class: 'text-base-content/30' }, `${value.length}项`)
+            ]),
+            h('div', { class: 'pl-2 border-l border-base-content/20 text-[10px] text-base-content/60 max-h-20 overflow-y-auto' },
+              value.slice(0, 10).join(', ') + (value.length > 10 ? '...' : '')
+            )
+          ])
+        }
+
+        // 对象数组(如 sources)
+        return h('div', { class: 'space-y-1' }, [
+          h('div', { class: 'flex items-center justify-between' }, [
+            h('span', { class: 'text-base-content/50' }, label),
+            h('span', { class: 'text-base-content/30' }, `${value.length}项`)
+          ]),
+          h('div', { class: 'pl-2 border-l border-base-content/20 max-h-32 overflow-y-auto space-y-1' },
+            value.slice(0, 5).map((item, idx) =>
+              h('div', { key: idx, class: 'text-[10px] text-base-content/60' },
+                typeof item === 'object' ? Object.values(item).filter(v => v).join(' | ') : String(item)
+              )
+            ).concat(
+              value.length > 5 ? [h('div', { class: 'text-[10px] text-base-content/40' }, `...还有${value.length - 5}项`)] : []
+            )
+          )
+        ])
+      }
+
+      // 对象类型(如 detail)
+      if (typeof value === 'object') {
+        const entries = Object.entries(value).filter(([k, v]) => v !== null && v !== undefined && v !== '')
+        if (entries.length === 0) return null
+
+        return h('div', { class: 'space-y-1' }, [
+          h('span', { class: 'text-base-content/50' }, label),
+          h('div', { class: 'pl-2 border-l-2 border-base-content/20 space-y-1' },
+            entries.map(([k, v]) =>
+              h(FieldDisplay, { key: k, label: k, value: v })
+            )
+          )
+        ])
+      }
+
+      // 特殊处理节点ID(source/target)
+      const isNodeId = (label === '源节点' || label === '目标节点' || label === 'source' || label === 'target')
+      const displayValue = isNodeId ? formatNodeId(value) : formatValue(value)
+
+      // 基础类型
+      return h('div', { class: 'flex justify-between gap-2' }, [
+        h('span', { class: 'text-base-content/50 shrink-0' }, label),
+        h('span', { class: 'text-base-content/80 text-right break-all', title: String(value) }, displayValue)
+      ])
+    }
   }
-  return value
 }
 </script>

+ 6 - 0
script/visualization/src/components/GraphView.vue

@@ -230,6 +230,12 @@ function renderGraph() {
     .attr('class', 'graph-link')
     .attr('stroke', d => getEdgeStyle(d).color)
     .attr('stroke-width', 1.5)
+    .style('cursor', 'pointer')
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      const edgeId = `${d.source.id || d.source}|${d.type}|${d.target.id || d.target}`
+      store.selectEdge(edgeId)
+    })
 
   // 边的分数标签
   const linkLabelData = links.filter(d => getEdgeStyle(d).scoreText)

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

@@ -338,6 +338,12 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
     .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
     .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
     .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
+    .style('cursor', 'pointer')
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      const edgeId = `${d.source}|匹配|${d.target}`
+      store.selectEdge(edgeId)
+    })
     .attr('d', d => {
       const midY = (d.srcY + d.tgtY) / 2
       return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
@@ -606,6 +612,12 @@ function renderWalkedLayer() {
     .attr('stroke-opacity', d => getEdgeStyle({ type: d.type, score: d.score }).opacity)
     .attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
     .attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
+    .style('cursor', 'pointer')
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      const edgeId = `${d.source}|${d.type}|${d.target}`
+      store.selectEdge(edgeId)
+    })
     .attr('d', d => {
       // 同一层的边(Y坐标相近)用向下弯曲的曲线
       if (Math.abs(d.srcY - d.tgtY) < 10) {

+ 6 - 2
script/visualization/src/style.css

@@ -202,8 +202,12 @@
     transition: stroke-opacity 0.2s;
   }
 
-  .graph-link:hover {
-    stroke-opacity: 1;
+  .graph-link:hover,
+  .match-link:hover,
+  .walked-link:hover,
+  .tree-link:hover {
+    stroke-opacity: 1 !important;
+    stroke-width: 3 !important;
   }
 
   .graph-link-label {