Просмотр исходного кода

chore: 更新前端配置及组件引用

- 新增 .claude/settings.local.json 配置文件,定义开发工具权限
- 将 Dashboard 中的 CategoryTree 组件替换为 CategoryTreeNew 组件
刘文武 9 часов назад
Родитель
Сommit
a5d7233a53

+ 12 - 0
knowhub/frontend/.claude/settings.local.json

@@ -0,0 +1,12 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(npx tsc *)",
+      "Bash(npx eslint *)",
+      "Bash(git stash *)",
+      "Bash(npx vite *)",
+      "Skill(superpowers-chrome:browsing)",
+      "mcp__plugin_superpowers-chrome_chrome__use_browser"
+    ]
+  }
+}

+ 821 - 263
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,15 +1,67 @@
 import { useState, useRef, useEffect, useCallback, useMemo } from "react";
 import { createPortal } from "react-dom";
 import { cn } from "../../lib/utils";
-import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree, FileText } from "lucide-react";
+import {
+  ChevronRight,
+  ChevronDown,
+  ChevronLeft,
+  ChevronUp,
+  ChevronsDown,
+  ChevronsUp,
+  RotateCcw,
+  ZoomIn,
+  ZoomOut,
+  Maximize,
+  FolderTree,
+  Info,
+  Search,
+} from "lucide-react";
+
+// 统一的数字徽标样式 (参考 .node-count) — 蓝色 accent + monospace
+const COUNT_BADGE_BASE =
+  "inline-flex items-center text-[11px] font-semibold leading-[1.5] px-[7px] py-px rounded-full tabular-nums [letter-spacing:-0.02em] [font-family:'JetBrains_Mono','SF_Mono',Consolas,monospace]";
+const COUNT_BADGE_BLUE = "bg-[#dbeafe] text-[#2563eb]";
+// expandable 版本: hover 时背景加深 (.node-count.expandable:hover)
+const COUNT_BADGE_BLUE_EXPANDABLE = "bg-[#dbeafe] text-[#2563eb] hover:bg-[#bfdbfe] cursor-pointer transition-colors";
+const COUNT_BADGE_BLUE_EXPANDED = "bg-[#bfdbfe] text-[#1e40af] cursor-pointer transition-colors";
+
+// 节点 border-left + hover 高亮统一使用蓝色 (#60a5fa, rgb 96,165,250)
+// hover 公式见 className 中的 hover:border-[rgba(96,165,250,...)] / hover:shadow-[...]
+const NODE_BORDER = "#60a5fa";
+
+// 与参考稿一致的维度配色 (border-left + hover ring + section header)
+type DimStyle = {
+  label: string;
+  border: string;
+  rgb: string;
+  badge: string;
+  badgeBg: string;
+};
+const DIM_STYLES: Record<string, DimStyle> = {
+  实质: { label: "实质", border: "#f9a825", rgb: "249, 168, 37", badge: "#a16207", badgeBg: "#fef3c7" },
+  形式: { label: "形式", border: "#42a5f5", rgb: "66, 165, 245", badge: "#1d4ed8", badgeBg: "#dbeafe" },
+  意图: { label: "意图", border: "#66bb6a", rgb: "102, 187, 106", badge: "#15803d", badgeBg: "#dcfce7" },
+  作用: { label: "作用", border: "#ab47bc", rgb: "171, 71, 188", badge: "#7e22ce", badgeBg: "#f3e8ff" },
+  感受: { label: "感受", border: "#ec407a", rgb: "236, 64, 122", badge: "#be185d", badgeBg: "#fce7f3" },
+};
+const DEFAULT_DIM: DimStyle = {
+  label: "其他",
+  border: "#94a3b8",
+  rgb: "148, 163, 184",
+  badge: "#475569",
+  badgeBg: "#e2e8f0",
+};
+const DIM_ORDER = ["实质", "形式", "意图", "作用", "感受"];
+
+type ElementItem = { name?: string; count?: number; element_id?: string | number };
 
 interface NodeProps {
   node: any;
   onSelect: (node: any) => void;
   onOpenDetail?: (node: any) => void;
   selectedIds: Set<string>;
-  level: number;
-  highlightLeafNames: Set<string> | null; // null = no filter active
+  depth: number;
+  highlightLeafNames: Set<string> | null;
   subtreeHighlightNodeIds: Set<string> | null;
   sourceNodeIds: Set<string> | null;
   patternNodeIds: Set<string> | null;
@@ -24,18 +76,18 @@ interface NodeProps {
       patternCount: number;
     }
   >;
-  dimensionColor: string; // hex color for the node's dimension
+  dimStyle: DimStyle;
   focusedTreeNodeId?: string | number | null;
   focusTrigger?: number;
   isOpen: (nodeId: string) => boolean;
   onToggleOpen: (nodeId: string) => void;
-  // 与 RelationCard 4 态视觉对齐所需的输入
   directHighlightNodeIds: Set<string> | null;
   indirectHighlightNodeIds: Set<string> | null;
   hasActiveFilter: boolean;
+  searchTokens: string[];
+  visibleDepth: number; // -1 = 不限
 }
 
-// Returns true if this node or any descendant is in the highlight set
 function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | null): boolean {
   if (!highlightLeafNames) return true;
   const hasChildren = node.children && node.children.length > 0;
@@ -43,18 +95,59 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
+function HighlightedText({ text, tokens }: { text: string; tokens: string[] }) {
+  if (!tokens.length) return <>{text}</>;
+  const lower = text.toLowerCase();
+  const ranges: Array<[number, number]> = [];
+  for (const t of tokens) {
+    if (!t) continue;
+    let from = 0;
+    while (from <= lower.length) {
+      const idx = lower.indexOf(t, from);
+      if (idx < 0) break;
+      ranges.push([idx, idx + t.length]);
+      from = idx + t.length;
+    }
+  }
+  if (!ranges.length) return <>{text}</>;
+  ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+  const merged: Array<[number, number]> = [ranges[0].slice() as [number, number]];
+  for (let i = 1; i < ranges.length; i++) {
+    const [s, e] = ranges[i];
+    const last = merged[merged.length - 1];
+    if (s <= last[1]) last[1] = Math.max(last[1], e);
+    else merged.push([s, e]);
+  }
+  const out: React.ReactNode[] = [];
+  let cursor = 0;
+  merged.forEach(([s, e], i) => {
+    if (s > cursor) out.push(text.slice(cursor, s));
+    out.push(
+      <mark
+        key={i}
+        className="rounded-[2px] bg-[#fff3a3] text-inherit px-[1px] font-semibold"
+      >
+        {text.slice(s, e)}
+      </mark>,
+    );
+    cursor = e;
+  });
+  if (cursor < text.length) out.push(text.slice(cursor));
+  return <>{out}</>;
+}
+
 function HorizontalTreeNode({
   node,
   onSelect,
   onOpenDetail,
   selectedIds,
-  level,
+  depth,
   highlightLeafNames,
   subtreeHighlightNodeIds,
   sourceNodeIds,
   patternNodeIds,
   nodeMetricsMap,
-  dimensionColor,
+  dimStyle,
   focusedTreeNodeId = null,
   focusTrigger = 0,
   isOpen,
@@ -62,6 +155,8 @@ function HorizontalTreeNode({
   directHighlightNodeIds,
   indirectHighlightNodeIds,
   hasActiveFilter,
+  searchTokens,
+  visibleDepth,
 }: NodeProps) {
   const expanded = isOpen(String(node.id));
   const [hoveredMetric, setHoveredMetric] = useState<null | {
@@ -71,8 +166,10 @@ function HorizontalTreeNode({
     x: number;
     y: number;
   }>(null);
+  const [elementsOpen, setElementsOpen] = useState(false);
   const nodeRef = useRef<HTMLDivElement>(null);
   const hasChildren = node.children && node.children.length > 0;
+  const elements: ElementItem[] = Array.isArray(node.elements) ? node.elements : [];
   const metrics = nodeMetricsMap[String(node.id)] || {
     reqCount: 0,
     procCount: 0,
@@ -81,18 +178,21 @@ function HorizontalTreeNode({
     nodeCount: 0,
     patternCount: 0,
   };
-  let textColor = "text-slate-800";
-  if (hasChildren) textColor = cn(textColor, "font-extrabold");
 
-  // Highlight/dim logic for reverse filtering (legacy:仅 inHighlight 用于 subtree fade-out)
   const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
   const isSelected = node.id !== null && node.id !== undefined && selectedIds.has(String(node.id));
-
-  // 4 态分层(叶子 & 非叶一视同仁):非叶节点按 "子树聚合" 判断
-  // 聚合集由 CategoryTree 根预计算,passed-in sets 已包含"自己或任一后代命中"的 id
   const isDirect = directHighlightNodeIds?.has(String(node.id)) ?? false;
   const isIndirect = !isDirect && (indirectHighlightNodeIds?.has(String(node.id)) ?? false);
   const isNoMatch = hasActiveFilter && !isSelected && !isDirect && !isIndirect;
+
+  const totalCount = node.total_element_count ?? node.element_count ?? 0;
+  const ownCount = node.element_count ?? 0;
+  const showElementsToggle = !hasChildren && elements.length > 0;
+
+  // visibleDepth 限制下,该节点的子树是否应该折叠
+  const limitedByDepth = visibleDepth >= 0 && depth >= visibleDepth - 1;
+  const shouldShowChildren = expanded && hasChildren && !limitedByDepth;
+
   const shouldScrollIntoView =
     focusedTreeNodeId !== null &&
     focusedTreeNodeId !== undefined &&
@@ -100,10 +200,6 @@ function HorizontalTreeNode({
     node.id !== undefined &&
     String(focusedTreeNodeId) === String(node.id);
 
-  // 只在"明确的用户导航行为"下 scroll,避免 filter 变化导致 focused 节点切换时整页跳动:
-  //   1. isSelected 变为 true(用户点了这个树节点本身)
-  //   2. focusTrigger 递增(用户按 < / > 切换匹配节点 / 点 tag 跳转)
-  // 过滤条件变化导致 shouldScrollIntoView 从 false→true 的情况**不**触发 scroll。
   const prevFocusTriggerRef = useRef(focusTrigger);
   useEffect(() => {
     if (!nodeRef.current) return;
@@ -117,158 +213,214 @@ function HorizontalTreeNode({
     }
   }, [isSelected, shouldScrollIntoView, focusTrigger]);
 
+  // 4 态视觉:selected / direct / no-match / default — 选中色统一为 #3b82f6
+  const labelStateStyle: React.CSSProperties = (() => {
+    if (isSelected) {
+      return {
+        borderColor: "#3b82f6",
+        boxShadow: "0 0 0 2px rgba(59,130,246,0.32), 0 1px 3px rgba(0,0,0,0.07)",
+      };
+    }
+    if (isDirect) {
+      return {
+        borderColor: "rgba(56,189,248,0.55)",
+        boxShadow: "0 0 0 1.5px rgba(125,211,252,0.5), 0 1px 3px rgba(0,0,0,0.07)",
+        background: "rgba(240,249,255,0.7)",
+      };
+    }
+    return {};
+  })();
+
   return (
     <div
       className={cn(
-        "flex flex-row items-start transition-opacity duration-200",
+        "tree-node flex flex-row items-start transition-opacity duration-200",
         highlightLeafNames && !inHighlight && "opacity-20",
       )}
     >
       {/* Node Card */}
       <div
         ref={nodeRef}
+        onClick={() => onSelect(node)}
         className={cn(
-          "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px] bg-white border-slate-200",
-          isSelected
-            ? "ring-2 ring-orange-400 ring-offset-2 border-orange-400 shadow-[0_0_0_2px_rgba(251,146,60,0.16)]"
-            : isDirect
-              ? // direct:与 RelationCard direct-match 视觉一致
-                "border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
-              : isNoMatch
-                ? // 无关联:置灰但仍可点击
-                  "border-slate-200 bg-slate-50 opacity-45 saturate-50"
-                : // indirect 或无筛选:默认态
-                  "hover:brightness-95",
+          "node-label group inline-flex items-center gap-1.5 cursor-pointer shrink-0",
+          "bg-white border border-slate-200 rounded-[9px] px-2.5 py-1.5",
+          "text-[13px] font-medium text-slate-800 whitespace-nowrap select-none",
+          "shadow-[0_1px_3px_rgba(0,0,0,0.07),0_0_0_1px_rgba(0,0,0,0.04)]",
+          "transition-[box-shadow,transform,border-color,background] duration-150",
+          // hover: --node-dim-rgb 公式 (96,165,250) — 上浮 + 蓝色淡边框 + 双层阴影
+          "hover:-translate-y-px",
+          "hover:border-[rgba(96,165,250,0.45)]",
+          "hover:shadow-[0_4px_16px_rgba(96,165,250,0.14),0_0_0_1.5px_rgba(96,165,250,0.3)]",
+          isNoMatch && "opacity-40 saturate-50",
         )}
-        onClick={() => onSelect(node)}
+        style={{
+          borderLeft: `3px solid ${NODE_BORDER}`,
+          ...labelStateStyle,
+        }}
       >
-        <span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
-        <div className="flex text-[9px] bg-slate-100 rounded px-1.5 shadow-sm items-center font-bold text-slate-700">
-          {node.total_posts_count || 0} 帖
-        </div>
-        <div className="relative flex items-center gap-2 ml-2">
-          <span
-            onMouseEnter={(e) =>
-              metrics.patternCount > 0 &&
-              setHoveredMetric({
-                key: "pattern",
-                count: metrics.patternCount,
-                colorClass: "bg-blue-500 border-blue-500 text-white",
-                x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
-                y: e.currentTarget.getBoundingClientRect().top - 8,
-              })
-            }
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === "pattern" ? null : prev))}
+        {hasChildren && (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggleOpen(String(node.id));
+            }}
             className={cn(
-              "w-2.5 h-2.5 rounded-full",
-              metrics.patternCount > 0 ? "bg-blue-500 opacity-100" : "opacity-0",
+              "node-arrow flex items-center justify-center w-[15px] h-[15px] rounded-[4px]",
+              "bg-slate-100 border border-slate-200 text-slate-400",
+              "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600 transition-colors",
             )}
+            title={expanded ? "收起" : "展开"}
+          >
+            {expanded ? <ChevronDown size={9} strokeWidth={2} /> : <ChevronRight size={9} strokeWidth={2} />}
+          </button>
+        )}
+
+        <span className={cn("px-0.5", hasChildren && "font-bold text-slate-900")}>
+          <HighlightedText
+            text={node.name || "Root"}
+            tokens={searchTokens}
           />
+        </span>
+
+        {/* 总计数 — 仅在父节点上显示,叶节点交给 element toggle */}
+        {totalCount > 0 && !showElementsToggle && (
           <span
-            onMouseEnter={(e) =>
-              metrics.reqCount > 0 &&
-              setHoveredMetric({
-                key: "req",
-                count: metrics.reqCount,
-                colorClass: "bg-cyan-500 border-cyan-500 text-white",
-                x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
-                y: e.currentTarget.getBoundingClientRect().top - 8,
-              })
-            }
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === "req" ? null : prev))}
-            className={cn("w-2.5 h-2.5 rounded-full", metrics.reqCount > 0 ? "bg-cyan-500" : "opacity-0")}
-          />
-          <span
-            onMouseEnter={(e) =>
-              metrics.procCount > 0 &&
-              setHoveredMetric({
-                key: "proc",
-                count: metrics.procCount,
-                colorClass: "bg-green-500 border-green-500 text-white",
-                x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
-                y: e.currentTarget.getBoundingClientRect().top - 8,
-              })
-            }
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === "proc" ? null : prev))}
-            className={cn("w-2.5 h-2.5 rounded-full", metrics.procCount > 0 ? "bg-green-500" : "opacity-0")}
-          />
-          <span
-            onMouseEnter={(e) =>
-              metrics.capCount > 0 &&
-              setHoveredMetric({
-                key: "cap",
-                count: metrics.capCount,
-                colorClass: "bg-amber-400 border-amber-400 text-white",
-                x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
-                y: e.currentTarget.getBoundingClientRect().top - 8,
-              })
-            }
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === "cap" ? null : prev))}
-            className={cn("w-2.5 h-2.5 rounded-full", metrics.capCount > 0 ? "bg-amber-400" : "opacity-0")}
-          />
+            className={cn(COUNT_BADGE_BASE, COUNT_BADGE_BLUE)}
+            title={`子树元素总数 ${totalCount}`}
+          >
+            {totalCount.toLocaleString()}
+          </span>
+        )}
+
+        {/* 帖子数 */}
+        {node.total_posts_count > 0 && (
           <span
-            onMouseEnter={(e) =>
-              metrics.toolCount > 0 &&
-              setHoveredMetric({
-                key: "tool",
-                count: metrics.toolCount,
-                colorClass: "bg-orange-500 border-orange-500 text-white",
-                x: e.currentTarget.getBoundingClientRect().left + e.currentTarget.getBoundingClientRect().width / 2,
-                y: e.currentTarget.getBoundingClientRect().top - 8,
-              })
-            }
-            onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === "tool" ? null : prev))}
-            className={cn("w-2.5 h-2.5 rounded-full", metrics.toolCount > 0 ? "bg-orange-500" : "opacity-0")}
-          />
-        </div>
+            className="inline-flex items-center text-[10px] font-semibold leading-[1.5] px-[6px] py-px rounded-full bg-slate-100 text-slate-600 tabular-nums"
+            title={`相关帖子 ${node.total_posts_count}`}
+          >
+            {node.total_posts_count} 帖
+          </span>
+        )}
 
-        {onOpenDetail && (
+        {/* 当前节点 elements 列表 (叶节点) — expandable 版本 */}
+        {showElementsToggle && (
           <button
-            className="ml-2 px-1.5 py-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded focus:outline-none transition-colors"
+            type="button"
             onClick={(e) => {
               e.stopPropagation();
-              onOpenDetail(node);
+              setElementsOpen((v) => !v);
             }}
-            title="查看详情"
+            className={cn(
+              COUNT_BADGE_BASE,
+              "gap-[3px]",
+              elementsOpen ? COUNT_BADGE_BLUE_EXPANDED : COUNT_BADGE_BLUE_EXPANDABLE,
+            )}
+            title={elementsOpen ? "收起元素" : "展开元素"}
           >
-            <FileText size={13} />
+            {ownCount || elements.length}
+            <ChevronDown
+              size={9}
+              strokeWidth={2.4}
+              className={cn("transition-transform", elementsOpen ? "" : "-rotate-90")}
+            />
           </button>
         )}
 
-        {hasChildren && (
+        {/* 关系指标点 */}
+        {(metrics.patternCount || metrics.reqCount || metrics.procCount || metrics.capCount || metrics.toolCount) >
+          0 && (
+          <div className="flex items-center gap-1 ml-1">
+            {[
+              { key: "pattern", count: metrics.patternCount, color: "bg-blue-500" },
+              { key: "req", count: metrics.reqCount, color: "bg-cyan-500" },
+              { key: "proc", count: metrics.procCount, color: "bg-green-500" },
+              { key: "cap", count: metrics.capCount, color: "bg-amber-400" },
+              { key: "tool", count: metrics.toolCount, color: "bg-orange-500" },
+            ]
+              .filter((m) => m.count > 0)
+              .map((m) => (
+                <span
+                  key={m.key}
+                  className={cn("w-2 h-2 rounded-full cursor-help", m.color)}
+                  onMouseEnter={(e) =>
+                    setHoveredMetric({
+                      key: m.key,
+                      count: m.count,
+                      colorClass: cn(m.color, "border", m.color.replace("bg-", "border-"), "text-white"),
+                      x:
+                        e.currentTarget.getBoundingClientRect().left +
+                        e.currentTarget.getBoundingClientRect().width / 2,
+                      y: e.currentTarget.getBoundingClientRect().top - 8,
+                    })
+                  }
+                  onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === m.key ? null : prev))}
+                />
+              ))}
+          </div>
+        )}
+
+        {/* hover-only 详情按钮 */}
+        {onOpenDetail && (
           <button
-            className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
+            type="button"
             onClick={(e) => {
               e.stopPropagation();
-              onToggleOpen(String(node.id));
+              onOpenDetail(node);
             }}
-          >
-            {expanded ? (
-              <ChevronDown
-                size={14}
-                className="opacity-70"
-              />
-            ) : (
-              <ChevronRight
-                size={14}
-                className="opacity-70"
-              />
+            className={cn(
+              "ml-1 inline-flex items-center justify-center w-[18px] h-[18px] rounded-[4px]",
+              "border border-slate-200 bg-slate-100 text-slate-400",
+              "opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto",
+              "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600",
+              "transition-[opacity,background,border-color,color]",
             )}
+            title="查看详情"
+          >
+            <Info size={11} strokeWidth={1.8} />
           </button>
         )}
       </div>
 
-      {/* Children */}
-      {expanded && hasChildren && (
-        <div className="flex flex-col relative ml-8">
-          <div className="absolute -left-8 top-[17px] w-8 h-px bg-slate-300"></div>
+      {/* 横向布局:children 在父节点右侧 */}
+      {shouldShowChildren && (
+        <div className="flex flex-col relative ml-8 shrink-0">
+          {/* 父节点右侧到第一个子节点的横向连接线 */}
+          <div className="absolute -left-8 top-[17px] w-8 h-px bg-[#d1d9e6]" />
+
+          {/* leaf elements 行 - 仅当节点本身是叶子且 elementsOpen */}
+          {showElementsToggle && elementsOpen && (
+            <div className="flex flex-wrap gap-[4px_6px] items-start max-w-[720px] pb-2">
+              {elements.map((el, i) => (
+                <span
+                  key={el.element_id ?? `${el.name}-${i}`}
+                  className={cn(
+                    "inline-flex items-center gap-1 text-[11px] font-medium px-2 py-[3px] rounded-full",
+                    "bg-white border border-slate-200 text-slate-600",
+                    "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600 transition-colors",
+                  )}
+                >
+                  <span>
+                    <HighlightedText
+                      text={el.name || ""}
+                      tokens={searchTokens}
+                    />
+                  </span>
+                  <span className="text-[10px] font-semibold text-slate-400 tabular-nums">{el.count ?? 0}</span>
+                </span>
+              ))}
+            </div>
+          )}
+
           {node.children.map((child: any, i: number) => (
             <div
               key={child.id || child.path || i}
               className={cn(
                 "relative pl-8 pb-3 flex items-start",
-                "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-slate-300",
-                "after:absolute after:left-0 after:w-px after:bg-slate-300",
+                // 横向小线 (从主干到子节点)
+                "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-[#d1d9e6]",
+                // 主干竖线
+                "after:absolute after:left-0 after:w-px after:bg-[#d1d9e6]",
                 "first:after:top-[17px] first:after:bottom-0",
                 "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
                 "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
@@ -280,24 +432,28 @@ function HorizontalTreeNode({
                 onSelect={onSelect}
                 onOpenDetail={onOpenDetail}
                 selectedIds={selectedIds}
-                level={level + 1}
+                depth={depth + 1}
                 highlightLeafNames={highlightLeafNames}
                 subtreeHighlightNodeIds={subtreeHighlightNodeIds}
                 sourceNodeIds={sourceNodeIds}
                 patternNodeIds={patternNodeIds}
                 nodeMetricsMap={nodeMetricsMap}
-                dimensionColor={dimensionColor}
+                dimStyle={dimStyle}
                 focusedTreeNodeId={focusedTreeNodeId}
+                focusTrigger={focusTrigger}
                 isOpen={isOpen}
                 onToggleOpen={onToggleOpen}
                 directHighlightNodeIds={directHighlightNodeIds}
                 indirectHighlightNodeIds={indirectHighlightNodeIds}
                 hasActiveFilter={hasActiveFilter}
+                searchTokens={searchTokens}
+                visibleDepth={visibleDepth}
               />
             </div>
           ))}
         </div>
       )}
+
       {hoveredMetric &&
         createPortal(
           <div
@@ -315,6 +471,16 @@ function HorizontalTreeNode({
   );
 }
 
+// ---------- 工具函数 ----------
+function computeMaxDepth(nodes: any[], d = 0): number {
+  if (!nodes || !nodes.length) return d - 1;
+  let m = d;
+  for (const n of nodes) {
+    if (n.children && n.children.length) m = Math.max(m, computeMaxDepth(n.children, d + 1));
+  }
+  return m;
+}
+
 export function CategoryTree({
   data,
   onSelect,
@@ -333,6 +499,7 @@ export function CategoryTree({
   treeFocusIndex = 0,
   treeMatchedCount = 0,
   focusedTreeNodeId = null,
+  focusTrigger = 0,
   onTreeFocusPrev,
   onTreeFocusNext,
   directHighlightNodeIds = null,
@@ -366,6 +533,7 @@ export function CategoryTree({
   treeFocusIndex?: number;
   treeMatchedCount?: number;
   focusedTreeNodeId?: string | number | null;
+  focusTrigger?: number;
   onTreeFocusPrev?: () => void;
   onTreeFocusNext?: () => void;
   directHighlightNodeIds?: Set<string> | null;
@@ -373,12 +541,35 @@ export function CategoryTree({
   hasActiveFilter?: boolean;
 }) {
   const [scale, setScale] = useState(1);
+  const [searchInput, setSearchInput] = useState("");
 
-  // 展开状态:defaultOpen 是基线,openStates 记录显式覆盖
+  // 展开状态
   const [defaultOpen, setDefaultOpen] = useState(true);
   const [openStates, setOpenStates] = useState<Map<string, boolean>>(new Map());
   const [autoCollapse, setAutoCollapse] = useState(false);
 
+  // dim section 折叠 (按维度名)
+  const [collapsedDims, setCollapsedDims] = useState<Set<string>>(new Set());
+
+  // visibleDepth: -1 表示无限制(全展开),N 表示只显示 0..N-1 层
+  const [visibleDepth, setVisibleDepth] = useState<number>(-1);
+  // 底部按钮的 primary 状态
+  const [primaryAction, setPrimaryAction] = useState<string>("expand-all");
+
+  // 浮动控制条 hint
+  const [hintText, setHintText] = useState<string | null>(null);
+  const hintTimerRef = useRef<number | null>(null);
+  const showHint = useCallback((msg: string) => {
+    setHintText(msg);
+    if (hintTimerRef.current) window.clearTimeout(hintTimerRef.current);
+    hintTimerRef.current = window.setTimeout(() => setHintText(null), 1600);
+  }, []);
+
+  // 滚动容器引用 + dim section 引用 (用于 HUD anchor 与 scroll spy)
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
+  const dimSectionRefs = useRef<Map<string, HTMLDivElement>>(new Map());
+  const [activeDim, setActiveDim] = useState<string | null>(null);
+
   const isOpen = useCallback(
     (id: string) => {
       if (openStates.has(id)) return openStates.get(id)!;
@@ -399,7 +590,6 @@ export function CategoryTree({
     [defaultOpen],
   );
 
-  // 从根路径找到目标节点的所有祖先 id(不含目标自己)
   const findAncestorIds = useMemo(() => {
     return (targetId: string): string[] => {
       if (!data?.children) return [];
@@ -423,16 +613,79 @@ export function CategoryTree({
   const expandAll = () => {
     setDefaultOpen(true);
     setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setPrimaryAction("expand-all");
+    showHint("全部展开");
   };
   const collapseAll = () => {
     setDefaultOpen(false);
     setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setPrimaryAction("collapse-all");
+    showHint("全部收起");
   };
+
+  // 维度内最大深度 (dim 子树最深)
+  const maxDepth = useMemo(() => {
+    if (!data?.children) return 0;
+    return Math.max(0, computeMaxDepth(data.children));
+  }, [data]);
+
+  const stepExpand = () => {
+    const max = maxDepth + 1;
+    let cur = visibleDepth < 0 ? max : visibleDepth;
+    if (cur >= max) {
+      setVisibleDepth(-1);
+      showHint("已全部展开");
+    } else {
+      cur += 1;
+      setVisibleDepth(cur >= max ? -1 : cur);
+      showHint(`展开至第 ${cur} 层`);
+    }
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-expand");
+  };
+  const stepCollapse = () => {
+    let cur = visibleDepth < 0 ? maxDepth + 1 : visibleDepth;
+    if (cur <= 1) {
+      showHint("已收起至一级分类");
+      setVisibleDepth(1);
+    } else {
+      cur -= 1;
+      setVisibleDepth(cur);
+      showHint(`收起至第 ${cur} 层`);
+    }
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-collapse");
+  };
+  const stepCollapseReverse = () => {
+    let cur = visibleDepth < 0 ? maxDepth + 1 : visibleDepth;
+    if (cur <= 1) {
+      showHint("已收起至一级分类");
+      return;
+    }
+    cur -= 1;
+    setVisibleDepth(cur);
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-collapse-rev");
+    showHint(`反向收起至第 ${cur} 层`);
+  };
+  const reset = () => {
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setCollapsedDims(new Set());
+    setPrimaryAction("expand-all");
+    showHint("已重置");
+  };
+
   const toggleAutoCollapse = () => {
     setAutoCollapse((v) => {
       const nextAuto = !v;
       if (nextAuto && focusedTreeNodeId != null) {
-        // 切换到自动折叠模式:立刻执行"全折叠 + 展开当前焦点的根通路"
         const ancestors = findAncestorIds(String(focusedTreeNodeId));
         setDefaultOpen(false);
         const next = new Map<string, boolean>();
@@ -443,7 +696,6 @@ export function CategoryTree({
     });
   };
 
-  // 焦点变化时展开路径
   useEffect(() => {
     if (focusedTreeNodeId == null) return;
     const ancestors = findAncestorIds(String(focusedTreeNodeId));
@@ -455,7 +707,6 @@ export function CategoryTree({
       ancestors.forEach((id) => next.set(id, true));
       setOpenStates(next);
     } else {
-      // 只在树处于折叠状态时才加路径;全展开状态下是 no-op
       setOpenStates((prev) => {
         let changed = false;
         const next = new Map(prev);
@@ -471,6 +722,86 @@ export function CategoryTree({
     }
   }, [focusedTreeNodeId, autoCollapse, findAncestorIds]);
 
+  // 搜索 token (逗号分隔)
+  const searchTokens = useMemo(
+    () =>
+      searchInput
+        .split(/[,,]/)
+        .map((s) => s.trim().toLowerCase())
+        .filter(Boolean),
+    [searchInput],
+  );
+
+  // 维度分组
+  const grouped = useMemo(() => {
+    if (!data?.children) return [];
+    const buckets: Record<string, any[]> = {};
+    for (const node of data.children) {
+      const t = node.source_type || "其他";
+      (buckets[t] ||= []).push(node);
+    }
+    const ordered = [...DIM_ORDER, ...Object.keys(buckets).filter((k) => !DIM_ORDER.includes(k))];
+    return ordered
+      .filter((dim) => buckets[dim] && buckets[dim].length)
+      .map((dim) => {
+        const nodes = buckets[dim];
+        const total = nodes.reduce(
+          (s, n) => s + (n.total_element_count ?? n.element_count ?? 0),
+          0,
+        );
+        return { dim, style: DIM_STYLES[dim] || { ...DEFAULT_DIM, label: dim }, nodes, total };
+      });
+  }, [data]);
+
+  // scroll spy 更新当前可见 dim — 用 getBoundingClientRect 计算相对偏移
+  // (offsetTop 取自 offsetParent — 由于外层容器是 position:relative,offsetTop 会包含 toolbar 高度,
+  //  导致 scrollTo 多滚动一段、目标被 toolbar 遮住)
+  useEffect(() => {
+    const sc = scrollContainerRef.current;
+    if (!sc) return;
+    let raf = 0;
+    const update = () => {
+      raf = 0;
+      const scTop = sc.getBoundingClientRect().top;
+      let bestDim: string | null = null;
+      let bestDist = Infinity;
+      for (const [dim, el] of dimSectionRefs.current) {
+        const offset = el.getBoundingClientRect().top - scTop; // 相对滚动容器顶部
+        if (offset <= 60) {
+          const dist = Math.abs(offset);
+          if (dist < bestDist) {
+            bestDist = dist;
+            bestDim = dim;
+          }
+        }
+      }
+      if (!bestDim && grouped.length) bestDim = grouped[0].dim;
+      setActiveDim(bestDim);
+    };
+    update();
+    const onScroll = () => {
+      if (raf) return;
+      raf = requestAnimationFrame(update);
+    };
+    sc.addEventListener("scroll", onScroll, { passive: true });
+    return () => {
+      sc.removeEventListener("scroll", onScroll);
+      if (raf) cancelAnimationFrame(raf);
+    };
+  }, [grouped]);
+
+  const scrollToDim = (dim: string) => {
+    const el = dimSectionRefs.current.get(dim);
+    const sc = scrollContainerRef.current;
+    if (el && sc) {
+      const scRect = sc.getBoundingClientRect();
+      const elRect = el.getBoundingClientRect();
+      // 让目标 dim 顶部刚好出现在滚动视口顶部下方 16px
+      const target = sc.scrollTop + (elRect.top - scRect.top) - 16;
+      sc.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
+    }
+  };
+
   if (!data || !data.children)
     return (
       <div className="bg-slate-100/50 rounded-2xl border border-slate-200 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
@@ -480,179 +811,406 @@ export function CategoryTree({
     );
 
   return (
-    <div className="bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden flex flex-col h-full relative">
-      {/* 标题栏 */}
-      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0 items-center">
-        <div className="flex items-center gap-2">
-          <FolderTree
-            size={14}
-            className="text-slate-500"
+    <div className="bg-[#eef0f4] rounded-2xl border border-[#e2e6ed] overflow-hidden flex flex-col h-full relative shadow-sm">
+      {/* 顶部工具栏 */}
+      <div className="px-3 py-2 bg-white border-b border-[#e2e6ed] flex items-center gap-2 shrink-0 shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
+        <FolderTree
+          size={15}
+          className="text-slate-500 shrink-0"
+        />
+        <span className="text-[13px] font-bold text-slate-700 shrink-0">分类树</span>
+
+        <div className="w-px h-5 bg-[#e2e6ed] mx-0.5 shrink-0" />
+
+        {/* 搜索框 */}
+        <div className="relative flex items-center shrink-0">
+          <Search
+            size={13}
+            className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"
+          />
+          <input
+            type="text"
+            value={searchInput}
+            onChange={(e) => setSearchInput(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Escape") setSearchInput("");
+            }}
+            placeholder="搜索分类… (逗号分隔多关键字)"
+            className={cn(
+              "h-[28px] pl-7 pr-2 rounded-[7px] border border-[#e2e6ed] bg-[#eef0f4]",
+              "text-[12.5px] text-slate-800 placeholder:text-slate-400 outline-none w-[200px]",
+              "transition-[border-color,background,width,box-shadow] duration-200",
+              "focus:border-blue-500 focus:bg-white focus:w-[260px] focus:shadow-[0_0_0_3px_rgba(59,130,246,0.1)]",
+            )}
           />
-          内容树
         </div>
-        <div className="flex items-center gap-2">
-          {filterLabel && (
+
+        <div className="flex-1 min-w-0" />
+
+        {filterLabel && (
+          <button
+            type="button"
+            onClick={onClearFilter}
+            className="text-[10px] font-bold px-2 py-1 rounded-md bg-sky-100 text-sky-700 hover:bg-sky-200 transition-colors shrink-0"
+            title="清除筛选"
+          >
+            <span className="max-w-[140px] inline-block truncate align-bottom">{filterLabel}</span> ×
+          </button>
+        )}
+        {treeMatchedCount > 1 && (
+          <div className="flex items-center gap-1 shrink-0">
             <button
               type="button"
-              onClick={onClearFilter}
-              className="text-[10px] font-bold px-2 py-1 rounded-md bg-sky-100 text-sky-700 hover:bg-sky-200 transition-colors"
+              onClick={onTreeFocusPrev}
+              disabled={treeFocusIndex === 0}
+              className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
             >
-              {filterLabel} ×
+              <ChevronLeft size={13} />
             </button>
+            <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center tabular-nums">
+              {treeFocusIndex + 1}/{treeMatchedCount}
+            </span>
+            <button
+              type="button"
+              onClick={onTreeFocusNext}
+              disabled={treeFocusIndex === treeMatchedCount - 1}
+              className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronRight size={13} />
+            </button>
+          </div>
+        )}
+
+        {/* 顶部统计 */}
+        <div className="flex items-center gap-2 shrink-0">
+          <div className="inline-flex items-baseline gap-1">
+            <span className="text-[13px] font-bold text-blue-600 tabular-nums">
+              {(totalNodeCount ?? 0).toLocaleString()}
+            </span>
+            <span className="text-[11.5px] text-slate-400">分类</span>
+          </div>
+        </div>
+
+        <div className="w-px h-5 bg-[#e2e6ed] mx-0.5 shrink-0" />
+
+        {/* 自动折叠 + 宽窄模式 */}
+        <button
+          onClick={toggleAutoCollapse}
+          className={cn(
+            "text-[10px] font-bold px-2 py-1 rounded transition-colors shrink-0",
+            autoCollapse ? "bg-blue-100 text-blue-600" : "bg-slate-100 text-slate-600 hover:bg-slate-200",
           )}
-          {treeMatchedCount > 1 && (
-            <div className="flex items-center gap-1">
-              <button
-                type="button"
-                onClick={onTreeFocusPrev}
-                disabled={treeFocusIndex === 0}
-                className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-              >
-                <ChevronLeft size={13} />
-              </button>
-              <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
-                {treeFocusIndex + 1}/{treeMatchedCount}
-              </span>
-              <button
-                type="button"
-                onClick={onTreeFocusNext}
-                disabled={treeFocusIndex === treeMatchedCount - 1}
-                className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-              >
-                <ChevronRight size={13} />
-              </button>
-            </div>
-          )}
-          <button
-            onClick={expandAll}
-            className="text-[10px] font-bold px-2 py-0.5 rounded bg-slate-200 text-slate-500 hover:bg-slate-300 transition-colors"
-          >
-            全展开
-          </button>
-          <button
-            onClick={collapseAll}
-            className="text-[10px] font-bold px-2 py-0.5 rounded bg-slate-200 text-slate-500 hover:bg-slate-300 transition-colors"
-          >
-            全折叠
-          </button>
-          <button
-            onClick={toggleAutoCollapse}
-            className={cn(
-              "text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
-              autoCollapse ? "bg-indigo-100 text-[#3b82f6]" : "bg-slate-200 text-slate-500 hover:bg-slate-300",
-            )}
-            title="开启后:每次定位到新节点都先全折叠再展开该节点的根路径"
-          >
-            自动折叠
-          </button>
+          title="开启后:每次定位到新节点都先全折叠再展开该节点的根路径"
+        >
+          自动折叠
+        </button>
+        {onToggleWideMode && (
           <button
             onClick={onToggleWideMode}
             className={cn(
-              "text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
-              wideMode ? "bg-indigo-100 text-[#3b82f6]" : "bg-slate-200 text-slate-500 hover:bg-slate-300",
+              "text-[10px] font-bold px-2 py-1 rounded transition-colors shrink-0",
+              wideMode ? "bg-blue-100 text-blue-600" : "bg-slate-100 text-slate-600 hover:bg-slate-200",
             )}
           >
             {wideMode ? "宽模式" : "窄模式"}
           </button>
-          <span className="text-slate-400">{totalNodeCount ?? 0}</span>
-        </div>
+        )}
       </div>
 
-      {/* 缩放控件 */}
-      <div className="absolute top-[52px] right-3 z-50 flex gap-1 bg-white/90 backdrop-blur p-1 rounded-lg shadow-sm border border-slate-200">
+      {/* 缩放控件 - 放在右下角避开 HUD */}
+      <div className="absolute bottom-4 right-4 z-50 flex gap-1 bg-white/96 backdrop-blur p-1 rounded-[10px] shadow-[0_2px_8px_rgba(0,0,0,0.08)] border border-[#e2e6ed]">
         <button
           onClick={() => setScale((s) => Math.min(s + 0.15, 3))}
           className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
           title="放大"
         >
-          <ZoomIn size={14} />
+          <ZoomIn size={13} />
         </button>
         <button
           onClick={() => setScale((s) => Math.max(s - 0.15, 0.3))}
           className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
           title="缩小"
         >
-          <ZoomOut size={14} />
+          <ZoomOut size={13} />
         </button>
         <button
           onClick={() => setScale(1)}
           className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
-          title="重置"
+          title="重置缩放"
         >
-          <Maximize size={14} />
+          <Maximize size={13} />
         </button>
       </div>
 
-      <div className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-slate-50/30 p-4 custom-scrollbar">
+      {/* 树面板 */}
+      <div
+        ref={scrollContainerRef}
+        className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-[#eef0f4] px-5 pt-4 pb-24 custom-scrollbar"
+      >
         <div
-          className="flex flex-col gap-8 select-none min-w-max pb-8 origin-top-left transition-all duration-200"
+          className="flex flex-col gap-3 select-none min-w-max origin-top-left transition-[zoom] duration-200"
           style={{ zoom: scale } as any}
         >
-          {(() => {
-            const orderKeyWords = ["形式", "实质", "意图"];
-            const groups: Record<string, any[]> = { 形式: [], 实质: [], 意图: [] };
-            data.children.forEach((node: any) => {
-              const type = node.source_type;
-              if (type && groups[type]) groups[type].push(node);
-            });
-
-            return orderKeyWords.map((dimensionName: string) => {
-              const nodesInDimension = groups[dimensionName] || [];
-              if (nodesInDimension.length === 0) return null;
-
-              let color = { bg: "bg-[#3b82f6]", border: "border-[#3b82f6]", text: "text-[#3b82f6]", hex: "#3b82f6" };
-              if (dimensionName === "形式")
-                color = { bg: "bg-[#E3F2FD]", border: "border-[#2196F3]", text: "text-slate-800", hex: "#2196F3" };
-              else if (dimensionName === "实质")
-                color = { bg: "bg-[#FFF3E0]", border: "border-[#FF9800]", text: "text-slate-800", hex: "#FF9800" };
-              else if (dimensionName === "意图")
-                color = { bg: "bg-[#F1F8E9]", border: "border-[#8BC34A]", text: "text-slate-800", hex: "#8BC34A" };
-
-              return (
-                <div
-                  key={dimensionName}
-                  className="flex flex-col"
+          {grouped.map(({ dim, style, nodes, total }) => {
+            const isCollapsed = collapsedDims.has(dim);
+            // 把 dim 当成一个 root 节点,name=维度名,children=L1 nodes
+            const dimRoot = {
+              id: `__dim__${dim}`,
+              name: style.label,
+              source_type: dim,
+              total_element_count: total,
+              children: nodes,
+              total_posts_count: 0,
+              element_count: 0,
+              elements: [],
+              __isDim: true,
+              __dimCount: total,
+            };
+            return (
+              <div
+                key={dim}
+                ref={(el) => {
+                  if (el) dimSectionRefs.current.set(dim, el);
+                  else dimSectionRefs.current.delete(dim);
+                }}
+                className="flex flex-row items-start"
+              >
+                {/* dim section header (chip-like, 同时是一个伪 root 节点) */}
+                <button
+                  type="button"
+                  onClick={() =>
+                    setCollapsedDims((prev) => {
+                      const next = new Set(prev);
+                      if (next.has(dim)) next.delete(dim);
+                      else next.add(dim);
+                      return next;
+                    })
+                  }
+                  className={cn(
+                    "node-label inline-flex items-center gap-1.5 cursor-pointer shrink-0",
+                    "bg-white border border-slate-200 rounded-[9px] px-2.5 py-1.5",
+                    "text-[13px] font-bold text-slate-800 whitespace-nowrap select-none",
+                    "shadow-[0_1px_3px_rgba(0,0,0,0.07),0_0_0_1px_rgba(0,0,0,0.04)]",
+                    "transition-[box-shadow,transform,border-color,background,opacity] duration-150",
+                    "hover:-translate-y-px",
+                    "hover:border-[rgba(96,165,250,0.45)]",
+                    "hover:shadow-[0_4px_16px_rgba(96,165,250,0.14),0_0_0_1.5px_rgba(96,165,250,0.3)]",
+                    isCollapsed && "opacity-50",
+                  )}
+                  style={{ borderLeft: `3px solid ${NODE_BORDER}` }}
                 >
-                  <div
+                  <span
                     className={cn(
-                      "px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4",
-                      color.bg,
-                      color.border,
-                      color.text,
+                      "node-arrow flex items-center justify-center w-[15px] h-[15px] rounded-[4px]",
+                      "bg-slate-100 border border-slate-200 text-slate-400",
                     )}
                   >
-                    {dimensionName} 维度
-                  </div>
-                  <div className="flex flex-col gap-6 pl-4">
-                    {nodesInDimension.map((subNode: any, subIdx: number) => (
-                      <HorizontalTreeNode
+                    {isCollapsed ? (
+                      <ChevronRight size={9} strokeWidth={2} />
+                    ) : (
+                      <ChevronDown size={9} strokeWidth={2} />
+                    )}
+                  </span>
+                  <span className="px-0.5 tracking-wide">{style.label}</span>
+                  <span className={cn(COUNT_BADGE_BASE, COUNT_BADGE_BLUE)}>
+                    {total.toLocaleString()}
+                  </span>
+                </button>
+
+                {/* dim section children (L1 nodes) */}
+                {!isCollapsed && nodes.length > 0 && (
+                  <div className="flex flex-col relative ml-8 shrink-0">
+                    <div className="absolute -left-8 top-[17px] w-8 h-px bg-[#d1d9e6]" />
+                    {nodes.map((subNode: any, subIdx: number) => (
+                      <div
                         key={subNode.id || subIdx}
-                        node={subNode}
-                        onSelect={onSelect}
-                        onOpenDetail={onOpenDetail}
-                        selectedIds={selectedIds}
-                        level={1}
-                        highlightLeafNames={highlightLeafNames}
-                        subtreeHighlightNodeIds={subtreeHighlightNodeIds}
-                        sourceNodeIds={sourceNodeIds}
-                        patternNodeIds={patternNodeIds}
-                        nodeMetricsMap={nodeMetricsMap}
-                        dimensionColor={color.hex}
-                        focusedTreeNodeId={focusedTreeNodeId}
-                        isOpen={isOpen}
-                        onToggleOpen={onToggleOpen}
-                        directHighlightNodeIds={directHighlightNodeIds}
-                        indirectHighlightNodeIds={indirectHighlightNodeIds}
-                        hasActiveFilter={hasActiveFilter}
-                      />
+                        className={cn(
+                          "relative pl-8 pb-3 flex items-start",
+                          "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-[#d1d9e6]",
+                          "after:absolute after:left-0 after:w-px after:bg-[#d1d9e6]",
+                          "first:after:top-[17px] first:after:bottom-0",
+                          "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
+                          "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
+                          "first:last:after:hidden",
+                        )}
+                      >
+                        <HorizontalTreeNode
+                          node={subNode}
+                          onSelect={onSelect}
+                          onOpenDetail={onOpenDetail}
+                          selectedIds={selectedIds}
+                          depth={1}
+                          highlightLeafNames={highlightLeafNames}
+                          subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                          sourceNodeIds={sourceNodeIds}
+                          patternNodeIds={patternNodeIds}
+                          nodeMetricsMap={nodeMetricsMap}
+                          dimStyle={style}
+                          focusedTreeNodeId={focusedTreeNodeId}
+                          focusTrigger={focusTrigger}
+                          isOpen={isOpen}
+                          onToggleOpen={onToggleOpen}
+                          directHighlightNodeIds={directHighlightNodeIds}
+                          indirectHighlightNodeIds={indirectHighlightNodeIds}
+                          hasActiveFilter={hasActiveFilter}
+                          searchTokens={searchTokens}
+                          visibleDepth={visibleDepth}
+                        />
+                      </div>
                     ))}
                   </div>
-                </div>
-              );
-            });
-          })()}
+                )}
+                {/* 不渲染 dimRoot 内容 - 仅作为 schema 占位 */}
+                {/* 占位以避免未使用警告 */}
+                <span className="hidden">{dimRoot.id}</span>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* 右侧 HUD anchor */}
+      {grouped.length > 1 && (
+        <div
+          className={cn(
+            "absolute z-40 flex flex-col gap-0.5 items-end",
+            "top-[64px] right-[14px]",
+            "bg-white/92 backdrop-blur-sm border border-[#e2e6ed] rounded-[10px] py-1.5 px-3",
+            "shadow-[0_1px_6px_rgba(0,0,0,0.07)]",
+          )}
+        >
+          {grouped.map(({ dim, style }) => {
+            const isActive = activeDim === dim;
+            return (
+              <button
+                key={dim}
+                type="button"
+                onClick={() => scrollToDim(dim)}
+                className={cn(
+                  "flex items-center gap-2 cursor-pointer py-[3px] rounded select-none transition-colors",
+                  "hover:bg-black/[0.03]",
+                )}
+                title={`跳转到 ${style.label}`}
+              >
+                <span
+                  className={cn(
+                    "text-[12px] tracking-tight whitespace-nowrap transition-colors",
+                    isActive ? "font-semibold text-blue-600" : "font-medium text-slate-400",
+                  )}
+                >
+                  {style.label}
+                </span>
+                <span
+                  className="rounded transition-all"
+                  style={{
+                    height: 2,
+                    width: isActive ? 22 : 14,
+                    background: isActive ? "#2563eb" : "#d1d5db",
+                  }}
+                />
+              </button>
+            );
+          })}
         </div>
+      )}
+
+      {/* 浮动底部控制栏 */}
+      <div
+        className={cn(
+          "absolute z-50 flex items-center gap-px p-1 rounded-[14px]",
+          "bottom-4 left-1/2 -translate-x-1/2",
+          "bg-white/96 backdrop-blur-xl border border-[#e2e6ed]",
+          "shadow-[0_4px_20px_rgba(0,0,0,0.11),0_1px_4px_rgba(0,0,0,0.06)]",
+        )}
+      >
+        <CtrlButton
+          icon={<ChevronsDown size={13} strokeWidth={1.8} />}
+          label="全部展开"
+          primary={primaryAction === "expand-all"}
+          onClick={expandAll}
+        />
+        <CtrlButton
+          icon={<ChevronsUp size={13} strokeWidth={1.8} />}
+          label="全部收起"
+          primary={primaryAction === "collapse-all"}
+          onClick={collapseAll}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={<ChevronDown size={13} strokeWidth={1.8} />}
+          label="逐层展开"
+          primary={primaryAction === "step-expand"}
+          onClick={stepExpand}
+        />
+        <CtrlButton
+          icon={<ChevronUp size={13} strokeWidth={1.8} />}
+          label="逐层收起"
+          primary={primaryAction === "step-collapse"}
+          onClick={stepCollapse}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={<ChevronUp size={13} strokeWidth={1.8} />}
+          label="反向收起"
+          primary={primaryAction === "step-collapse-rev"}
+          onClick={stepCollapseReverse}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={<RotateCcw size={13} strokeWidth={1.8} />}
+          label="重置"
+          primary={false}
+          onClick={reset}
+        />
       </div>
+
+      {/* 操作 hint */}
+      {hintText && (
+        <div
+          className={cn(
+            "absolute z-[51] left-1/2 -translate-x-1/2 bottom-[70px]",
+            "px-3.5 py-1.5 rounded-full whitespace-nowrap text-[12px]",
+            "bg-[rgba(24,32,46,0.82)] text-white pointer-events-none",
+          )}
+          style={{ fontFamily: '"JetBrains Mono","SF Mono",Consolas,monospace' }}
+        >
+          {hintText}
+        </div>
+      )}
     </div>
   );
 }
+
+// 浮动控制栏内的按钮和分隔
+function CtrlButton({
+  icon,
+  label,
+  primary,
+  onClick,
+}: {
+  icon: React.ReactNode;
+  label: string;
+  primary?: boolean;
+  onClick?: () => void;
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={cn(
+        "inline-flex items-center gap-1.5 px-3 py-[7px] rounded-[10px]",
+        "text-[12.5px] font-medium whitespace-nowrap transition-colors",
+        primary
+          ? "bg-blue-600 text-white hover:bg-blue-700"
+          : "bg-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-800",
+      )}
+    >
+      {icon}
+      <span>{label}</span>
+    </button>
+  );
+}
+
+function CtrlSep() {
+  return <span className="w-px h-[22px] bg-[#e2e6ed] mx-0.5" />;
+}

+ 1260 - 0
knowhub/frontend/src/components/dashboard/CategoryTreeNew.tsx

@@ -0,0 +1,1260 @@
+import { useState, useRef, useEffect, useCallback, useMemo } from "react";
+import { createPortal } from "react-dom";
+import { cn } from "../../lib/utils";
+import {
+  ChevronRight,
+  ChevronDown,
+  ChevronLeft,
+  ChevronUp,
+  ChevronsDown,
+  ChevronsUp,
+  RotateCcw,
+  ZoomIn,
+  ZoomOut,
+  Maximize,
+  FolderTree,
+  Info,
+  Search,
+} from "lucide-react";
+
+// 统一的数字徽标样式 (参考 .node-count) — 蓝色 accent + monospace
+const COUNT_BADGE_BASE =
+  "inline-flex items-center text-[11px] font-semibold leading-[1.5] px-[7px] py-px rounded-full tabular-nums [letter-spacing:-0.02em] [font-family:'JetBrains_Mono','SF_Mono',Consolas,monospace]";
+const COUNT_BADGE_BLUE = "bg-[#dbeafe] text-[#2563eb]";
+// expandable 版本: hover 时背景加深 (.node-count.expandable:hover)
+const COUNT_BADGE_BLUE_EXPANDABLE = "bg-[#dbeafe] text-[#2563eb] hover:bg-[#bfdbfe] cursor-pointer transition-colors";
+const COUNT_BADGE_BLUE_EXPANDED = "bg-[#bfdbfe] text-[#1e40af] cursor-pointer transition-colors";
+
+// 节点 border-left + hover 高亮统一使用蓝色 (#60a5fa, rgb 96,165,250)
+// hover 公式见 className 中的 hover:border-[rgba(96,165,250,...)] / hover:shadow-[...]
+const NODE_BORDER = "#60a5fa";
+
+// 与参考稿一致的维度配色 (border-left + hover ring + section header)
+type DimStyle = {
+  label: string;
+  border: string;
+  rgb: string;
+  badge: string;
+  badgeBg: string;
+};
+const DIM_STYLES: Record<string, DimStyle> = {
+  实质: { label: "实质", border: "#f9a825", rgb: "249, 168, 37", badge: "#a16207", badgeBg: "#fef3c7" },
+  形式: { label: "形式", border: "#42a5f5", rgb: "66, 165, 245", badge: "#1d4ed8", badgeBg: "#dbeafe" },
+  意图: { label: "意图", border: "#66bb6a", rgb: "102, 187, 106", badge: "#15803d", badgeBg: "#dcfce7" },
+  作用: { label: "作用", border: "#ab47bc", rgb: "171, 71, 188", badge: "#7e22ce", badgeBg: "#f3e8ff" },
+  感受: { label: "感受", border: "#ec407a", rgb: "236, 64, 122", badge: "#be185d", badgeBg: "#fce7f3" },
+};
+const DEFAULT_DIM: DimStyle = {
+  label: "其他",
+  border: "#94a3b8",
+  rgb: "148, 163, 184",
+  badge: "#475569",
+  badgeBg: "#e2e8f0",
+};
+const DIM_ORDER = ["实质", "形式", "意图", "作用", "感受"];
+
+type ElementItem = { name?: string; count?: number; element_id?: string | number };
+
+interface NodeProps {
+  node: any;
+  onSelect: (node: any) => void;
+  onOpenDetail?: (node: any) => void;
+  selectedIds: Set<string>;
+  depth: number;
+  highlightLeafNames: Set<string> | null;
+  subtreeHighlightNodeIds: Set<string> | null;
+  sourceNodeIds: Set<string> | null;
+  patternNodeIds: Set<string> | null;
+  nodeMetricsMap: Record<
+    string,
+    {
+      reqCount: number;
+      procCount: number;
+      capCount: number;
+      toolCount: number;
+      nodeCount: number;
+      patternCount: number;
+    }
+  >;
+  dimStyle: DimStyle;
+  focusedTreeNodeId?: string | number | null;
+  focusTrigger?: number;
+  isOpen: (nodeId: string) => boolean;
+  onToggleOpen: (nodeId: string) => void;
+  directHighlightNodeIds: Set<string> | null;
+  indirectHighlightNodeIds: Set<string> | null;
+  hasActiveFilter: boolean;
+  searchTokens: string[];
+  visibleDepth: number; // -1 = 不限
+}
+
+function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | null): boolean {
+  if (!highlightLeafNames) return true;
+  const hasChildren = node.children && node.children.length > 0;
+  if (!hasChildren) return highlightLeafNames.has(node.name);
+  return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
+}
+
+function HighlightedText({ text, tokens }: { text: string; tokens: string[] }) {
+  if (!tokens.length) return <>{text}</>;
+  const lower = text.toLowerCase();
+  const ranges: Array<[number, number]> = [];
+  for (const t of tokens) {
+    if (!t) continue;
+    let from = 0;
+    while (from <= lower.length) {
+      const idx = lower.indexOf(t, from);
+      if (idx < 0) break;
+      ranges.push([idx, idx + t.length]);
+      from = idx + t.length;
+    }
+  }
+  if (!ranges.length) return <>{text}</>;
+  ranges.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+  const merged: Array<[number, number]> = [ranges[0].slice() as [number, number]];
+  for (let i = 1; i < ranges.length; i++) {
+    const [s, e] = ranges[i];
+    const last = merged[merged.length - 1];
+    if (s <= last[1]) last[1] = Math.max(last[1], e);
+    else merged.push([s, e]);
+  }
+  const out: React.ReactNode[] = [];
+  let cursor = 0;
+  merged.forEach(([s, e], i) => {
+    if (s > cursor) out.push(text.slice(cursor, s));
+    out.push(
+      <mark
+        key={i}
+        className="rounded-[2px] bg-[#fff3a3] text-inherit px-[1px] font-semibold"
+      >
+        {text.slice(s, e)}
+      </mark>,
+    );
+    cursor = e;
+  });
+  if (cursor < text.length) out.push(text.slice(cursor));
+  return <>{out}</>;
+}
+
+function HorizontalTreeNode({
+  node,
+  onSelect,
+  onOpenDetail,
+  selectedIds,
+  depth,
+  highlightLeafNames,
+  subtreeHighlightNodeIds,
+  sourceNodeIds,
+  patternNodeIds,
+  nodeMetricsMap,
+  dimStyle,
+  focusedTreeNodeId = null,
+  focusTrigger = 0,
+  isOpen,
+  onToggleOpen,
+  directHighlightNodeIds,
+  indirectHighlightNodeIds,
+  hasActiveFilter,
+  searchTokens,
+  visibleDepth,
+}: NodeProps) {
+  const expanded = isOpen(String(node.id));
+  const [hoveredMetric, setHoveredMetric] = useState<null | {
+    key: string;
+    count: number;
+    colorClass: string;
+    x: number;
+    y: number;
+  }>(null);
+  const [elementsOpen, setElementsOpen] = useState(false);
+  const nodeRef = useRef<HTMLDivElement>(null);
+  const hasChildren = node.children && node.children.length > 0;
+  const elements: ElementItem[] = Array.isArray(node.elements) ? node.elements : [];
+  const metrics = nodeMetricsMap[String(node.id)] || {
+    reqCount: 0,
+    procCount: 0,
+    capCount: 0,
+    toolCount: 0,
+    nodeCount: 0,
+    patternCount: 0,
+  };
+
+  const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
+  const isSelected = node.id !== null && node.id !== undefined && selectedIds.has(String(node.id));
+  const isDirect = directHighlightNodeIds?.has(String(node.id)) ?? false;
+  const isIndirect = !isDirect && (indirectHighlightNodeIds?.has(String(node.id)) ?? false);
+  const isNoMatch = hasActiveFilter && !isSelected && !isDirect && !isIndirect;
+
+  const totalCount = node.total_element_count ?? node.element_count ?? 0;
+  const ownCount = node.element_count ?? 0;
+  const showElementsToggle = !hasChildren && elements.length > 0;
+
+  // visibleDepth 限制下,该节点的子树是否应该折叠
+  const limitedByDepth = visibleDepth >= 0 && depth >= visibleDepth - 1;
+  const shouldShowChildren = expanded && hasChildren && !limitedByDepth;
+
+  const shouldScrollIntoView =
+    focusedTreeNodeId !== null &&
+    focusedTreeNodeId !== undefined &&
+    node.id !== null &&
+    node.id !== undefined &&
+    String(focusedTreeNodeId) === String(node.id);
+
+  const prevFocusTriggerRef = useRef(focusTrigger);
+  useEffect(() => {
+    if (!nodeRef.current) return;
+    const focusBumped = focusTrigger !== prevFocusTriggerRef.current;
+    prevFocusTriggerRef.current = focusTrigger;
+
+    if (isSelected) {
+      nodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
+    } else if (focusBumped && shouldScrollIntoView) {
+      nodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
+    }
+  }, [isSelected, shouldScrollIntoView, focusTrigger]);
+
+  // 4 态视觉:selected / direct / no-match / default — 选中色统一为 #3b82f6
+  const labelStateStyle: React.CSSProperties = (() => {
+    if (isSelected) {
+      return {
+        borderColor: "#3b82f6",
+        boxShadow: "0 0 0 2px rgba(59,130,246,0.32), 0 1px 3px rgba(0,0,0,0.07)",
+      };
+    }
+    if (isDirect) {
+      return {
+        borderColor: "rgba(56,189,248,0.55)",
+        boxShadow: "0 0 0 1.5px rgba(125,211,252,0.5), 0 1px 3px rgba(0,0,0,0.07)",
+        background: "rgba(240,249,255,0.7)",
+      };
+    }
+    return {};
+  })();
+
+  return (
+    <div
+      className={cn(
+        "tree-node flex flex-row items-start transition-opacity duration-200",
+        highlightLeafNames && !inHighlight && "opacity-20",
+      )}
+    >
+      {/* Node Card */}
+      <div
+        ref={nodeRef}
+        onClick={() => onSelect(node)}
+        className={cn(
+          "node-label group inline-flex items-center gap-1.5 cursor-pointer shrink-0",
+          "bg-white border border-slate-200 rounded-[9px] px-2.5 py-1.5",
+          "text-[13px] font-medium text-slate-800 whitespace-nowrap select-none",
+          "shadow-[0_1px_3px_rgba(0,0,0,0.07),0_0_0_1px_rgba(0,0,0,0.04)]",
+          "transition-[box-shadow,transform,border-color,background] duration-150",
+          // hover: --node-dim-rgb 公式 (96,165,250) — 上浮 + 蓝色淡边框 + 双层阴影
+          "hover:-translate-y-px",
+          "hover:border-[rgba(96,165,250,0.45)]",
+          "hover:shadow-[0_4px_16px_rgba(96,165,250,0.14),0_0_0_1.5px_rgba(96,165,250,0.3)]",
+          isNoMatch && "opacity-40 saturate-50",
+        )}
+        style={{
+          borderLeft: `3px solid ${NODE_BORDER}`,
+          ...labelStateStyle,
+        }}
+      >
+        {hasChildren && (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              onToggleOpen(String(node.id));
+            }}
+            className={cn(
+              "node-arrow flex items-center justify-center w-[15px] h-[15px] rounded-[4px]",
+              "bg-slate-100 border border-slate-200 text-slate-400",
+              "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600 transition-colors",
+            )}
+            title={expanded ? "收起" : "展开"}
+          >
+            {expanded ? (
+              <ChevronDown
+                size={9}
+                strokeWidth={2}
+              />
+            ) : (
+              <ChevronRight
+                size={9}
+                strokeWidth={2}
+              />
+            )}
+          </button>
+        )}
+
+        <span className={cn("px-0.5", hasChildren && "font-bold text-slate-900")}>
+          <HighlightedText
+            text={node.name || "Root"}
+            tokens={searchTokens}
+          />
+        </span>
+
+        {/* 总计数 — 仅在父节点上显示,叶节点交给 element toggle */}
+        {totalCount > 0 && !showElementsToggle && (
+          <span
+            className={cn(COUNT_BADGE_BASE, COUNT_BADGE_BLUE)}
+            title={`子树元素总数 ${totalCount}`}
+          >
+            {totalCount.toLocaleString()}
+          </span>
+        )}
+
+        {/* 帖子数 */}
+        {node.total_posts_count > 0 && (
+          <span
+            className="inline-flex items-center text-[10px] font-semibold leading-[1.5] px-[6px] py-px rounded-full bg-slate-100 text-slate-600 tabular-nums"
+            title={`相关帖子 ${node.total_posts_count}`}
+          >
+            {node.total_posts_count} 帖
+          </span>
+        )}
+
+        {/* 当前节点 elements 列表 (叶节点) — expandable 版本 */}
+        {showElementsToggle && (
+          <button
+            type="button"
+            onClick={(e) => {
+              e.stopPropagation();
+              setElementsOpen((v) => !v);
+            }}
+            className={cn(
+              COUNT_BADGE_BASE,
+              "gap-[3px]",
+              elementsOpen ? COUNT_BADGE_BLUE_EXPANDED : COUNT_BADGE_BLUE_EXPANDABLE,
+            )}
+            title={elementsOpen ? "收起元素" : "展开元素"}
+          >
+            {ownCount || elements.length}
+            <ChevronDown
+              size={9}
+              strokeWidth={2.4}
+              className={cn("transition-transform", elementsOpen ? "" : "-rotate-90")}
+            />
+          </button>
+        )}
+
+        {/* 关系指标点 */}
+        {(metrics.patternCount || metrics.reqCount || metrics.procCount || metrics.capCount || metrics.toolCount) >
+          0 && (
+          <div className="flex items-center gap-1 ml-1">
+            {[
+              { key: "pattern", count: metrics.patternCount, color: "bg-blue-500" },
+              { key: "req", count: metrics.reqCount, color: "bg-cyan-500" },
+              { key: "proc", count: metrics.procCount, color: "bg-green-500" },
+              { key: "cap", count: metrics.capCount, color: "bg-amber-400" },
+              { key: "tool", count: metrics.toolCount, color: "bg-orange-500" },
+            ]
+              .filter((m) => m.count > 0)
+              .map((m) => (
+                <span
+                  key={m.key}
+                  className={cn("w-2 h-2 rounded-full cursor-help", m.color)}
+                  onMouseEnter={(e) =>
+                    setHoveredMetric({
+                      key: m.key,
+                      count: m.count,
+                      colorClass: cn(m.color, "border", m.color.replace("bg-", "border-"), "text-white"),
+                      x:
+                        e.currentTarget.getBoundingClientRect().left +
+                        e.currentTarget.getBoundingClientRect().width / 2,
+                      y: e.currentTarget.getBoundingClientRect().top - 8,
+                    })
+                  }
+                  onMouseLeave={() => setHoveredMetric((prev) => (prev?.key === m.key ? null : prev))}
+                />
+              ))}
+          </div>
+        )}
+
+        {/* hover-only 详情按钮 */}
+        {onOpenDetail && (
+          <button
+            type="button"
+            onClick={(e) => {
+              e.stopPropagation();
+              onOpenDetail(node);
+            }}
+            className={cn(
+              "ml-1 inline-flex items-center justify-center w-[18px] h-[18px] rounded-[4px]",
+              "border border-slate-200 bg-slate-100 text-slate-400",
+              "opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto",
+              "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600",
+              "transition-[opacity,background,border-color,color]",
+            )}
+            title="查看详情"
+          >
+            <Info
+              size={11}
+              strokeWidth={1.8}
+            />
+          </button>
+        )}
+      </div>
+
+      {/* 横向布局:children 在父节点右侧 */}
+      {shouldShowChildren && (
+        <div className="flex flex-col relative ml-8 shrink-0">
+          {/* 父节点右侧到第一个子节点的横向连接线 */}
+          <div className="absolute -left-8 top-[17px] w-8 h-px bg-[#d1d9e6]" />
+
+          {/* leaf elements 行 - 仅当节点本身是叶子且 elementsOpen */}
+          {showElementsToggle && elementsOpen && (
+            <div className="flex flex-wrap gap-[4px_6px] items-start max-w-[720px] pb-2">
+              {elements.map((el, i) => (
+                <span
+                  key={el.element_id ?? `${el.name}-${i}`}
+                  className={cn(
+                    "inline-flex items-center gap-1 text-[11px] font-medium px-2 py-[3px] rounded-full",
+                    "bg-white border border-slate-200 text-slate-600",
+                    "hover:bg-blue-50 hover:border-blue-400 hover:text-blue-600 transition-colors",
+                  )}
+                >
+                  <span>
+                    <HighlightedText
+                      text={el.name || ""}
+                      tokens={searchTokens}
+                    />
+                  </span>
+                  <span className="text-[10px] font-semibold text-slate-400 tabular-nums">{el.count ?? 0}</span>
+                </span>
+              ))}
+            </div>
+          )}
+
+          {node.children.map((child: any, i: number) => (
+            <div
+              key={child.id || child.path || i}
+              className={cn(
+                "relative pl-8 pb-3 flex items-start",
+                // 横向小线 (从主干到子节点)
+                "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-[#d1d9e6]",
+                // 主干竖线
+                "after:absolute after:left-0 after:w-px after:bg-[#d1d9e6]",
+                "first:after:top-[17px] first:after:bottom-0",
+                "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
+                "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
+                "first:last:after:hidden",
+              )}
+            >
+              <HorizontalTreeNode
+                node={child}
+                onSelect={onSelect}
+                onOpenDetail={onOpenDetail}
+                selectedIds={selectedIds}
+                depth={depth + 1}
+                highlightLeafNames={highlightLeafNames}
+                subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                sourceNodeIds={sourceNodeIds}
+                patternNodeIds={patternNodeIds}
+                nodeMetricsMap={nodeMetricsMap}
+                dimStyle={dimStyle}
+                focusedTreeNodeId={focusedTreeNodeId}
+                focusTrigger={focusTrigger}
+                isOpen={isOpen}
+                onToggleOpen={onToggleOpen}
+                directHighlightNodeIds={directHighlightNodeIds}
+                indirectHighlightNodeIds={indirectHighlightNodeIds}
+                hasActiveFilter={hasActiveFilter}
+                searchTokens={searchTokens}
+                visibleDepth={visibleDepth}
+              />
+            </div>
+          ))}
+        </div>
+      )}
+
+      {hoveredMetric &&
+        createPortal(
+          <div
+            className={cn(
+              "fixed z-[99999] rounded-lg border shadow-lg min-w-[40px] px-3 py-2 flex items-center justify-center -translate-x-1/2 -translate-y-full",
+              hoveredMetric.colorClass,
+            )}
+            style={{ left: hoveredMetric.x, top: hoveredMetric.y }}
+          >
+            <div className="text-sm font-black leading-none text-center">{hoveredMetric.count}</div>
+          </div>,
+          document.body,
+        )}
+    </div>
+  );
+}
+
+// ---------- 工具函数 ----------
+function computeMaxDepth(nodes: any[], d = 0): number {
+  if (!nodes || !nodes.length) return d - 1;
+  let m = d;
+  for (const n of nodes) {
+    if (n.children && n.children.length) m = Math.max(m, computeMaxDepth(n.children, d + 1));
+  }
+  return m;
+}
+
+export function CategoryTreeNew({
+  data,
+  onSelect,
+  onOpenDetail,
+  selectedIds,
+  highlightLeafNames = null,
+  subtreeHighlightNodeIds = null,
+  sourceNodeIds = null,
+  patternNodeIds = null,
+  nodeMetricsMap = {},
+  filterLabel,
+  onClearFilter,
+  totalNodeCount,
+  wideMode = false,
+  onToggleWideMode,
+  treeFocusIndex = 0,
+  treeMatchedCount = 0,
+  focusedTreeNodeId = null,
+  focusTrigger = 0,
+  onTreeFocusPrev,
+  onTreeFocusNext,
+  directHighlightNodeIds = null,
+  indirectHighlightNodeIds = null,
+  hasActiveFilter = false,
+}: {
+  data: any;
+  onSelect: (node: any) => void;
+  onOpenDetail?: (node: any) => void;
+  selectedIds: Set<string>;
+  highlightLeafNames?: Set<string> | null;
+  subtreeHighlightNodeIds?: Set<string> | null;
+  sourceNodeIds?: Set<string> | null;
+  patternNodeIds?: Set<string> | null;
+  nodeMetricsMap?: Record<
+    string,
+    {
+      reqCount: number;
+      procCount: number;
+      capCount: number;
+      toolCount: number;
+      nodeCount: number;
+      patternCount: number;
+    }
+  >;
+  filterLabel?: string | null;
+  onClearFilter?: () => void;
+  totalNodeCount?: number;
+  wideMode?: boolean;
+  onToggleWideMode?: () => void;
+  treeFocusIndex?: number;
+  treeMatchedCount?: number;
+  focusedTreeNodeId?: string | number | null;
+  focusTrigger?: number;
+  onTreeFocusPrev?: () => void;
+  onTreeFocusNext?: () => void;
+  directHighlightNodeIds?: Set<string> | null;
+  indirectHighlightNodeIds?: Set<string> | null;
+  hasActiveFilter?: boolean;
+}) {
+  const [scale, setScale] = useState(1);
+  const [searchInput, setSearchInput] = useState("");
+
+  // 展开状态
+  const [defaultOpen, setDefaultOpen] = useState(true);
+  const [openStates, setOpenStates] = useState<Map<string, boolean>>(new Map());
+  const [autoCollapse, setAutoCollapse] = useState(false);
+
+  // dim section 折叠 (按维度名)
+  const [collapsedDims, setCollapsedDims] = useState<Set<string>>(new Set());
+
+  // visibleDepth: -1 表示无限制(全展开),N 表示只显示 0..N-1 层
+  const [visibleDepth, setVisibleDepth] = useState<number>(-1);
+  // 底部按钮的 primary 状态
+  const [primaryAction, setPrimaryAction] = useState<string>("expand-all");
+
+  // 浮动控制条 hint
+  const [hintText, setHintText] = useState<string | null>(null);
+  const hintTimerRef = useRef<number | null>(null);
+  const showHint = useCallback((msg: string) => {
+    setHintText(msg);
+    if (hintTimerRef.current) window.clearTimeout(hintTimerRef.current);
+    hintTimerRef.current = window.setTimeout(() => setHintText(null), 1600);
+  }, []);
+
+  // 滚动容器引用 + dim section 引用 (用于 HUD anchor 与 scroll spy)
+  const scrollContainerRef = useRef<HTMLDivElement>(null);
+  const dimSectionRefs = useRef<Map<string, HTMLDivElement>>(new Map());
+  const [activeDim, setActiveDim] = useState<string | null>(null);
+
+  const isOpen = useCallback(
+    (id: string) => {
+      if (openStates.has(id)) return openStates.get(id)!;
+      return defaultOpen;
+    },
+    [openStates, defaultOpen],
+  );
+
+  const onToggleOpen = useCallback(
+    (id: string) => {
+      setOpenStates((prev) => {
+        const next = new Map(prev);
+        const current = next.has(id) ? next.get(id)! : defaultOpen;
+        next.set(id, !current);
+        return next;
+      });
+    },
+    [defaultOpen],
+  );
+
+  const findAncestorIds = useMemo(() => {
+    return (targetId: string): string[] => {
+      if (!data?.children) return [];
+      const walk = (node: any, path: string[]): string[] | null => {
+        if (String(node.id) === targetId) return path;
+        if (!node.children) return null;
+        for (const child of node.children) {
+          const r = walk(child, [...path, String(node.id)]);
+          if (r) return r;
+        }
+        return null;
+      };
+      for (const top of data.children) {
+        const r = walk(top, []);
+        if (r) return r;
+      }
+      return [];
+    };
+  }, [data]);
+
+  const expandAll = () => {
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setPrimaryAction("expand-all");
+    showHint("全部展开");
+  };
+  const collapseAll = () => {
+    setDefaultOpen(false);
+    setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setPrimaryAction("collapse-all");
+    showHint("全部收起");
+  };
+
+  // 维度内最大深度 (dim 子树最深)
+  const maxDepth = useMemo(() => {
+    if (!data?.children) return 0;
+    return Math.max(0, computeMaxDepth(data.children));
+  }, [data]);
+
+  const stepExpand = () => {
+    const max = maxDepth + 1;
+    let cur = visibleDepth < 0 ? max : visibleDepth;
+    if (cur >= max) {
+      setVisibleDepth(-1);
+      showHint("已全部展开");
+    } else {
+      cur += 1;
+      setVisibleDepth(cur >= max ? -1 : cur);
+      showHint(`展开至第 ${cur} 层`);
+    }
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-expand");
+  };
+  const stepCollapse = () => {
+    let cur = visibleDepth < 0 ? maxDepth + 1 : visibleDepth;
+    if (cur <= 1) {
+      showHint("已收起至一级分类");
+      setVisibleDepth(1);
+    } else {
+      cur -= 1;
+      setVisibleDepth(cur);
+      showHint(`收起至第 ${cur} 层`);
+    }
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-collapse");
+  };
+  const stepCollapseReverse = () => {
+    let cur = visibleDepth < 0 ? maxDepth + 1 : visibleDepth;
+    if (cur <= 1) {
+      showHint("已收起至一级分类");
+      return;
+    }
+    cur -= 1;
+    setVisibleDepth(cur);
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setPrimaryAction("step-collapse-rev");
+    showHint(`反向收起至第 ${cur} 层`);
+  };
+  const reset = () => {
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+    setVisibleDepth(-1);
+    setCollapsedDims(new Set());
+    setPrimaryAction("expand-all");
+    showHint("已重置");
+  };
+
+  const toggleAutoCollapse = () => {
+    setAutoCollapse((v) => {
+      const nextAuto = !v;
+      if (nextAuto && focusedTreeNodeId != null) {
+        const ancestors = findAncestorIds(String(focusedTreeNodeId));
+        setDefaultOpen(false);
+        const next = new Map<string, boolean>();
+        ancestors.forEach((id) => next.set(id, true));
+        setOpenStates(next);
+      }
+      return nextAuto;
+    });
+  };
+
+  useEffect(() => {
+    if (focusedTreeNodeId == null) return;
+    const ancestors = findAncestorIds(String(focusedTreeNodeId));
+    if (ancestors.length === 0) return;
+
+    if (autoCollapse) {
+      setDefaultOpen(false);
+      const next = new Map<string, boolean>();
+      ancestors.forEach((id) => next.set(id, true));
+      setOpenStates(next);
+    } else {
+      setOpenStates((prev) => {
+        let changed = false;
+        const next = new Map(prev);
+        ancestors.forEach((id) => {
+          const cur = next.has(id) ? next.get(id)! : defaultOpen;
+          if (!cur) {
+            next.set(id, true);
+            changed = true;
+          }
+        });
+        return changed ? next : prev;
+      });
+    }
+  }, [focusedTreeNodeId, autoCollapse, findAncestorIds]);
+
+  // 搜索 token (逗号分隔)
+  const searchTokens = useMemo(
+    () =>
+      searchInput
+        .split(/[,,]/)
+        .map((s) => s.trim().toLowerCase())
+        .filter(Boolean),
+    [searchInput],
+  );
+
+  // 维度分组
+  const grouped = useMemo(() => {
+    if (!data?.children) return [];
+    const buckets: Record<string, any[]> = {};
+    for (const node of data.children) {
+      const t = node.source_type || "其他";
+      (buckets[t] ||= []).push(node);
+    }
+    const ordered = [...DIM_ORDER, ...Object.keys(buckets).filter((k) => !DIM_ORDER.includes(k))];
+    return ordered
+      .filter((dim) => buckets[dim] && buckets[dim].length)
+      .map((dim) => {
+        const nodes = buckets[dim];
+        const total = nodes.reduce((s, n) => s + (n.total_element_count ?? n.element_count ?? 0), 0);
+        return { dim, style: DIM_STYLES[dim] || { ...DEFAULT_DIM, label: dim }, nodes, total };
+      });
+  }, [data]);
+
+  // scroll spy 更新当前可见 dim — 用 getBoundingClientRect 计算相对偏移
+  // (offsetTop 取自 offsetParent — 由于外层容器是 position:relative,offsetTop 会包含 toolbar 高度,
+  //  导致 scrollTo 多滚动一段、目标被 toolbar 遮住)
+  useEffect(() => {
+    const sc = scrollContainerRef.current;
+    if (!sc) return;
+    let raf = 0;
+    const update = () => {
+      raf = 0;
+      const scTop = sc.getBoundingClientRect().top;
+      let bestDim: string | null = null;
+      let bestDist = Infinity;
+      for (const [dim, el] of dimSectionRefs.current) {
+        const offset = el.getBoundingClientRect().top - scTop; // 相对滚动容器顶部
+        if (offset <= 60) {
+          const dist = Math.abs(offset);
+          if (dist < bestDist) {
+            bestDist = dist;
+            bestDim = dim;
+          }
+        }
+      }
+      if (!bestDim && grouped.length) bestDim = grouped[0].dim;
+      setActiveDim(bestDim);
+    };
+    update();
+    const onScroll = () => {
+      if (raf) return;
+      raf = requestAnimationFrame(update);
+    };
+    sc.addEventListener("scroll", onScroll, { passive: true });
+    return () => {
+      sc.removeEventListener("scroll", onScroll);
+      if (raf) cancelAnimationFrame(raf);
+    };
+  }, [grouped]);
+
+  const scrollToDim = (dim: string) => {
+    const el = dimSectionRefs.current.get(dim);
+    const sc = scrollContainerRef.current;
+    if (el && sc) {
+      const scRect = sc.getBoundingClientRect();
+      const elRect = el.getBoundingClientRect();
+      // 让目标 dim 顶部刚好出现在滚动视口顶部下方 16px
+      const target = sc.scrollTop + (elRect.top - scRect.top) - 16;
+      sc.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
+    }
+  };
+
+  if (!data || !data.children)
+    return (
+      <div className="bg-slate-100/50 rounded-2xl border border-slate-200 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
+        <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
+        加载树形结构中...
+      </div>
+    );
+
+  return (
+    <div className="bg-[#eef0f4] rounded-2xl border border-[#e2e6ed] overflow-hidden flex flex-col h-full relative shadow-sm">
+      {/* 顶部工具栏 */}
+      <div className="px-3 py-2 bg-white border-b border-[#e2e6ed] flex items-center gap-2 shrink-0 shadow-[0_1px_4px_rgba(0,0,0,0.05)]">
+        <FolderTree
+          size={15}
+          className="text-slate-500 shrink-0"
+        />
+        <span className="text-[13px] font-bold text-slate-700 shrink-0">分类树</span>
+
+        <div className="w-px h-5 bg-[#e2e6ed] mx-0.5 shrink-0" />
+
+        {/* 搜索框 */}
+        <div className="relative flex items-center shrink-0">
+          <Search
+            size={13}
+            className="absolute left-2 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none"
+          />
+          <input
+            type="text"
+            value={searchInput}
+            onChange={(e) => setSearchInput(e.target.value)}
+            onKeyDown={(e) => {
+              if (e.key === "Escape") setSearchInput("");
+            }}
+            placeholder="搜索分类… (逗号分隔多关键字)"
+            className={cn(
+              "h-[28px] pl-7 pr-2 rounded-[7px] border border-[#e2e6ed] bg-[#eef0f4]",
+              "text-[12.5px] text-slate-800 placeholder:text-slate-400 outline-none w-[200px]",
+              "transition-[border-color,background,width,box-shadow] duration-200",
+              "focus:border-blue-500 focus:bg-white focus:w-[260px] focus:shadow-[0_0_0_3px_rgba(59,130,246,0.1)]",
+            )}
+          />
+        </div>
+
+        <div className="flex-1 min-w-0" />
+
+        {filterLabel && (
+          <button
+            type="button"
+            onClick={onClearFilter}
+            className="text-[10px] font-bold px-2 py-1 rounded-md bg-sky-100 text-sky-700 hover:bg-sky-200 transition-colors shrink-0"
+            title="清除筛选"
+          >
+            <span className="max-w-[140px] inline-block truncate align-bottom">{filterLabel}</span> ×
+          </button>
+        )}
+        {treeMatchedCount > 1 && (
+          <div className="flex items-center gap-1 shrink-0">
+            <button
+              type="button"
+              onClick={onTreeFocusPrev}
+              disabled={treeFocusIndex === 0}
+              className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronLeft size={13} />
+            </button>
+            <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center tabular-nums">
+              {treeFocusIndex + 1}/{treeMatchedCount}
+            </span>
+            <button
+              type="button"
+              onClick={onTreeFocusNext}
+              disabled={treeFocusIndex === treeMatchedCount - 1}
+              className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronRight size={13} />
+            </button>
+          </div>
+        )}
+
+        {/* 顶部统计 */}
+        <div className="flex items-center gap-2 shrink-0">
+          <div className="inline-flex items-baseline gap-1">
+            <span className="text-[13px] font-bold text-blue-600 tabular-nums">
+              {(totalNodeCount ?? 0).toLocaleString()}
+            </span>
+            <span className="text-[11.5px] text-slate-400">分类</span>
+          </div>
+        </div>
+
+        <div className="w-px h-5 bg-[#e2e6ed] mx-0.5 shrink-0" />
+
+        {/* 自动折叠 + 宽窄模式 */}
+        <button
+          onClick={toggleAutoCollapse}
+          className={cn(
+            "text-[10px] font-bold px-2 py-1 rounded transition-colors shrink-0",
+            autoCollapse ? "bg-blue-100 text-blue-600" : "bg-slate-100 text-slate-600 hover:bg-slate-200",
+          )}
+          title="开启后:每次定位到新节点都先全折叠再展开该节点的根路径"
+        >
+          自动折叠
+        </button>
+        {onToggleWideMode && (
+          <button
+            onClick={onToggleWideMode}
+            className={cn(
+              "text-[10px] font-bold px-2 py-1 rounded transition-colors shrink-0",
+              wideMode ? "bg-blue-100 text-blue-600" : "bg-slate-100 text-slate-600 hover:bg-slate-200",
+            )}
+          >
+            {wideMode ? "宽模式" : "窄模式"}
+          </button>
+        )}
+      </div>
+
+      {/* 缩放控件 - 放在右下角避开 HUD */}
+      <div className="absolute bottom-4 right-4 z-50 flex gap-1 bg-white/96 backdrop-blur p-1 rounded-[10px] shadow-[0_2px_8px_rgba(0,0,0,0.08)] border border-[#e2e6ed]">
+        <button
+          onClick={() => setScale((s) => Math.min(s + 0.15, 3))}
+          className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
+          title="放大"
+        >
+          <ZoomIn size={13} />
+        </button>
+        <button
+          onClick={() => setScale((s) => Math.max(s - 0.15, 0.3))}
+          className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
+          title="缩小"
+        >
+          <ZoomOut size={13} />
+        </button>
+        <button
+          onClick={() => setScale(1)}
+          className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
+          title="重置缩放"
+        >
+          <Maximize size={13} />
+        </button>
+      </div>
+
+      {/* 树面板 */}
+      <div
+        ref={scrollContainerRef}
+        className="flex-1 w-full h-full overflow-x-auto overflow-y-auto bg-[#eef0f4] px-5 pt-4 pb-24 custom-scrollbar"
+      >
+        <div
+          className="flex flex-col gap-3 select-none min-w-max origin-top-left transition-[zoom] duration-200"
+          style={{ zoom: scale } as any}
+        >
+          {grouped.map(({ dim, style, nodes, total }) => {
+            const isCollapsed = collapsedDims.has(dim);
+            // 把 dim 当成一个 root 节点,name=维度名,children=L1 nodes
+            const dimRoot = {
+              id: `__dim__${dim}`,
+              name: style.label,
+              source_type: dim,
+              total_element_count: total,
+              children: nodes,
+              total_posts_count: 0,
+              element_count: 0,
+              elements: [],
+              __isDim: true,
+              __dimCount: total,
+            };
+            return (
+              <div
+                key={dim}
+                ref={(el) => {
+                  if (el) dimSectionRefs.current.set(dim, el);
+                  else dimSectionRefs.current.delete(dim);
+                }}
+                className="flex flex-row items-start"
+              >
+                {/* dim section header (chip-like, 同时是一个伪 root 节点) */}
+                <button
+                  type="button"
+                  onClick={() =>
+                    setCollapsedDims((prev) => {
+                      const next = new Set(prev);
+                      if (next.has(dim)) next.delete(dim);
+                      else next.add(dim);
+                      return next;
+                    })
+                  }
+                  className={cn(
+                    "node-label inline-flex items-center gap-1.5 cursor-pointer shrink-0",
+                    "bg-white border border-slate-200 rounded-[9px] px-2.5 py-1.5",
+                    "text-[13px] font-bold text-slate-800 whitespace-nowrap select-none",
+                    "shadow-[0_1px_3px_rgba(0,0,0,0.07),0_0_0_1px_rgba(0,0,0,0.04)]",
+                    "transition-[box-shadow,transform,border-color,background,opacity] duration-150",
+                    "hover:-translate-y-px",
+                    "hover:border-[rgba(96,165,250,0.45)]",
+                    "hover:shadow-[0_4px_16px_rgba(96,165,250,0.14),0_0_0_1.5px_rgba(96,165,250,0.3)]",
+                    isCollapsed && "opacity-50",
+                  )}
+                  style={{ borderLeft: `3px solid ${NODE_BORDER}` }}
+                >
+                  <span
+                    className={cn(
+                      "node-arrow flex items-center justify-center w-[15px] h-[15px] rounded-[4px]",
+                      "bg-slate-100 border border-slate-200 text-slate-400",
+                    )}
+                  >
+                    {isCollapsed ? (
+                      <ChevronRight
+                        size={9}
+                        strokeWidth={2}
+                      />
+                    ) : (
+                      <ChevronDown
+                        size={9}
+                        strokeWidth={2}
+                      />
+                    )}
+                  </span>
+                  <span className="px-0.5 tracking-wide">{style.label}</span>
+                  <span className={cn(COUNT_BADGE_BASE, COUNT_BADGE_BLUE)}>{total.toLocaleString()}</span>
+                </button>
+
+                {/* dim section children (L1 nodes) */}
+                {!isCollapsed && nodes.length > 0 && (
+                  <div className="flex flex-col relative ml-8 shrink-0">
+                    <div className="absolute -left-8 top-[17px] w-8 h-px bg-[#d1d9e6]" />
+                    {nodes.map((subNode: any, subIdx: number) => (
+                      <div
+                        key={subNode.id || subIdx}
+                        className={cn(
+                          "relative pl-8 pb-3 flex items-start",
+                          "before:absolute before:left-0 before:top-[17px] before:w-8 before:h-px before:bg-[#d1d9e6]",
+                          "after:absolute after:left-0 after:w-px after:bg-[#d1d9e6]",
+                          "first:after:top-[17px] first:after:bottom-0",
+                          "last:after:top-0 last:after:bottom-[calc(100%-17px)]",
+                          "[&:not(:first-child):not(:last-child)]:after:top-0 [&:not(:first-child):not(:last-child)]:after:bottom-0",
+                          "first:last:after:hidden",
+                        )}
+                      >
+                        <HorizontalTreeNode
+                          node={subNode}
+                          onSelect={onSelect}
+                          onOpenDetail={onOpenDetail}
+                          selectedIds={selectedIds}
+                          depth={1}
+                          highlightLeafNames={highlightLeafNames}
+                          subtreeHighlightNodeIds={subtreeHighlightNodeIds}
+                          sourceNodeIds={sourceNodeIds}
+                          patternNodeIds={patternNodeIds}
+                          nodeMetricsMap={nodeMetricsMap}
+                          dimStyle={style}
+                          focusedTreeNodeId={focusedTreeNodeId}
+                          focusTrigger={focusTrigger}
+                          isOpen={isOpen}
+                          onToggleOpen={onToggleOpen}
+                          directHighlightNodeIds={directHighlightNodeIds}
+                          indirectHighlightNodeIds={indirectHighlightNodeIds}
+                          hasActiveFilter={hasActiveFilter}
+                          searchTokens={searchTokens}
+                          visibleDepth={visibleDepth}
+                        />
+                      </div>
+                    ))}
+                  </div>
+                )}
+                {/* 不渲染 dimRoot 内容 - 仅作为 schema 占位 */}
+                {/* 占位以避免未使用警告 */}
+                <span className="hidden">{dimRoot.id}</span>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* 右侧 HUD anchor */}
+      {grouped.length > 1 && (
+        <div
+          className={cn(
+            "absolute z-40 flex flex-col gap-0.5 items-end",
+            "top-[64px] right-[14px]",
+            "bg-white/92 backdrop-blur-sm border border-[#e2e6ed] rounded-[10px] py-1.5 px-3",
+            "shadow-[0_1px_6px_rgba(0,0,0,0.07)]",
+          )}
+        >
+          {grouped.map(({ dim, style }) => {
+            const isActive = activeDim === dim;
+            return (
+              <button
+                key={dim}
+                type="button"
+                onClick={() => scrollToDim(dim)}
+                className={cn(
+                  "flex items-center gap-2 cursor-pointer py-[3px] rounded select-none transition-colors",
+                  "hover:bg-black/[0.03]",
+                )}
+                title={`跳转到 ${style.label}`}
+              >
+                <span
+                  className={cn(
+                    "text-[12px] tracking-tight whitespace-nowrap transition-colors",
+                    isActive ? "font-semibold text-blue-600" : "font-medium text-slate-400",
+                  )}
+                >
+                  {style.label}
+                </span>
+                <span
+                  className="rounded transition-all"
+                  style={{
+                    height: 2,
+                    width: isActive ? 22 : 14,
+                    background: isActive ? "#2563eb" : "#d1d5db",
+                  }}
+                />
+              </button>
+            );
+          })}
+        </div>
+      )}
+
+      {/* 浮动底部控制栏 */}
+      <div
+        className={cn(
+          "absolute z-50 flex items-center gap-px p-1 rounded-[14px]",
+          "bottom-4 left-1/2 -translate-x-1/2",
+          "bg-white/96 backdrop-blur-xl border border-[#e2e6ed]",
+          "shadow-[0_4px_20px_rgba(0,0,0,0.11),0_1px_4px_rgba(0,0,0,0.06)]",
+        )}
+      >
+        <CtrlButton
+          icon={
+            <ChevronsDown
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="全部展开"
+          primary={primaryAction === "expand-all"}
+          onClick={expandAll}
+        />
+        <CtrlButton
+          icon={
+            <ChevronsUp
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="全部收起"
+          primary={primaryAction === "collapse-all"}
+          onClick={collapseAll}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={
+            <ChevronDown
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="逐层展开"
+          primary={primaryAction === "step-expand"}
+          onClick={stepExpand}
+        />
+        <CtrlButton
+          icon={
+            <ChevronUp
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="逐层收起"
+          primary={primaryAction === "step-collapse"}
+          onClick={stepCollapse}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={
+            <ChevronUp
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="反向收起"
+          primary={primaryAction === "step-collapse-rev"}
+          onClick={stepCollapseReverse}
+        />
+        <CtrlSep />
+        <CtrlButton
+          icon={
+            <RotateCcw
+              size={13}
+              strokeWidth={1.8}
+            />
+          }
+          label="重置"
+          primary={false}
+          onClick={reset}
+        />
+      </div>
+
+      {/* 操作 hint */}
+      {hintText && (
+        <div
+          className={cn(
+            "absolute z-[51] left-1/2 -translate-x-1/2 bottom-[70px]",
+            "px-3.5 py-1.5 rounded-full whitespace-nowrap text-[12px]",
+            "bg-[rgba(24,32,46,0.82)] text-white pointer-events-none",
+          )}
+          style={{ fontFamily: '"JetBrains Mono","SF Mono",Consolas,monospace' }}
+        >
+          {hintText}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 浮动控制栏内的按钮和分隔
+function CtrlButton({
+  icon,
+  label,
+  primary,
+  onClick,
+}: {
+  icon: React.ReactNode;
+  label: string;
+  primary?: boolean;
+  onClick?: () => void;
+}) {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={cn(
+        "inline-flex items-center gap-1.5 px-3 py-[7px] rounded-[10px]",
+        "text-[12.5px] font-medium whitespace-nowrap transition-colors",
+        primary
+          ? "bg-blue-600 text-white hover:bg-blue-700"
+          : "bg-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-800",
+      )}
+    >
+      {icon}
+      <span>{label}</span>
+    </button>
+  );
+}
+
+function CtrlSep() {
+  return <span className="w-px h-[22px] bg-[#e2e6ed] mx-0.5" />;
+}

+ 2 - 2
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useMemo, useRef, useCallback, Fragment, type ReactNode, type WheelEvent } from "react";
 import { createPortal } from "react-dom";
 import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from "lucide-react";
-import { CategoryTree } from "../components/dashboard/CategoryTree";
+import { CategoryTreeNew } from "../components/dashboard/CategoryTreeNew";
 import { VersionSwitcher } from "../components/layout/VersionSwitcher";
 import { SideDrawer } from "../components/common/SideDrawer";
 import { cn } from "../lib/utils";
@@ -1944,7 +1944,7 @@ export function Dashboard({
               className="shrink-0 min-h-0 relative"
               style={{ width: treeWidth }}
             >
-              <CategoryTree
+              <CategoryTreeNew
                 data={treeData}
                 onSelect={(node) => {
                   setManualFocusedTreeNodeId(null);