Browse Source

feat: 优化可视化布局和详情展示

- 调整布局结构:左侧(人设树/相关图)和右侧(匹配列表/详情)固定宽度,中间(推导图谱/待解构帖子)平分剩余空间
- 匹配列表高度调整为1/3,详情占2/3
- 添加边列表模态框:点击"入边"/"出边"标题可查看完整边列表
- 推导图谱中组合节点不再显示名称

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 16 hours ago
parent
commit
51e235331e

+ 107 - 26
script/visualization/src/App.vue

@@ -83,7 +83,19 @@
           <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>
+              <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>
@@ -100,10 +112,10 @@
 
     <!-- 主内容区 - 帖子匹配 Tab -->
     <main v-else-if="activeTab === 'match'" class="flex flex-1 overflow-hidden">
-      <!-- 左侧:人设树 + 相关图(上下布局) -->
+      <!-- 左侧:人设树 + 相关图(固定宽度) -->
       <div
         v-if="viewConfig.personaTree || viewConfig.graph"
-        class="shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
+        class="w-[360px] shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
         :class="getLeftPanelClass()"
       >
         <!-- 人设树(上部) -->
@@ -141,21 +153,52 @@
           <GraphView class="h-full" :show-expand="true" />
         </div>
       </div>
-      <!-- 中间:推导图谱(有数据且配置显示时才显示) -->
+
+      <!-- 中间区域:推导图谱 + 待解构帖子(平分剩余空间) -->
       <div
-        v-if="showDerivation"
-        class="shrink-0 bg-base-200 border-l border-base-300 transition-all duration-200"
-        :class="getDerivationPanelClass()"
+        v-if="showDerivation || viewConfig.postTree"
+        class="flex flex-1 min-w-0 transition-all duration-200"
+        :class="getMiddleAreaClass()"
       >
-        <DerivationView class="h-full" />
+        <!-- 推导图谱 -->
+        <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
-        v-if="viewConfig.postTree"
-        class="bg-base-200 border-l border-base-300 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>
@@ -178,12 +221,17 @@ const activeTab = ref('match')
 
 // 视图配置(默认全部勾选)
 const viewConfig = reactive({
-  personaTree: true,
-  graph: true,
-  derivation: true,
-  postTree: true
+  personaTree: true,      // 人设树
+  graph: true,            // 相关图
+  derivation: true,       // 推导图谱
+  postTree: true,         // 待解构帖子
+  matchList: true,        // 匹配列表
+  detail: true            // 详情
 })
 
+// 匹配列表折叠状态
+const matchListCollapsed = ref(false)
+
 // 检测推导图谱是否有数据
 const hasDerivationData = computed(() => {
   const postGraph = store.currentPostGraph
@@ -208,13 +256,18 @@ function switchTab(tab) {
 
 
 // ==================== 布局类计算 ====================
+
+// 左侧面板(人设树+相关图):固定宽度,放大时占满
 function getLeftPanelClass() {
   const panel = store.expandedPanel
-  if (panel === 'post-tree' || panel === 'derivation') return 'w-0 opacity-0 overflow-hidden'
-  if (panel === 'persona-tree' || panel === 'graph') return 'flex-1'
-  return 'w-[360px]'
+  // 其他面板放大时,左侧隐藏
+  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 getPersonaTreeInnerClass() {
   const panel = store.expandedPanel
   if (panel === 'persona-tree') return 'flex-1'
@@ -224,17 +277,45 @@ function getPersonaTreeInnerClass() {
   return 'h-1/2 shrink-0'
 }
 
-function getPostTreeClass() {
+// 中间区域(推导图谱+待解构帖子容器)
+function getMiddleAreaClass() {
   const panel = store.expandedPanel
-  if (panel === 'post-tree') return 'flex-1'
-  if (panel === 'persona-tree' || panel === 'graph' || panel === 'derivation') return 'w-0 opacity-0 overflow-hidden'
-  return 'flex-1'
+  // 左侧放大时,中间隐藏
+  if (panel === 'persona-tree' || panel === 'graph') return '!w-0 !flex-none opacity-0 overflow-hidden'
+  return ''
 }
 
+// 推导图谱面板:与待解构帖子平分
 function getDerivationPanelClass() {
   const panel = store.expandedPanel
+  // 推导图谱放大
   if (panel === 'derivation') return 'flex-1'
-  if (panel === 'persona-tree' || panel === 'graph' || panel === 'post-tree') return 'w-0 opacity-0 overflow-hidden'
-  return 'w-[400px]'
+  // 待解构帖子放大,推导隐藏
+  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 === '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>

+ 5 - 3
script/visualization/src/components/DerivationView.vue

@@ -646,8 +646,8 @@ function handleNodeClick(event, d) {
     applySelectedHighlight()
   }
 
-  // 联动 store
-  store.selectNode(d.id)
+  // 联动 store(传完整节点对象,以便详情面板显示入边/出边)
+  store.selectNode(d)
 }
 
 // 应用选中状态的高亮(只高亮选中节点的入边路径,基于当前显示的边)
@@ -923,11 +923,13 @@ function render() {
     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
     })

+ 127 - 13
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>
@@ -154,7 +168,10 @@
               </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">入边 ({{ nodeInEdges.length }})</div>
+                <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"
@@ -170,7 +187,10 @@
               </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">出边 ({{ nodeOutEdges.length }})</div>
+                <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"
@@ -256,6 +276,56 @@
         <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>
 
@@ -271,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)
@@ -294,6 +385,29 @@ 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