Bläddra i källkod

feat: 优化锁定按钮交互和样式

- 锁定按钮改为tspan追加到节点文字后,无空隙
- 只有按钮有呼吸灯效果,节点名称保持原样式
- 点击节点名称选中节点,点击按钮才触发锁定/解锁
- 按钮样式:蓝色🔒锁定,橙色🔓解锁,加粗字体
- hover 400ms后显示按钮
- 两个视图(PostTreeView/GraphView)样式统一

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 15 timmar sedan
förälder
incheckning
3573e60bb7

+ 99 - 107
script/visualization/src/components/GraphView.vue

@@ -322,22 +322,24 @@ function renderGraph() {
         d.fx = null
         d.fy = null
       }))
-    .on('click', (e, d) => {
-      e.stopPropagation()
-      store.selectNode(d.id)
-    })
-    .on('mouseenter', (e, d) => {
+    .on('mouseenter', function(e, d) {
       if (d.isCenter) return  // 中心节点不处理
       // 路径计算由 store 统一处理,标记来源为 graph
       store.computeHoverPath(centerNodeId, d.id, 'graph')
-      // 显示锁定按钮
+      // 显示锁定按钮(在当前节点上)
       if (store.hoverPathNodes.size > 0) {
-        showLockButton(d.x, d.y, centerNodeId)
+        showLockButton(this)
       }
     })
     .on('mouseleave', () => {
+      // 如果已锁定,不清除
+      if (store.lockedHoverNodeId) return
       store.clearHover()
-      delayHideLockButton()
+      hideLockButton()
+    })
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      store.selectNode(d.id)
     })
 
   // 节点形状(使用统一配置)
@@ -377,125 +379,115 @@ function renderGraph() {
     node.attr('transform', d => `translate(${d.x},${d.y})`)
   })
 
-  // 创建锁定按钮
-  createLockButton()
-
   // 应用初始高亮状态
   nextTick(updateHighlight)
 }
 
 // 锁定按钮相关
-let lockButtonG = null
-let hideButtonTimer = null
+let showButtonTimer = null  // 延迟显示按钮的定时器
+
+// 在节点文字后面添加锁定按钮(作为独立的 tspan)
+function showLockButton(nodeEl, immediate = false) {
+  if (!nodeEl) return
+
+  const node = d3.select(nodeEl)
+  const textEl = node.select('text')
+  if (textEl.empty()) return
+
+  // 如果已有按钮,只更新状态
+  let btn = textEl.select('.lock-btn')
+  if (!btn.empty()) {
+    const isLocked = !!store.lockedHoverNodeId
+    btn.text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+    if (!isLocked) startBreathingAnimation(btn)
+    return
+  }
 
-// 创建锁定按钮
-function createLockButton() {
-  if (!graphG) return
+  // 创建按钮的函数
+  const createBtn = () => {
+    // 先清除其他节点的按钮
+    d3.selectAll('.lock-btn').remove()
+
+    const isLocked = !!store.lockedHoverNodeId
+
+    // 添加按钮 tspan(紧跟在文字后面)
+    const btn = textEl.append('tspan')
+      .attr('class', 'lock-btn')
+      .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+      .attr('font-weight', 'bold')
+      .style('cursor', 'pointer')
+      .text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+      .on('click', (e) => {
+        e.stopPropagation()
+        handleLockClick()
+      })
 
-  // 移除旧按钮
-  graphG.selectAll('.lock-button').remove()
+    // 呼吸灯动画(未锁定时)
+    if (!isLocked) {
+      startBreathingAnimation(btn)
+    }
+  }
 
-  // 创建按钮组
-  lockButtonG = graphG.append('g')
-    .attr('class', 'lock-button')
-    .style('display', 'none')
-    .style('cursor', 'pointer')
-    .on('click', (e) => {
-      e.stopPropagation()
-      if (store.lockedHoverNodeId) {
-        // 已锁定,点击解锁
-        store.clearLockedHover()
-        hideLockButton()
-      } else if (currentStartNodeId) {
-        // 未锁定,点击锁定
-        store.lockCurrentHover(currentStartNodeId)
-        updateLockButtonIcon(true)
-      }
-    })
-    .on('mouseenter', () => {
-      if (hideButtonTimer) {
-        clearTimeout(hideButtonTimer)
-        hideButtonTimer = null
-      }
-    })
-    .on('mouseleave', () => {
-      if (!store.lockedHoverNodeId) {
-        hideLockButton()
-      }
-    })
+  // 如果是立即显示,不需要延迟
+  if (immediate) {
+    createBtn()
+    return
+  }
 
-  // 按钮背景(较大的点击区域)
-  lockButtonG.append('rect')
-    .attr('x', -20)
-    .attr('y', -10)
-    .attr('width', 40)
-    .attr('height', 20)
-    .attr('rx', 4)
-    .attr('fill', '#2d3748')
-    .attr('stroke', '#4a5568')
-    .attr('stroke-width', 1)
-
-  // 锁定状态文字
-  lockButtonG.append('text')
-    .attr('class', 'lock-icon')
-    .attr('text-anchor', 'middle')
-    .attr('dy', '0.35em')
-    .attr('font-size', '10px')
-    .attr('fill', '#fff')
-    .text('锁定')
-}
+  // 取消之前的显示定时器
+  if (showButtonTimer) {
+    clearTimeout(showButtonTimer)
+  }
 
-// 更新锁定按钮图标
-function updateLockButtonIcon(isLocked) {
-  if (!lockButtonG) return
-  const text = lockButtonG.select('.lock-icon')
-  text.text(isLocked ? '解锁' : '锁定')
-  // 锁定状态用不同颜色
-  lockButtonG.select('rect').attr('fill', isLocked ? '#744210' : '#2d3748')
+  // 延迟 400ms 后显示
+  showButtonTimer = setTimeout(() => {
+    createBtn()
+    showButtonTimer = null
+  }, 400)
 }
 
-// 当前起始节点ID(用于锁定按钮)
-let currentStartNodeId = null
-
-// 显示锁定按钮(紧贴节点右侧)
-function showLockButton(x, y, startNodeId) {
-  if (!lockButtonG) return
-  currentStartNodeId = startNodeId
-
-  if (hideButtonTimer) {
-    clearTimeout(hideButtonTimer)
-    hideButtonTimer = null
+// 呼吸灯动画(只有按钮部分,蓝色呼吸)
+function startBreathingAnimation(btn) {
+  function breathe() {
+    if (btn.empty() || !btn.node()) return
+    if (store.lockedHoverNodeId) return
+    btn
+      .transition()
+      .duration(800)
+      .attr('fill', '#90cdf4')
+      .transition()
+      .duration(800)
+      .attr('fill', '#63b3ed')
+      .on('end', breathe)
   }
-
-  // 更新图标状态
-  updateLockButtonIcon(!!store.lockedHoverNodeId)
-
-  // 按钮紧贴节点右侧(只偏移8px)
-  lockButtonG
-    .attr('transform', `translate(${x + 8}, ${y})`)
-    .style('display', 'block')
+  breathe()
 }
 
 // 隐藏锁定按钮
 function hideLockButton() {
-  if (!lockButtonG) return
-  lockButtonG.style('display', 'none')
+  if (showButtonTimer) {
+    clearTimeout(showButtonTimer)
+    showButtonTimer = null
+  }
+  d3.selectAll('.lock-btn').interrupt().remove()
 }
 
-// 延迟隐藏锁定按钮
-function delayHideLockButton() {
-  // 如果已锁定,不隐藏
-  if (store.lockedHoverNodeId) return
-
-  if (hideButtonTimer) {
-    clearTimeout(hideButtonTimer)
-  }
-  hideButtonTimer = setTimeout(() => {
-    if (!store.lockedHoverNodeId) {
-      hideLockButton()
+// 处理锁定按钮点击
+function handleLockClick() {
+  const startNodeId = store.selectedNodeId
+  if (store.lockedHoverNodeId) {
+    store.clearLockedHover()
+    hideLockButton()
+  } else if (startNodeId) {
+    store.lockCurrentHover(startNodeId)
+    const btn = d3.select('.lock-btn')
+    if (!btn.empty()) {
+      btn.interrupt()
+        .text(' 🔓解锁')
+        .attr('fill', '#f6ad55')
     }
-    hideButtonTimer = null
-  }, 200)
+  }
 }
 
 // 点击空白取消

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

@@ -932,117 +932,111 @@ function renderWalkedLayer() {
 }
 
 // 锁定按钮相关
-let lockButtonG = null
-let hideButtonTimer = null
-
-// 创建锁定按钮(在主组中创建一次)
-function createLockButton() {
-  if (!mainG) return
+let showButtonTimer = null  // 延迟显示按钮的定时器
+
+// 在节点文字后面添加锁定按钮(作为独立的 tspan)
+function showLockButton(nodeEl, immediate = false) {
+  if (!nodeEl) return
+
+  const node = d3.select(nodeEl)
+  const textEl = node.select('text')
+  if (textEl.empty()) return
+
+  // 如果已有按钮,只更新状态
+  let btn = textEl.select('.lock-btn')
+  if (!btn.empty()) {
+    const isLocked = !!store.lockedHoverNodeId
+    btn.text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+    if (!isLocked) startBreathingAnimation(btn)
+    return
+  }
 
-  // 移除旧按钮
-  mainG.selectAll('.lock-button').remove()
+  // 创建按钮的函数
+  const createBtn = () => {
+    // 先清除其他节点的按钮
+    d3.selectAll('.lock-btn').remove()
+
+    const isLocked = !!store.lockedHoverNodeId
+
+    // 添加按钮 tspan(紧跟在文字后面)
+    const btn = textEl.append('tspan')
+      .attr('class', 'lock-btn')
+      .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+      .attr('font-weight', 'bold')
+      .style('cursor', 'pointer')
+      .text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+      .on('click', (e) => {
+        e.stopPropagation()
+        handleLockClick()
+      })
 
-  // 创建按钮组
-  lockButtonG = mainG.append('g')
-    .attr('class', 'lock-button')
-    .style('display', 'none')
-    .style('cursor', 'pointer')
-    .on('click', (e) => {
-      e.stopPropagation()
-      const startNodeId = store.selectedNodeId
-      if (store.lockedHoverNodeId) {
-        // 已锁定,点击解锁
-        store.clearLockedHover()
-        hideLockButton()
-      } else if (startNodeId) {
-        // 未锁定,点击锁定
-        store.lockCurrentHover(startNodeId)
-        updateLockButtonIcon(true)
-      }
-    })
-    .on('mouseenter', () => {
-      // 鼠标进入按钮,取消隐藏定时器
-      if (hideButtonTimer) {
-        clearTimeout(hideButtonTimer)
-        hideButtonTimer = null
-      }
-    })
-    .on('mouseleave', () => {
-      // 鼠标离开按钮,如果未锁定则隐藏
-      if (!store.lockedHoverNodeId) {
-        hideLockButton()
-      }
-    })
+    // 呼吸灯动画(未锁定时)
+    if (!isLocked) {
+      startBreathingAnimation(btn)
+    }
+  }
 
-  // 按钮背景(较大的点击区域)
-  lockButtonG.append('rect')
-    .attr('x', -20)
-    .attr('y', -10)
-    .attr('width', 40)
-    .attr('height', 20)
-    .attr('rx', 4)
-    .attr('fill', '#2d3748')
-    .attr('stroke', '#4a5568')
-    .attr('stroke-width', 1)
+  // 如果是立即显示,不需要延迟
+  if (immediate) {
+    createBtn()
+    return
+  }
 
-  // 锁定状态文字
-  lockButtonG.append('text')
-    .attr('class', 'lock-icon')
-    .attr('text-anchor', 'middle')
-    .attr('dy', '0.35em')
-    .attr('font-size', '10px')
-    .attr('fill', '#fff')
-    .text('锁定')
-}
+  // 取消之前的显示定时器
+  if (showButtonTimer) {
+    clearTimeout(showButtonTimer)
+  }
 
-// 更新锁定按钮图标
-function updateLockButtonIcon(isLocked) {
-  if (!lockButtonG) return
-  const text = lockButtonG.select('.lock-icon')
-  text.text(isLocked ? '解锁' : '锁定')
-  // 锁定状态用不同颜色
-  lockButtonG.select('rect').attr('fill', isLocked ? '#744210' : '#2d3748')
+  // 延迟 400ms 后显示
+  showButtonTimer = setTimeout(() => {
+    createBtn()
+    showButtonTimer = null
+  }, 400)
 }
 
-// 显示锁定按钮(紧贴节点右上方)
-function showLockButton(x, y) {
-  if (!lockButtonG) return
-
-  // 取消隐藏定时器
-  if (hideButtonTimer) {
-    clearTimeout(hideButtonTimer)
-    hideButtonTimer = null
+// 呼吸灯动画(只有按钮部分,蓝色呼吸)
+function startBreathingAnimation(btn) {
+  function breathe() {
+    if (btn.empty() || !btn.node()) return
+    if (store.lockedHoverNodeId) return
+    btn
+      .transition()
+      .duration(800)
+      .attr('fill', '#90cdf4')
+      .transition()
+      .duration(800)
+      .attr('fill', '#63b3ed')
+      .on('end', breathe)
   }
-
-  // 更新图标状态
-  updateLockButtonIcon(!!store.lockedHoverNodeId)
-
-  // 按钮紧贴节点右侧(只偏移8px,确保鼠标能顺利移到按钮)
-  lockButtonG
-    .attr('transform', `translate(${x + 8}, ${y})`)
-    .style('display', 'block')
+  breathe()
 }
 
 // 隐藏锁定按钮
 function hideLockButton() {
-  if (!lockButtonG) return
-  lockButtonG.style('display', 'none')
+  if (showButtonTimer) {
+    clearTimeout(showButtonTimer)
+    showButtonTimer = null
+  }
+  d3.selectAll('.lock-btn').interrupt().remove()
 }
 
-// 延迟隐藏锁定按钮(给用户移动到按钮的时间)
-function delayHideLockButton() {
-  // 如果已锁定,不隐藏
-  if (store.lockedHoverNodeId) return
-
-  if (hideButtonTimer) {
-    clearTimeout(hideButtonTimer)
-  }
-  hideButtonTimer = setTimeout(() => {
-    if (!store.lockedHoverNodeId) {
-      hideLockButton()
+// 处理锁定按钮点击
+function handleLockClick() {
+  const startNodeId = store.selectedNodeId
+  if (store.lockedHoverNodeId) {
+    store.clearLockedHover()
+    hideLockButton()
+  } else if (startNodeId) {
+    store.lockCurrentHover(startNodeId)
+    // 更新按钮显示
+    const btn = d3.select('.lock-btn')
+    if (!btn.empty()) {
+      btn.interrupt()
+        .text(' 🔓解锁')
+        .attr('fill', '#f6ad55')
     }
-    hideButtonTimer = null
-  }, 200)
+  }
 }
 
 // 设置 hover 处理器(在所有元素创建后调用)
@@ -1053,43 +1047,42 @@ function setupHoverHandlers() {
   const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
   const startNodeId = store.selectedNodeId
 
-  // 创建锁定按钮
-  createLockButton()
-
   // 如果已有锁定状态,显示解锁按钮
   if (store.lockedHoverNodeId) {
     const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
-    if (lockedNodeInfo) {
-      showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
+    if (lockedNodeInfo?.element) {
+      showLockButton(lockedNodeInfo.element, true)
     }
   }
 
   // 添加 hover 处理器(路径计算由 store 统一处理)
   allNodes
-    .on('mouseenter', (event, d) => {
+    .on('mouseenter', function(event, d) {
       const nodeId = d.data?.id || d.id
       // 排除起始节点
       if (nodeId === startNodeId) return
 
       store.computeHoverPath(startNodeId, nodeId, 'post-tree')
 
-      // 显示锁定按钮
-      // 如果已锁定,按钮保持在锁定节点位置;否则跟随hover节点
-      if (store.lockedHoverNodeId) {
-        const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
-        if (lockedNodeInfo) {
-          showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
-        }
-      } else {
-        const nodeInfo = nodeElements[nodeId]
-        if (nodeInfo && store.hoverPathNodes.size > 0) {
-          showLockButton(nodeInfo.x - 50, nodeInfo.y - 25)
+      // 显示锁定按钮(在当前hover节点上)
+      if (store.hoverPathNodes.size > 0) {
+        // 如果已锁定,按钮显示在锁定节点上
+        if (store.lockedHoverNodeId) {
+          const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
+          if (lockedNodeInfo?.element) {
+            showLockButton(lockedNodeInfo.element, true)
+          }
+        } else {
+          // 未锁定,按钮显示在当前hover节点上
+          showLockButton(this)
         }
       }
     })
     .on('mouseleave', () => {
+      // 如果已锁定,不清除
+      if (store.lockedHoverNodeId) return
       store.clearHover()
-      delayHideLockButton()
+      hideLockButton()
     })
 }