فهرست منبع

Merge branch 'feat-ui-0509'

刘文武 7 ساعت پیش
والد
کامیت
b808a720bf

+ 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"
+    ]
+  }
+}

+ 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);