Prechádzať zdrojové kódy

feat: 帖子树垂直布局 + 面板放大功能

- 帖子树改为从上到下垂直展示
- 各面板添加放大/恢复按钮
- 支持动态布局切换:人设树、相关图、帖子树可分别放大
- 放大时其他面板收起

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 3 dní pred
rodič
commit
df6e228346

+ 67 - 7
script/visualization/src/App.vue

@@ -61,19 +61,50 @@
     <!-- 主内容区 - 帖子匹配 Tab -->
     <main v-show="activeTab === 'match'" class="flex flex-1 overflow-hidden">
       <!-- 左侧:人设树 + 相关图(上下布局) -->
-      <div class="w-[360px] shrink-0 bg-base-200 border-r border-base-300 flex flex-col">
-        <!-- 人设树(上部,弹性填充) -->
-        <TreeView class="flex-1 min-h-0" />
+      <div
+        class="shrink-0 bg-base-200 border-r border-base-300 flex flex-col transition-all duration-200"
+        :class="getLeftPanelClass()"
+      >
+        <!-- 人设树(上部) -->
+        <div
+          class="flex flex-col transition-all duration-200 border-b border-base-300"
+          :class="getPersonaTreeClass()"
+        >
+          <div class="flex items-center justify-between px-4 py-1 bg-base-300 text-xs text-base-content/60 shrink-0">
+            <span>人设树</span>
+            <div class="flex gap-1">
+              <button
+                v-if="store.expandedPanel !== 'persona-tree'"
+                @click="store.expandPanel('persona-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>
+            </div>
+          </div>
+          <TreeView class="flex-1 min-h-0" :hide-header="true" />
+        </div>
         <!-- 相关图(下部,未激活时收起) -->
         <div
-          class="border-t border-base-300 transition-all duration-200"
-          :class="store.selectedNodeId ? 'h-[45%]' : 'h-8'"
+          v-show="store.expandedPanel !== 'persona-tree'"
+          class="flex flex-col transition-all duration-200"
+          :class="getGraphClass()"
         >
-          <GraphView :collapsed="!store.selectedNodeId" />
+          <GraphView :collapsed="!store.selectedNodeId" :show-expand="true" />
         </div>
       </div>
       <!-- 右侧:帖子树 -->
-      <PostTreeView class="flex-1 bg-base-200" />
+      <div
+        class="bg-base-200 flex flex-col transition-all duration-200"
+        :class="getPostTreeClass()"
+      >
+        <PostTreeView class="flex-1" :show-expand="true" />
+      </div>
     </main>
 
     <!-- 详情面板 -->
@@ -110,4 +141,33 @@ const edgeTypesWithColors = computed(() => {
   })
   return result
 })
+
+// ==================== 布局类计算 ====================
+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]'
+}
+
+function getPersonaTreeClass() {
+  const panel = store.expandedPanel
+  if (panel === 'persona-tree') return 'flex-1'
+  if (panel === 'graph') return 'h-8 overflow-hidden'
+  return 'flex-1'
+}
+
+function getGraphClass() {
+  const panel = store.expandedPanel
+  if (panel === 'graph') return 'flex-1'
+  if (store.selectedNodeId) return 'h-[45%]'
+  return 'h-8'
+}
+
+function getPostTreeClass() {
+  const panel = store.expandedPanel
+  if (panel === 'post-tree') return 'flex-1'
+  if (panel === 'persona-tree' || panel === 'graph') return 'w-0 opacity-0 overflow-hidden'
+  return 'flex-1'
+}
 </script>

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

@@ -9,6 +9,20 @@
       <button v-if="!collapsed" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
         {{ showConfig ? '隐藏配置' : '游走配置' }}
       </button>
+      <template v-if="showExpand && !collapsed">
+        <button
+          v-if="store.expandedPanel !== 'graph'"
+          @click="store.expandPanel('graph')"
+          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>
     </div>
 
     <!-- 游走配置面板 -->
@@ -62,6 +76,10 @@ const props = defineProps({
   collapsed: {
     type: Boolean,
     default: false
+  },
+  showExpand: {
+    type: Boolean,
+    default: false
   }
 })
 

+ 45 - 22
script/visualization/src/components/PostTreeView.vue

@@ -3,9 +3,25 @@
     <!-- 头部 -->
     <div 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.highlightedPostNodeIds.size > 0" class="text-primary">
-        已高亮 {{ store.highlightedPostNodeIds.size }} 个节点
-      </span>
+      <div class="flex items-center gap-2">
+        <span v-if="store.highlightedPostNodeIds.size > 0" class="text-primary">
+          已高亮 {{ store.highlightedPostNodeIds.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>
+      </div>
     </div>
 
     <!-- 帖子选择下拉框 -->
@@ -38,6 +54,13 @@ import { ref, computed, onMounted, watch } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
+const props = defineProps({
+  showExpand: {
+    type: Boolean,
+    default: false
+  }
+})
+
 const store = useGraphStore()
 
 const containerRef = ref(null)
@@ -98,30 +121,30 @@ function renderTree() {
   const root = d3.hierarchy(treeData)
   currentRoot = root
 
-  // 智能计算树的尺寸
+  // 智能计算树的尺寸(垂直布局)
   const allNodes = root.descendants()
   const maxDepth = d3.max(allNodes, d => d.depth)
   const leafCount = allNodes.filter(d => !d.children).length
 
-  // 高度:基于叶子节点数量
-  const treeHeight = Math.max(600, leafCount * 20 + 100)
-  // 度:根据深度
-  const treeWidth = Math.max(400, (maxDepth + 1) * 120 + 50)
+  // 宽度:基于叶子节点数量(垂直布局时叶子横向展开)
+  const treeWidth = Math.max(400, leafCount * 80 + 100)
+  // 度:根据深度
+  const treeHeight = Math.max(400, (maxDepth + 1) * 100 + 50)
 
   svg.attr('width', treeWidth).attr('height', treeHeight + 50)
 
-  // 创建树布局
+  // 创建树布局(垂直方向:从上到下)
   const treeLayout = d3.tree()
-    .size([treeHeight - 50, treeWidth - 100])
-    .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
+    .size([treeWidth - 100, treeHeight - 50])
+    .separation((a, b) => a.parent === b.parent ? 1 : 1.5)
 
   treeLayout(root)
 
   // 创建主组
   const g = svg.append('g')
-    .attr('transform', 'translate(25, 25)')
+    .attr('transform', 'translate(50, 25)')
 
-  // 绘制边
+  // 绘制边(垂直方向)
   g.append('g')
     .attr('class', 'tree-edges')
     .selectAll('.tree-link')
@@ -133,11 +156,11 @@ function renderTree() {
     .attr('stroke-opacity', 0.3)
     .attr('stroke-width', 1)
     .attr('d', d => {
-      const midX = (d.source.y + d.target.y) / 2
-      return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
+      const midY = (d.source.y + d.target.y) / 2
+      return `M${d.source.x},${d.source.y} C${d.source.x},${midY} ${d.target.x},${midY} ${d.target.x},${d.target.y}`
     })
 
-  // 绘制节点
+  // 绘制节点(垂直布局:x 是水平位置,y 是垂直位置)
   const nodes = g.append('g')
     .attr('class', 'tree-nodes')
     .selectAll('.tree-node')
@@ -149,7 +172,7 @@ function renderTree() {
       if (store.highlightedPostNodeIds.has(d.data.id)) cls += ' highlighted'
       return cls
     })
-    .attr('transform', d => `translate(${d.y},${d.x})`)
+    .attr('transform', d => `translate(${d.x},${d.y})`)
     .style('cursor', 'pointer')
     .on('click', handleNodeClick)
 
@@ -186,11 +209,11 @@ function renderTree() {
     nodeElements[d.data.id] = this
   })
 
-  // 节点标签
+  // 节点标签(垂直布局:标签在节点下方或右侧)
   nodes.append('text')
-    .attr('dy', '0.31em')
-    .attr('x', d => d.children ? -8 : 8)
-    .attr('text-anchor', d => d.children ? 'end' : 'start')
+    .attr('dy', d => d.children ? -10 : 4)
+    .attr('dx', d => d.children ? 0 : 10)
+    .attr('text-anchor', d => d.children ? 'middle' : 'start')
     .attr('fill', d => {
       const isRoot = d.depth === 0
       const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
@@ -208,7 +231,7 @@ function renderTree() {
     })
     .text(d => {
       const name = d.data.name
-      const maxLen = d.children ? 6 : 8
+      const maxLen = 10
       return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
     })
 }

+ 8 - 1
script/visualization/src/components/TreeView.vue

@@ -1,7 +1,7 @@
 <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">
+    <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 }} 个节点
@@ -66,6 +66,13 @@ import { ref, computed, onMounted, watch } from 'vue'
 import * as d3 from 'd3'
 import { useGraphStore } from '../stores/graph'
 
+const props = defineProps({
+  hideHeader: {
+    type: Boolean,
+    default: false
+  }
+})
+
 const store = useGraphStore()
 
 const containerRef = ref(null)

+ 17 - 1
script/visualization/src/stores/graph.js

@@ -145,6 +145,18 @@ export const useGraphStore = defineStore('graph', () => {
   // 帖子树数据
   const postTreeData = computed(() => currentPostGraph.value?.tree)
 
+  // ==================== 布局状态 ====================
+  // 'default' | 'persona-tree' | 'graph' | 'post-tree'
+  const expandedPanel = ref('default')
+
+  function expandPanel(panel) {
+    expandedPanel.value = panel
+  }
+
+  function resetLayout() {
+    expandedPanel.value = 'default'
+  }
+
   return {
     // 人设图谱
     graphData,
@@ -167,6 +179,10 @@ export const useGraphStore = defineStore('graph', () => {
     highlightedPostNodeIds,
     getPostNode,
     selectPostNode,
-    clearPostSelection
+    clearPostSelection,
+    // 布局
+    expandedPanel,
+    expandPanel,
+    resetLayout
   }
 })