|
|
@@ -1,7 +1,7 @@
|
|
|
-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 { 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";
|
|
|
|
|
|
interface NodeProps {
|
|
|
node: any;
|
|
|
@@ -13,7 +13,17 @@ interface NodeProps {
|
|
|
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 }>;
|
|
|
+ nodeMetricsMap: Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ reqCount: number;
|
|
|
+ procCount: number;
|
|
|
+ capCount: number;
|
|
|
+ toolCount: number;
|
|
|
+ nodeCount: number;
|
|
|
+ patternCount: number;
|
|
|
+ }
|
|
|
+ >;
|
|
|
dimensionColor: string; // hex color for the node's dimension
|
|
|
focusedTreeNodeId?: string | number | null;
|
|
|
focusTrigger?: number;
|
|
|
@@ -33,21 +43,50 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
|
|
|
return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
|
|
|
}
|
|
|
|
|
|
-function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0, isOpen, onToggleOpen, directHighlightNodeIds, indirectHighlightNodeIds, hasActiveFilter }: NodeProps) {
|
|
|
+function HorizontalTreeNode({
|
|
|
+ node,
|
|
|
+ onSelect,
|
|
|
+ onOpenDetail,
|
|
|
+ selectedIds,
|
|
|
+ level,
|
|
|
+ highlightLeafNames,
|
|
|
+ subtreeHighlightNodeIds,
|
|
|
+ sourceNodeIds,
|
|
|
+ patternNodeIds,
|
|
|
+ nodeMetricsMap,
|
|
|
+ dimensionColor,
|
|
|
+ focusedTreeNodeId = null,
|
|
|
+ focusTrigger = 0,
|
|
|
+ isOpen,
|
|
|
+ onToggleOpen,
|
|
|
+ directHighlightNodeIds,
|
|
|
+ indirectHighlightNodeIds,
|
|
|
+ hasActiveFilter,
|
|
|
+}: NodeProps) {
|
|
|
const expanded = isOpen(String(node.id));
|
|
|
- const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
|
|
|
+ const [hoveredMetric, setHoveredMetric] = useState<null | {
|
|
|
+ key: string;
|
|
|
+ count: number;
|
|
|
+ colorClass: string;
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ }>(null);
|
|
|
const nodeRef = useRef<HTMLDivElement>(null);
|
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
- const metrics = nodeMetricsMap[String(node.id)] || { reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, nodeCount: 0, patternCount: 0 };
|
|
|
+ const metrics = nodeMetricsMap[String(node.id)] || {
|
|
|
+ reqCount: 0,
|
|
|
+ procCount: 0,
|
|
|
+ capCount: 0,
|
|
|
+ toolCount: 0,
|
|
|
+ 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));
|
|
|
+ const isSelected = node.id !== null && node.id !== undefined && selectedIds.has(String(node.id));
|
|
|
|
|
|
// 4 态分层(叶子 & 非叶一视同仁):非叶节点按 "子树聚合" 判断
|
|
|
// 聚合集由 CategoryTree 根预计算,passed-in sets 已包含"自己或任一后代命中"的 id
|
|
|
@@ -72,14 +111,19 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
prevFocusTriggerRef.current = focusTrigger;
|
|
|
|
|
|
if (isSelected) {
|
|
|
- nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
|
+ nodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
|
|
} else if (focusBumped && shouldScrollIntoView) {
|
|
|
- nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
|
|
+ nodeRef.current.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
|
|
}
|
|
|
}, [isSelected, shouldScrollIntoView, focusTrigger]);
|
|
|
|
|
|
return (
|
|
|
- <div className={cn("flex flex-row items-start transition-opacity duration-200", highlightLeafNames && !inHighlight && "opacity-20")}>
|
|
|
+ <div
|
|
|
+ className={cn(
|
|
|
+ "flex flex-row items-start transition-opacity duration-200",
|
|
|
+ highlightLeafNames && !inHighlight && "opacity-20",
|
|
|
+ )}
|
|
|
+ >
|
|
|
{/* Node Card */}
|
|
|
<div
|
|
|
ref={nodeRef}
|
|
|
@@ -88,13 +132,13 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
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)]"
|
|
|
+ ? // 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"
|
|
|
+ ? // 无关联:置灰但仍可点击
|
|
|
+ "border-slate-200 bg-slate-50 opacity-45 saturate-50"
|
|
|
+ : // indirect 或无筛选:默认态
|
|
|
+ "hover:brightness-95",
|
|
|
)}
|
|
|
onClick={() => onSelect(node)}
|
|
|
>
|
|
|
@@ -104,28 +148,76 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
</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))}
|
|
|
- className={cn("w-2.5 h-2.5 rounded-full", metrics.patternCount > 0 ? "bg-blue-500 opacity-100" : "opacity-0")}
|
|
|
+ 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))}
|
|
|
+ className={cn(
|
|
|
+ "w-2.5 h-2.5 rounded-full",
|
|
|
+ metrics.patternCount > 0 ? "bg-blue-500 opacity-100" : "opacity-0",
|
|
|
+ )}
|
|
|
/>
|
|
|
<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))}
|
|
|
+ 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))}
|
|
|
+ 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))}
|
|
|
+ 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")}
|
|
|
/>
|
|
|
<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))}
|
|
|
+ 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>
|
|
|
@@ -133,7 +225,10 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
{onOpenDetail && (
|
|
|
<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"
|
|
|
- onClick={(e) => { e.stopPropagation(); onOpenDetail(node); }}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onOpenDetail(node);
|
|
|
+ }}
|
|
|
title="查看详情"
|
|
|
>
|
|
|
<FileText size={13} />
|
|
|
@@ -143,9 +238,22 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
{hasChildren && (
|
|
|
<button
|
|
|
className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
|
|
|
- onClick={(e) => { e.stopPropagation(); onToggleOpen(String(node.id)); }}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onToggleOpen(String(node.id));
|
|
|
+ }}
|
|
|
>
|
|
|
- {expanded ? <ChevronDown size={14} className="opacity-70" /> : <ChevronRight size={14} className="opacity-70" />}
|
|
|
+ {expanded ? (
|
|
|
+ <ChevronDown
|
|
|
+ size={14}
|
|
|
+ className="opacity-70"
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <ChevronRight
|
|
|
+ size={14}
|
|
|
+ className="opacity-70"
|
|
|
+ />
|
|
|
+ )}
|
|
|
</button>
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -164,7 +272,7 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
"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"
|
|
|
+ "first:last:after:hidden",
|
|
|
)}
|
|
|
>
|
|
|
<HorizontalTreeNode
|
|
|
@@ -190,15 +298,19 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level,
|
|
|
))}
|
|
|
</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
|
|
|
- )}
|
|
|
+ {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>
|
|
|
);
|
|
|
}
|
|
|
@@ -235,7 +347,17 @@ export function CategoryTree({
|
|
|
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 }>;
|
|
|
+ nodeMetricsMap?: Record<
|
|
|
+ string,
|
|
|
+ {
|
|
|
+ reqCount: number;
|
|
|
+ procCount: number;
|
|
|
+ capCount: number;
|
|
|
+ toolCount: number;
|
|
|
+ nodeCount: number;
|
|
|
+ patternCount: number;
|
|
|
+ }
|
|
|
+ >;
|
|
|
filterLabel?: string | null;
|
|
|
onClearFilter?: () => void;
|
|
|
totalNodeCount?: number;
|
|
|
@@ -257,19 +379,25 @@ export function CategoryTree({
|
|
|
const [openStates, setOpenStates] = useState<Map<string, boolean>>(new Map());
|
|
|
const [autoCollapse, setAutoCollapse] = useState(false);
|
|
|
|
|
|
- const isOpen = useCallback((id: string) => {
|
|
|
- if (openStates.has(id)) return openStates.get(id)!;
|
|
|
- return defaultOpen;
|
|
|
- }, [openStates, defaultOpen]);
|
|
|
+ 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 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],
|
|
|
+ );
|
|
|
|
|
|
// 从根路径找到目标节点的所有祖先 id(不含目标自己)
|
|
|
const findAncestorIds = useMemo(() => {
|
|
|
@@ -301,14 +429,14 @@ export function CategoryTree({
|
|
|
setOpenStates(new Map());
|
|
|
};
|
|
|
const toggleAutoCollapse = () => {
|
|
|
- setAutoCollapse(v => {
|
|
|
+ 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));
|
|
|
+ ancestors.forEach((id) => next.set(id, true));
|
|
|
setOpenStates(next);
|
|
|
}
|
|
|
return nextAuto;
|
|
|
@@ -324,35 +452,42 @@ export function CategoryTree({
|
|
|
if (autoCollapse) {
|
|
|
setDefaultOpen(false);
|
|
|
const next = new Map<string, boolean>();
|
|
|
- ancestors.forEach(id => next.set(id, true));
|
|
|
+ ancestors.forEach((id) => next.set(id, true));
|
|
|
setOpenStates(next);
|
|
|
} else {
|
|
|
// 只在树处于折叠状态时才加路径;全展开状态下是 no-op
|
|
|
- setOpenStates(prev => {
|
|
|
+ setOpenStates((prev) => {
|
|
|
let changed = false;
|
|
|
const next = new Map(prev);
|
|
|
- ancestors.forEach(id => {
|
|
|
+ ancestors.forEach((id) => {
|
|
|
const cur = next.has(id) ? next.get(id)! : defaultOpen;
|
|
|
- if (!cur) { next.set(id, true); changed = true; }
|
|
|
+ if (!cur) {
|
|
|
+ next.set(id, true);
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
});
|
|
|
return changed ? next : prev;
|
|
|
});
|
|
|
}
|
|
|
}, [focusedTreeNodeId, autoCollapse, findAncestorIds]);
|
|
|
|
|
|
- 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>
|
|
|
- );
|
|
|
+ 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-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" />
|
|
|
+ <FolderTree
|
|
|
+ size={14}
|
|
|
+ className="text-slate-500"
|
|
|
+ />
|
|
|
内容树
|
|
|
</div>
|
|
|
<div className="flex items-center gap-2">
|
|
|
@@ -404,7 +539,7 @@ export function CategoryTree({
|
|
|
onClick={toggleAutoCollapse}
|
|
|
className={cn(
|
|
|
"text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
|
|
|
- autoCollapse ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
|
|
|
+ autoCollapse ? "bg-indigo-100 text-[#3b82f6]" : "bg-slate-200 text-slate-500 hover:bg-slate-300",
|
|
|
)}
|
|
|
title="开启后:每次定位到新节点都先全折叠再展开该节点的根路径"
|
|
|
>
|
|
|
@@ -414,10 +549,10 @@ export function CategoryTree({
|
|
|
onClick={onToggleWideMode}
|
|
|
className={cn(
|
|
|
"text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
|
|
|
- wideMode ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
|
|
|
+ wideMode ? "bg-indigo-100 text-[#3b82f6]" : "bg-slate-200 text-slate-500 hover:bg-slate-300",
|
|
|
)}
|
|
|
>
|
|
|
- {wideMode ? '宽模式' : '窄模式'}
|
|
|
+ {wideMode ? "宽模式" : "窄模式"}
|
|
|
</button>
|
|
|
<span className="text-slate-400">{totalNodeCount ?? 0}</span>
|
|
|
</div>
|
|
|
@@ -425,13 +560,25 @@ export function CategoryTree({
|
|
|
|
|
|
{/* 缩放控件 */}
|
|
|
<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">
|
|
|
- <button onClick={() => setScale(s => Math.min(s + 0.15, 3))} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="放大">
|
|
|
+ <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} />
|
|
|
</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="缩小">
|
|
|
+ <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} />
|
|
|
</button>
|
|
|
- <button onClick={() => setScale(1)} className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors" title="重置">
|
|
|
+ <button
|
|
|
+ onClick={() => setScale(1)}
|
|
|
+ className="p-1 hover:bg-slate-100 rounded text-slate-600 transition-colors"
|
|
|
+ title="重置"
|
|
|
+ >
|
|
|
<Maximize size={14} />
|
|
|
</button>
|
|
|
</div>
|
|
|
@@ -443,7 +590,7 @@ export function CategoryTree({
|
|
|
>
|
|
|
{(() => {
|
|
|
const orderKeyWords = ["形式", "实质", "意图"];
|
|
|
- const groups: Record<string, any[]> = { "形式": [], "实质": [], "意图": [] };
|
|
|
+ const groups: Record<string, any[]> = { 形式: [], 实质: [], 意图: [] };
|
|
|
data.children.forEach((node: any) => {
|
|
|
const type = node.source_type;
|
|
|
if (type && groups[type]) groups[type].push(node);
|
|
|
@@ -453,14 +600,27 @@ export function CategoryTree({
|
|
|
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' };
|
|
|
+ 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">
|
|
|
- <div className={cn("px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4", color.bg, color.border, color.text)}>
|
|
|
+ <div
|
|
|
+ key={dimensionName}
|
|
|
+ className="flex flex-col"
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ className={cn(
|
|
|
+ "px-4 py-3 border-l-[6px] text-sm font-bold w-full mb-4",
|
|
|
+ color.bg,
|
|
|
+ color.border,
|
|
|
+ color.text,
|
|
|
+ )}
|
|
|
+ >
|
|
|
{dimensionName} 维度
|
|
|
</div>
|
|
|
<div className="flex flex-col gap-6 pl-4">
|