|
@@ -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" />;
|
|
|
|
|
+}
|