|
|
@@ -1,14 +1,17 @@
|
|
|
import { useState } from 'react';
|
|
|
+import { createPortal } from 'react-dom';
|
|
|
import { cn } from '../../lib/utils';
|
|
|
import { ChevronRight, ChevronDown, ZoomIn, ZoomOut, Maximize, FolderTree } from 'lucide-react';
|
|
|
|
|
|
interface NodeProps {
|
|
|
node: any;
|
|
|
onSelect: (node: any) => void;
|
|
|
- onDoubleClick: (node: any) => void;
|
|
|
selectedId: string | number | null;
|
|
|
level: number;
|
|
|
highlightLeafNames: Set<string> | null; // null = no filter active
|
|
|
+ subtreeHighlightNodeIds: Set<string> | null;
|
|
|
+ sourceNodeNames: Set<string> | null;
|
|
|
+ nodeMetricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number }>;
|
|
|
}
|
|
|
|
|
|
// Returns true if this node or any descendant is in the highlight set
|
|
|
@@ -19,88 +22,75 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
|
|
|
return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
|
|
|
}
|
|
|
|
|
|
-function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level, highlightLeafNames }: NodeProps) {
|
|
|
+function HorizontalTreeNode({ node, onSelect, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeNames, nodeMetricsMap }: NodeProps) {
|
|
|
const [expanded, setExpanded] = useState(true);
|
|
|
+ const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
|
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
-
|
|
|
- const count = node.total_posts_count || 0;
|
|
|
- const status = node.node_status ?? 0;
|
|
|
-
|
|
|
- let intensity = 0;
|
|
|
- if (count < 10) intensity = 0;
|
|
|
- else if (count < 50) intensity = 1;
|
|
|
- else if (count < 100) intensity = 2;
|
|
|
- else if (count < 300) intensity = 3;
|
|
|
- else if (count < 800) intensity = 4;
|
|
|
- else intensity = 5;
|
|
|
-
|
|
|
- const palettes = {
|
|
|
- 0: [
|
|
|
- { bg: "bg-slate-100", border: "border-slate-200", text: "text-slate-900" },
|
|
|
- { bg: "bg-slate-200", border: "border-slate-300", text: "text-slate-900" },
|
|
|
- { bg: "bg-slate-300", border: "border-slate-400", text: "text-slate-900" },
|
|
|
- { bg: "bg-slate-400", border: "border-slate-500", text: "text-slate-900" },
|
|
|
- { bg: "bg-slate-500", border: "border-slate-600", text: "text-white" },
|
|
|
- { bg: "bg-slate-600", border: "border-slate-700", text: "text-white" }
|
|
|
- ],
|
|
|
- 1: [
|
|
|
- { bg: "bg-indigo-100", border: "border-indigo-200", text: "text-indigo-900" },
|
|
|
- { bg: "bg-indigo-300", border: "border-indigo-400", text: "text-indigo-900" },
|
|
|
- { bg: "bg-indigo-500", border: "border-indigo-600", text: "text-white" },
|
|
|
- { bg: "bg-indigo-600", border: "border-indigo-700", text: "text-white" },
|
|
|
- { bg: "bg-indigo-700", border: "border-indigo-800", text: "text-white" },
|
|
|
- { bg: "bg-indigo-900", border: "border-indigo-950", text: "text-white" }
|
|
|
- ],
|
|
|
- 2: [
|
|
|
- { bg: "bg-teal-100", border: "border-teal-200", text: "text-teal-900" },
|
|
|
- { bg: "bg-teal-300", border: "border-teal-400", text: "text-teal-900" },
|
|
|
- { bg: "bg-teal-500", border: "border-teal-600", text: "text-white" },
|
|
|
- { bg: "bg-teal-600", border: "border-teal-700", text: "text-white" },
|
|
|
- { bg: "bg-teal-700", border: "border-teal-800", text: "text-white" },
|
|
|
- { bg: "bg-teal-900", border: "border-teal-950", text: "text-white" }
|
|
|
- ],
|
|
|
- 3: [
|
|
|
- { bg: "bg-green-100", border: "border-green-200", text: "text-green-900" },
|
|
|
- { bg: "bg-green-300", border: "border-green-400", text: "text-green-900" },
|
|
|
- { bg: "bg-green-500", border: "border-green-600", text: "text-white" },
|
|
|
- { bg: "bg-green-600", border: "border-green-700", text: "text-white" },
|
|
|
- { bg: "bg-green-700", border: "border-green-800", text: "text-white" },
|
|
|
- { bg: "bg-green-900", border: "border-green-950", text: "text-white" }
|
|
|
- ]
|
|
|
- };
|
|
|
-
|
|
|
- const theme = palettes[status as keyof typeof palettes][intensity];
|
|
|
- let textColor = theme.text;
|
|
|
+ const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
|
|
|
+ let textColor = "text-slate-800";
|
|
|
if (hasChildren) textColor = cn(textColor, "font-extrabold");
|
|
|
|
|
|
// Highlight/dim logic for reverse filtering
|
|
|
const inHighlight = nodeHasHighlightedLeaf(node, highlightLeafNames);
|
|
|
const isLeafHighlighted = highlightLeafNames && highlightLeafNames.has(node.name);
|
|
|
-
|
|
|
+ const isInSubtreeHighlight = subtreeHighlightNodeIds?.has(String(node.id)) ?? false;
|
|
|
+ const isSourceNode = sourceNodeNames?.has(node.name) ?? false;
|
|
|
+ const isSelected =
|
|
|
+ selectedId !== null &&
|
|
|
+ selectedId !== undefined &&
|
|
|
+ node.id !== null &&
|
|
|
+ node.id !== undefined &&
|
|
|
+ String(selectedId) === String(node.id);
|
|
|
return (
|
|
|
<div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
|
|
|
{/* Node Card */}
|
|
|
<div
|
|
|
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]",
|
|
|
- theme.bg, theme.border,
|
|
|
- selectedId === node.id
|
|
|
- ? "ring-2 ring-indigo-500 ring-offset-1 border-indigo-400"
|
|
|
+ "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)]"
|
|
|
+ : isSourceNode
|
|
|
+ ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
|
|
|
+ : isInSubtreeHighlight
|
|
|
+ ? "border-sky-300 border-dashed bg-white shadow-none"
|
|
|
: isLeafHighlighted
|
|
|
- ? "ring-2 ring-orange-400 ring-offset-1"
|
|
|
+ ? "ring-2 ring-sky-300 ring-offset-1 border-sky-300"
|
|
|
: "hover:brightness-95"
|
|
|
)}
|
|
|
onClick={() => onSelect(node)}
|
|
|
- onDoubleClick={(e) => { e.stopPropagation(); if (!hasChildren) onDoubleClick(node); }}
|
|
|
- ref={(el) => { if (el && selectedId === node.id) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }}
|
|
|
+ ref={(el) => { if (el && isSelected) el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' }); }}
|
|
|
>
|
|
|
<span className={cn("text-xs font-bold mr-3", textColor)}>{node.name || "Root"}</span>
|
|
|
- {node.id && <span className="text-[10px] opacity-60 mr-2 font-mono">{node.id}</span>}
|
|
|
+ {isSourceNode && (
|
|
|
+ <span className="text-[9px] mr-2 px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 border border-sky-300 font-bold whitespace-nowrap shadow-sm">
|
|
|
+ 来源
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
|
|
|
- {/* Count Pill */}
|
|
|
- <div className="flex text-[9px] bg-white/70 rounded px-1 group shadow-sm items-center">
|
|
|
- <span className="px-1 text-slate-500 font-medium">{node.total_element_count || 0}</span>
|
|
|
- <span className="px-1 font-bold text-slate-800 border-l border-white/50 pl-1">{node.total_posts_count || 0} ▶</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.reqCount > 0 && setHoveredMetric({ key: 'req', count: metrics.reqCount, colorClass: 'bg-indigo-500 border-indigo-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-indigo-500" : "opacity-0")}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ onMouseEnter={(e) => metrics.procCount > 0 && setHoveredMetric({ key: 'proc', count: metrics.procCount, colorClass: 'bg-purple-500 border-purple-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-purple-500" : "opacity-0")}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ onMouseEnter={(e) => metrics.capCount > 0 && setHoveredMetric({ key: 'cap', count: metrics.capCount, colorClass: 'bg-rose-500 border-rose-500 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-rose-500" : "opacity-0")}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ onMouseEnter={(e) => metrics.toolCount > 0 && setHoveredMetric({ key: 'tool', count: metrics.toolCount, 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 === 'tool' ? null : prev))}
|
|
|
+ className={cn("w-2.5 h-2.5 rounded-full", metrics.toolCount > 0 ? "bg-green-500" : "opacity-0")}
|
|
|
+ />
|
|
|
</div>
|
|
|
|
|
|
{hasChildren && (
|
|
|
@@ -133,15 +123,26 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
|
|
|
<HorizontalTreeNode
|
|
|
node={child}
|
|
|
onSelect={onSelect}
|
|
|
- onDoubleClick={onDoubleClick}
|
|
|
selectedId={selectedId}
|
|
|
level={level + 1}
|
|
|
highlightLeafNames={highlightLeafNames}
|
|
|
+ subtreeHighlightNodeIds={subtreeHighlightNodeIds}
|
|
|
+ sourceNodeNames={sourceNodeNames}
|
|
|
+ nodeMetricsMap={nodeMetricsMap}
|
|
|
/>
|
|
|
</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>
|
|
|
);
|
|
|
}
|
|
|
@@ -149,18 +150,26 @@ function HorizontalTreeNode({ node, onSelect, onDoubleClick, selectedId, level,
|
|
|
export function CategoryTree({
|
|
|
data,
|
|
|
onSelect,
|
|
|
- onDoubleClick,
|
|
|
selectedId,
|
|
|
highlightLeafNames = null,
|
|
|
+ subtreeHighlightNodeIds = null,
|
|
|
+ sourceNodeNames = null,
|
|
|
+ nodeMetricsMap = {},
|
|
|
+ filterLabel,
|
|
|
+ onClearFilter,
|
|
|
totalNodeCount,
|
|
|
wideMode = false,
|
|
|
onToggleWideMode,
|
|
|
}: {
|
|
|
data: any;
|
|
|
onSelect: (node: any) => void;
|
|
|
- onDoubleClick?: (node: any) => void;
|
|
|
selectedId: any;
|
|
|
highlightLeafNames?: Set<string> | null;
|
|
|
+ subtreeHighlightNodeIds?: Set<string> | null;
|
|
|
+ sourceNodeNames?: Set<string> | null;
|
|
|
+ nodeMetricsMap?: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number }>;
|
|
|
+ filterLabel?: string | null;
|
|
|
+ onClearFilter?: () => void;
|
|
|
totalNodeCount?: number;
|
|
|
wideMode?: boolean;
|
|
|
onToggleWideMode?: () => void;
|
|
|
@@ -183,6 +192,15 @@ export function CategoryTree({
|
|
|
内容树
|
|
|
</div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
+ {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"
|
|
|
+ >
|
|
|
+ {filterLabel} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
<button
|
|
|
onClick={onToggleWideMode}
|
|
|
className={cn(
|
|
|
@@ -242,10 +260,12 @@ export function CategoryTree({
|
|
|
key={subNode.id || subIdx}
|
|
|
node={subNode}
|
|
|
onSelect={onSelect}
|
|
|
- onDoubleClick={onDoubleClick ?? (() => {})}
|
|
|
selectedId={selectedId}
|
|
|
level={1}
|
|
|
highlightLeafNames={highlightLeafNames}
|
|
|
+ subtreeHighlightNodeIds={subtreeHighlightNodeIds}
|
|
|
+ sourceNodeNames={sourceNodeNames}
|
|
|
+ nodeMetricsMap={nodeMetricsMap}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|