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