|
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, useCallback, Fragment, type React
|
|
|
import { createPortal } from 'react-dom';
|
|
import { createPortal } from 'react-dom';
|
|
|
import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
import { CategoryTree } from '../components/dashboard/CategoryTree';
|
|
import { CategoryTree } from '../components/dashboard/CategoryTree';
|
|
|
|
|
+import { VersionSwitcher } from '../components/layout/VersionSwitcher';
|
|
|
import { SideDrawer } from '../components/common/SideDrawer';
|
|
import { SideDrawer } from '../components/common/SideDrawer';
|
|
|
import { cn } from '../lib/utils';
|
|
import { cn } from '../lib/utils';
|
|
|
import { getResource, batchGetPosts } from '../services/api';
|
|
import { getResource, batchGetPosts } from '../services/api';
|
|
@@ -163,21 +164,21 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
|
|
|
|
|
const visibleCrossEdges = flowSelectedDetailNode
|
|
const visibleCrossEdges = flowSelectedDetailNode
|
|
|
? activeCrossEdges
|
|
? activeCrossEdges
|
|
|
- .filter((edge: any) => edge.source === flowSelectedDetailNode || edge.target === flowSelectedDetailNode)
|
|
|
|
|
- .map((edge: any) => {
|
|
|
|
|
- const selectedIsSource = edge.source === flowSelectedDetailNode;
|
|
|
|
|
- const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
|
|
|
|
|
- const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
|
|
|
|
|
- const selectedCenterX = dynamicLayouts.list[selectedIndex]?.cx || 0;
|
|
|
|
|
- const otherCenterX = dynamicLayouts.list[otherIndex]?.cx || 0;
|
|
|
|
|
- const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
|
|
|
|
|
- const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
|
|
|
|
|
- return {
|
|
|
|
|
- ...edge,
|
|
|
|
|
- sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
|
|
|
|
|
- targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
|
|
|
|
|
- };
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ .filter((edge: any) => edge.source === flowSelectedDetailNode || edge.target === flowSelectedDetailNode)
|
|
|
|
|
+ .map((edge: any) => {
|
|
|
|
|
+ const selectedIsSource = edge.source === flowSelectedDetailNode;
|
|
|
|
|
+ const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
|
|
|
|
|
+ const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
|
|
|
|
|
+ const selectedCenterX = dynamicLayouts.list[selectedIndex]?.cx || 0;
|
|
|
|
|
+ const otherCenterX = dynamicLayouts.list[otherIndex]?.cx || 0;
|
|
|
|
|
+ const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
|
|
|
|
|
+ const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...edge,
|
|
|
|
|
+ sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
|
|
|
|
|
+ targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
|
|
|
|
|
+ };
|
|
|
|
|
+ })
|
|
|
: [];
|
|
: [];
|
|
|
|
|
|
|
|
const canvasWidth = dynamicLayouts.totalWidth;
|
|
const canvasWidth = dynamicLayouts.totalWidth;
|
|
@@ -482,7 +483,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
|
|
// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
function PatternColumn({
|
|
function PatternColumn({
|
|
|
- selectedItemsetId,
|
|
|
|
|
|
|
+ selectedItemsetIds,
|
|
|
itemsets,
|
|
itemsets,
|
|
|
eligibleItemsetIds,
|
|
eligibleItemsetIds,
|
|
|
metricsMap,
|
|
metricsMap,
|
|
@@ -499,9 +500,9 @@ function PatternColumn({
|
|
|
onFocusPrev,
|
|
onFocusPrev,
|
|
|
onFocusNext,
|
|
onFocusNext,
|
|
|
}: {
|
|
}: {
|
|
|
- selectedItemsetId: number | null;
|
|
|
|
|
|
|
+ selectedItemsetIds: Set<string>;
|
|
|
itemsets: any[];
|
|
itemsets: any[];
|
|
|
- eligibleItemsetIds: Set<string>;
|
|
|
|
|
|
|
+ eligibleItemsetIds: { direct: Set<string>, indirect: Set<string> } | Set<string>;
|
|
|
metricsMap: Record<string, { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number }>;
|
|
metricsMap: Record<string, { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number }>;
|
|
|
frozenOrder?: string[];
|
|
frozenOrder?: string[];
|
|
|
contextNodeNames: Set<string>;
|
|
contextNodeNames: Set<string>;
|
|
@@ -529,7 +530,7 @@ function PatternColumn({
|
|
|
}
|
|
}
|
|
|
return { ...itemset, matched_nodes: matchedNodes };
|
|
return { ...itemset, matched_nodes: matchedNodes };
|
|
|
});
|
|
});
|
|
|
- if (selectedItemsetId !== null && frozenOrder && frozenOrder.length > 0) {
|
|
|
|
|
|
|
+ if (selectedItemsetIds.size > 0 && frozenOrder && frozenOrder.length > 0) {
|
|
|
const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
|
|
const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
|
|
|
return [...withMatches].sort((a, b) => {
|
|
return [...withMatches].sort((a, b) => {
|
|
|
const aRank = rank.get(String(a.id));
|
|
const aRank = rank.get(String(a.id));
|
|
@@ -540,70 +541,60 @@ function PatternColumn({
|
|
|
return aRank - bRank;
|
|
return aRank - bRank;
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
- return withMatches.sort((a, b) => {
|
|
|
|
|
- const aEligible = eligibleItemsetIds.has(String(a.id)) ? 0 : 1;
|
|
|
|
|
- const bEligible = eligibleItemsetIds.has(String(b.id)) ? 0 : 1;
|
|
|
|
|
- if (aEligible !== bEligible) return aEligible - bEligible;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback sorting: if active filter, bubbling up eligible!
|
|
|
|
|
+ return [...withMatches].sort((a, b) => {
|
|
|
|
|
+ // If sorting normally with filter on, eligible bubbling
|
|
|
|
|
+ if (hasAnyFilter) {
|
|
|
|
|
+ const getRank = (id: string) => {
|
|
|
|
|
+ if (!eligibleItemsetIds) return 0;
|
|
|
|
|
+ if (eligibleItemsetIds instanceof Set) {
|
|
|
|
|
+ return eligibleItemsetIds.has(String(id)) ? 1 : 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (eligibleItemsetIds.direct?.has(String(id))) return 2;
|
|
|
|
|
+ if (eligibleItemsetIds.indirect?.has(String(id))) return 1;
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ };
|
|
|
|
|
+ const aR = getRank(String(a.id));
|
|
|
|
|
+ const bR = getRank(String(b.id));
|
|
|
|
|
+ if (aR !== bR) return bR - aR;
|
|
|
|
|
+ }
|
|
|
return 0;
|
|
return 0;
|
|
|
});
|
|
});
|
|
|
- }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds, selectedItemsetId, frozenOrder]);
|
|
|
|
|
-
|
|
|
|
|
- const matchedIndices = useMemo(() => {
|
|
|
|
|
- const indices: number[] = [];
|
|
|
|
|
- displayItemsets.forEach((itemset, idx) => {
|
|
|
|
|
- const isSelected = selectedItemsetId === itemset.id;
|
|
|
|
|
- const isEligible = eligibleItemsetIds.has(String(itemset.id));
|
|
|
|
|
- if (isSelected || isEligible) indices.push(idx);
|
|
|
|
|
- });
|
|
|
|
|
- return indices;
|
|
|
|
|
- }, [displayItemsets, selectedItemsetId, eligibleItemsetIds]);
|
|
|
|
|
|
|
+ }, [itemsets, contextNodeNames, patternMatchedNodesMap, frozenOrder, selectedItemsetIds, eligibleItemsetIds, hasAnyFilter]);
|
|
|
|
|
+
|
|
|
|
|
+ const matchedIndices: number[] = [];
|
|
|
|
|
+ displayItemsets.forEach((itemset, idx) => {
|
|
|
|
|
+ const isSelected = selectedItemsetIds.has(String(itemset.id));
|
|
|
|
|
+ const isEligible = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? eligibleItemsetIds.has(String(itemset.id)) : (eligibleItemsetIds.direct?.has(String(itemset.id)) || eligibleItemsetIds.indirect?.has(String(itemset.id)));
|
|
|
|
|
+ if (isSelected || isEligible) matchedIndices.push(idx);
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
|
|
const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
|
|
|
const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- if (focusTrigger > 0 && focusedItemIndex >= 0) {
|
|
|
|
|
- itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
|
|
+ if (focusTrigger > 0 && focusedItemIndex >= 0 && itemRefs.current[focusedItemIndex]) {
|
|
|
|
|
+ itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
}
|
|
}
|
|
|
- }, [focusedItemIndex, focusTrigger]);
|
|
|
|
|
|
|
+ }, [focusTrigger, focusedItemIndex]);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div className="w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden border-t-2 border-t-blue-500">
|
|
|
|
|
- <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
|
|
|
|
|
|
|
+ <div className="w-[300px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden">
|
|
|
|
|
+ <div className={cn("px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0", DASHBOARD_COLUMN_THEME.pattern.headerColor)}>
|
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex justify-between items-center">
|
|
|
<span>Pattern</span>
|
|
<span>Pattern</span>
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
|
- {matchedIndices.length > 1 && (
|
|
|
|
|
- <div className="flex items-center gap-1">
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={onFocusPrev}
|
|
|
|
|
- disabled={clampedFocusIdx === 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">
|
|
|
|
|
- {clampedFocusIdx + 1}/{matchedIndices.length}
|
|
|
|
|
- </span>
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={onFocusNext}
|
|
|
|
|
- disabled={clampedFocusIdx === matchedIndices.length - 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>
|
|
|
|
|
- )}
|
|
|
|
|
<span className="text-slate-400">{displayItemsets.length}</span>
|
|
<span className="text-slate-400">{displayItemsets.length}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
<div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
{displayItemsets.map((itemset, idx) => {
|
|
{displayItemsets.map((itemset, idx) => {
|
|
|
- const isSelected = selectedItemsetId === itemset.id;
|
|
|
|
|
- const isEligible = eligibleItemsetIds.has(String(itemset.id));
|
|
|
|
|
|
|
+ const isSelected = selectedItemsetIds.has(String(itemset.id));
|
|
|
|
|
+ const isDirect = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? eligibleItemsetIds.has(String(itemset.id)) : eligibleItemsetIds.direct?.has(String(itemset.id));
|
|
|
|
|
+ const isIndirect = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? false : eligibleItemsetIds.indirect?.has(String(itemset.id));
|
|
|
|
|
+ const isEligible = isDirect || isIndirect;
|
|
|
const metrics = metricsMap[String(itemset.id)] || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
|
|
const metrics = metricsMap[String(itemset.id)] || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
|
|
|
const metricItems = [
|
|
const metricItems = [
|
|
|
{ key: 'node', count: metrics.nodeCount, className: 'bg-slate-500 text-white' },
|
|
{ key: 'node', count: metrics.nodeCount, className: 'bg-slate-500 text-white' },
|
|
@@ -628,6 +619,7 @@ function PatternColumn({
|
|
|
))}
|
|
))}
|
|
|
</>
|
|
</>
|
|
|
);
|
|
);
|
|
|
|
|
+ const isUnrelated = hasAnyFilter && !isEligible && !isSelected;
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
key={itemset.id}
|
|
key={itemset.id}
|
|
@@ -635,31 +627,31 @@ function PatternColumn({
|
|
|
itemRefs.current[idx] = el;
|
|
itemRefs.current[idx] = el;
|
|
|
}}
|
|
}}
|
|
|
onClick={() => {
|
|
onClick={() => {
|
|
|
- if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
|
|
const next = isSelected ? null : itemset.id;
|
|
const next = isSelected ? null : itemset.id;
|
|
|
onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
|
|
onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
|
|
|
- if (next !== null) onOpenDrawer(itemset);
|
|
|
|
|
}}
|
|
}}
|
|
|
className={cn(
|
|
className={cn(
|
|
|
- "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4 border-l-blue-400",
|
|
|
|
|
|
|
+ "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4",
|
|
|
|
|
+ // 仅在非 direct/selected/unrelated 时保留 column identity 蓝色左条
|
|
|
|
|
+ !isSelected && !(hasAnyFilter && isDirect) && !isUnrelated && "border-l-blue-400",
|
|
|
isSelected
|
|
isSelected
|
|
|
- ? "border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
|
|
|
|
|
- : isEligible
|
|
|
|
|
- ? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
|
|
- : "border border-transparent hover:border-slate-200",
|
|
|
|
|
- hasAnyFilter && !isEligible && !isSelected && "border border-slate-200 bg-slate-50 opacity-45 saturate-50 cursor-not-allowed"
|
|
|
|
|
|
|
+ ? "border-l-orange-400 border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
|
|
|
|
|
+ : (hasAnyFilter && isDirect)
|
|
|
|
|
+ ? "border-l-sky-400 border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
|
|
+ : isUnrelated
|
|
|
|
|
+ ? "border-l-slate-200 border border-slate-200 bg-slate-50 opacity-45 saturate-50"
|
|
|
|
|
+ : "border border-transparent hover:border-slate-200"
|
|
|
)}
|
|
)}
|
|
|
>
|
|
>
|
|
|
- <div className="flex items-start gap-2">
|
|
|
|
|
- <div className="min-w-0 flex-1">
|
|
|
|
|
- <div className="flex flex-wrap gap-1 mt-1.5">
|
|
|
|
|
|
|
+ <div className="flex flex-col">
|
|
|
|
|
+ <div className="flex items-start justify-between">
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 flex-1 mt-1.5">
|
|
|
{(itemset.leaf_names || []).map((name: string) => {
|
|
{(itemset.leaf_names || []).map((name: string) => {
|
|
|
const isMatched = itemset.matched_nodes.includes(name);
|
|
const isMatched = itemset.matched_nodes.includes(name);
|
|
|
return (
|
|
return (
|
|
|
<span
|
|
<span
|
|
|
key={name}
|
|
key={name}
|
|
|
onClick={(e) => {
|
|
onClick={(e) => {
|
|
|
- if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
|
|
if (!onNodeClick) return;
|
|
if (!onNodeClick) return;
|
|
|
e.stopPropagation();
|
|
e.stopPropagation();
|
|
|
onNodeClick(name);
|
|
onNodeClick(name);
|
|
@@ -669,7 +661,7 @@ function PatternColumn({
|
|
|
(isSelected || isEligible) && isMatched
|
|
(isSelected || isEligible) && isMatched
|
|
|
? "bg-blue-100 text-blue-700 ring-1 ring-blue-400 border-blue-200 font-bold"
|
|
? "bg-blue-100 text-blue-700 ring-1 ring-blue-400 border-blue-200 font-bold"
|
|
|
: "bg-slate-50 text-slate-700 border-slate-200",
|
|
: "bg-slate-50 text-slate-700 border-slate-200",
|
|
|
- onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-blue-300"
|
|
|
|
|
|
|
+ onNodeClick && "cursor-pointer hover:ring-1 hover:ring-blue-300"
|
|
|
)}
|
|
)}
|
|
|
>
|
|
>
|
|
|
{name}
|
|
{name}
|
|
@@ -677,12 +669,28 @@ function PatternColumn({
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
|
</div>
|
|
</div>
|
|
|
- <div className={cn(
|
|
|
|
|
- "flex items-center gap-1.5 mt-2 transition-opacity duration-150",
|
|
|
|
|
- isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
|
|
|
- )}>
|
|
|
|
|
- {metricDots}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ onOpenDrawer(itemset);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "p-1.5 ml-2 mt-1 rounded-lg shrink-0 transition-all",
|
|
|
|
|
+ isSelected
|
|
|
|
|
+ ? "bg-orange-100 text-orange-600 hover:bg-orange-200"
|
|
|
|
|
+ : "text-slate-400 hover:bg-slate-100 hover:text-slate-600"
|
|
|
|
|
+ )}
|
|
|
|
|
+ title="查看详情"
|
|
|
|
|
+ >
|
|
|
|
|
+ <FileText size={15} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className={cn(
|
|
|
|
|
+ "flex items-center gap-1.5 mt-2 transition-opacity duration-150",
|
|
|
|
|
+ isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
|
|
|
+ )}>
|
|
|
|
|
+ {metricDots}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -709,19 +717,39 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
|
|
const allCaps = useMemo(() => [...dbData.caps, ...virtualCaps], [dbData.caps, virtualCaps]);
|
|
const allCaps = useMemo(() => [...dbData.caps, ...virtualCaps], [dbData.caps, virtualCaps]);
|
|
|
|
|
|
|
|
- const [selectedNode, setSelectedNode] = useState<any>(null);
|
|
|
|
|
- const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
|
|
|
|
|
- const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
|
|
|
|
|
- const [selectedCapId, setSelectedCapId] = useState<string | null>(null);
|
|
|
|
|
- const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
|
|
|
|
|
- const [selectedItemsetId, setSelectedItemsetId] = useState<number | null>(null);
|
|
|
|
|
|
|
+
|
|
|
|
|
|
|
|
// 关系列UI状态(原有的 focus/drawer 等)
|
|
// 关系列UI状态(原有的 focus/drawer 等)
|
|
|
const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
|
|
const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
|
|
|
- const [treeWideMode, setTreeWideMode] = useState(false);
|
|
|
|
|
|
|
+ // 内容树宽度:可拖拽调节;"宽/窄" 按钮在 420 / 900 之间切换,drag 用于精确微调
|
|
|
|
|
+ const TREE_WIDTH_MIN = 280;
|
|
|
|
|
+ const TREE_WIDTH_MAX = 1600;
|
|
|
|
|
+ const TREE_WIDTH_PRESET_NARROW = 420;
|
|
|
|
|
+ const TREE_WIDTH_PRESET_WIDE = 900;
|
|
|
|
|
+ const [treeWidth, setTreeWidth] = useState(TREE_WIDTH_PRESET_NARROW);
|
|
|
|
|
+ const treeWideMode = treeWidth >= (TREE_WIDTH_PRESET_NARROW + TREE_WIDTH_PRESET_WIDE) / 2;
|
|
|
|
|
+ const beginTreeDrag = (e: React.MouseEvent) => {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const startX = e.clientX;
|
|
|
|
|
+ const startWidth = treeWidth;
|
|
|
|
|
+ const onMove = (ev: MouseEvent) => {
|
|
|
|
|
+ const next = Math.max(TREE_WIDTH_MIN, Math.min(TREE_WIDTH_MAX, startWidth + (ev.clientX - startX)));
|
|
|
|
|
+ setTreeWidth(next);
|
|
|
|
|
+ };
|
|
|
|
|
+ const onUp = () => {
|
|
|
|
|
+ window.removeEventListener('mousemove', onMove);
|
|
|
|
|
+ window.removeEventListener('mouseup', onUp);
|
|
|
|
|
+ document.body.style.cursor = '';
|
|
|
|
|
+ document.body.style.userSelect = '';
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener('mousemove', onMove);
|
|
|
|
|
+ window.addEventListener('mouseup', onUp);
|
|
|
|
|
+ document.body.style.cursor = 'col-resize';
|
|
|
|
|
+ document.body.style.userSelect = 'none';
|
|
|
|
|
+ };
|
|
|
const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
|
|
const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
|
|
|
const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false);
|
|
const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false);
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
|
|
const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
|
|
|
const [columnFocusTrigger, setColumnFocusTrigger] = useState<Record<string, number>>({});
|
|
const [columnFocusTrigger, setColumnFocusTrigger] = useState<Record<string, number>>({});
|
|
|
const [columnFrozenOrders, setColumnFrozenOrders] = useState<Record<string, string[]>>({});
|
|
const [columnFrozenOrders, setColumnFrozenOrders] = useState<Record<string, string[]>>({});
|
|
@@ -733,7 +761,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
// 来自其他页面的跳转
|
|
// 来自其他页面的跳转
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (pendingNode && nameToNodeMap[pendingNode]) {
|
|
if (pendingNode && nameToNodeMap[pendingNode]) {
|
|
|
- setSelectedNode(nameToNodeMap[pendingNode]);
|
|
|
|
|
|
|
+ dashFilter.setSingleSelection('tree', String(nameToNodeMap[pendingNode].id));
|
|
|
setDrawerItem({ type: 'node', data: nameToNodeMap[pendingNode] });
|
|
setDrawerItem({ type: 'node', data: nameToNodeMap[pendingNode] });
|
|
|
onPendingConsumed?.();
|
|
onPendingConsumed?.();
|
|
|
}
|
|
}
|
|
@@ -925,34 +953,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
|
|
const dashFilter = useDashboardFilter(graph);
|
|
const dashFilter = useDashboardFilter(graph);
|
|
|
|
|
|
|
|
- // Tree selection injection into graph state
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- if (selectedNode) {
|
|
|
|
|
- if (!dashFilter.selections.tree.has(selectedNode.name)) {
|
|
|
|
|
- dashFilter.toggleSelection('tree', selectedNode.name);
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- dashFilter.selections.tree.forEach(name => {
|
|
|
|
|
- dashFilter.toggleSelection('tree', name);
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }, [selectedNode]);
|
|
|
|
|
|
|
|
|
|
- // Helper bindings for the legacy UI
|
|
|
|
|
- const getCardState = useCallback((type: 'req' | 'proc' | 'cap' | 'tool' | 'pattern', id: string) => {
|
|
|
|
|
- return dashFilter.getItemState(type, id);
|
|
|
|
|
- }, [dashFilter]);
|
|
|
|
|
-
|
|
|
|
|
- const handleEntitySelection = useCallback((type: 'req' | 'proc' | 'cap' | 'tool' | 'pattern', id: string | null) => {
|
|
|
|
|
- // Current UI supports multiselect via modifier keys theoretically, but here let's preserve the toggle capability.
|
|
|
|
|
- // Assuming single select click without modifier toggles just one:
|
|
|
|
|
- if (id !== null) {
|
|
|
|
|
- dashFilter.toggleSelection(type, id);
|
|
|
|
|
- } else {
|
|
|
|
|
- // Meaning "clear selection for this col"
|
|
|
|
|
- dashFilter.selections[type].forEach(existingId => dashFilter.toggleSelection(type, existingId));
|
|
|
|
|
- }
|
|
|
|
|
- }, [dashFilter]);
|
|
|
|
|
|
|
|
|
|
// Replacement values for what was calculated in loops:
|
|
// Replacement values for what was calculated in loops:
|
|
|
const hasAnyFilter = dashFilter.hasActiveFilters;
|
|
const hasAnyFilter = dashFilter.hasActiveFilters;
|
|
@@ -973,34 +974,43 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return ids;
|
|
return ids;
|
|
|
}, [dashFilter.filterResults]);
|
|
}, [dashFilter.filterResults]);
|
|
|
|
|
|
|
|
- const relatedItemsetIds = useMemo(() => {
|
|
|
|
|
- const ids = new Set<string>();
|
|
|
|
|
- Array.from(dashFilter.filterResults?.matched || []).forEach(ref => {
|
|
|
|
|
- if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
|
|
|
|
|
- ids.add(ref.split(':::')[1]);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
- return ids;
|
|
|
|
|
- }, [dashFilter.filterResults]);
|
|
|
|
|
-
|
|
|
|
|
- const selectedSubtreeLeafNames = undefined;
|
|
|
|
|
- const selectedSubtreeNodeIds = undefined;
|
|
|
|
|
- const relationFilterHighlightLeafNames = undefined;
|
|
|
|
|
- // All graph relationships are now dynamically resolved by `dashFilter` engine.
|
|
|
|
|
-
|
|
|
|
|
// The filtered arrays:
|
|
// The filtered arrays:
|
|
|
- const filteredData = {
|
|
|
|
|
|
|
+ const filteredData = useMemo(() => ({
|
|
|
reqs: dbData.reqs.filter((r: any) => dashFilter.getItemState('req', r.id) !== 'dimmed'),
|
|
reqs: dbData.reqs.filter((r: any) => dashFilter.getItemState('req', r.id) !== 'dimmed'),
|
|
|
procs: dbData.procs.filter(p => dashFilter.getItemState('proc', p.id) !== 'dimmed'),
|
|
procs: dbData.procs.filter(p => dashFilter.getItemState('proc', p.id) !== 'dimmed'),
|
|
|
caps: allCaps.filter((c: any) => dashFilter.getItemState('cap', c.id) !== 'dimmed'),
|
|
caps: allCaps.filter((c: any) => dashFilter.getItemState('cap', c.id) !== 'dimmed'),
|
|
|
tools: dbData.tools.filter((t: any) => dashFilter.getItemState('tool', t.id) !== 'dimmed'),
|
|
tools: dbData.tools.filter((t: any) => dashFilter.getItemState('tool', t.id) !== 'dimmed'),
|
|
|
- };
|
|
|
|
|
|
|
+ }), [dbData, allCaps, dashFilter]);
|
|
|
|
|
|
|
|
const eligibleReqIds = useMemo(() => new Set(filteredData.reqs.map((r: any) => r.id)), [filteredData.reqs]);
|
|
const eligibleReqIds = useMemo(() => new Set(filteredData.reqs.map((r: any) => r.id)), [filteredData.reqs]);
|
|
|
const eligibleProcIds = useMemo(() => new Set(filteredData.procs.map((workflow) => workflow.id)), [filteredData.procs]);
|
|
const eligibleProcIds = useMemo(() => new Set(filteredData.procs.map((workflow) => workflow.id)), [filteredData.procs]);
|
|
|
const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
|
|
const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
|
|
|
const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
|
|
const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const relatedItemsetIds = useMemo(() => {
|
|
|
|
|
+ const directIds = new Set<string>();
|
|
|
|
|
+ const indirectIds = new Set<string>();
|
|
|
|
|
+
|
|
|
|
|
+ // Pattern 列的直接/间接完全由 graph engine 决定,与其他列保持一致
|
|
|
|
|
+ // 无筛选时 matched 是 Set(['ALL']),此处不会 startsWith('pattern:::'),结果为空——Pattern 卡显示默认样式
|
|
|
|
|
+ Array.from(dashFilter.filterResults?.directMatched || []).forEach(ref => {
|
|
|
|
|
+ if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
|
|
|
|
|
+ directIds.add(ref.split(':::')[1]);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ Array.from(dashFilter.filterResults?.indirectMatched || []).forEach(ref => {
|
|
|
|
|
+ if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
|
|
|
|
|
+ indirectIds.add(ref.split(':::')[1]);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { direct: directIds, indirect: indirectIds };
|
|
|
|
|
+ }, [dashFilter.filterResults]);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedSubtreeLeafNames = undefined;
|
|
|
|
|
+ const selectedSubtreeNodeIds = undefined;
|
|
|
|
|
+ const relationFilterHighlightLeafNames = undefined;
|
|
|
|
|
+ // All graph relationships are now dynamically resolved by `dashFilter` engine.
|
|
|
const reqRelationTagsMap = useMemo((): Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> => {
|
|
const reqRelationTagsMap = useMemo((): Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> => {
|
|
|
return {}; // Legacy relation tags (Pattern/Direct) are simplified right now
|
|
return {}; // Legacy relation tags (Pattern/Direct) are simplified right now
|
|
|
}, []);
|
|
}, []);
|
|
@@ -1009,7 +1019,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0, pattern: 0 });
|
|
setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0, pattern: 0 });
|
|
|
setTreeFocusIndex(0);
|
|
setTreeFocusIndex(0);
|
|
|
- }, [selectedNode]);
|
|
|
|
|
|
|
+ }, [dashFilter.selections.tree]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
setTreeFocusIndex(0);
|
|
setTreeFocusIndex(0);
|
|
@@ -1327,13 +1337,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
};
|
|
};
|
|
|
}, [treeData, dbData.reqs, dbData.tools, totalNodeCount, visibleNodeCount, filteredData.reqs.length, filteredData.caps.length, filteredData.tools.length, filteredData.procs.length, allCaps, collectDirectNodesFromReqs]);
|
|
}, [treeData, dbData.reqs, dbData.tools, totalNodeCount, visibleNodeCount, filteredData.reqs.length, filteredData.caps.length, filteredData.tools.length, filteredData.procs.length, allCaps, collectDirectNodesFromReqs]);
|
|
|
|
|
|
|
|
- const getColumnActiveId = (type: string): string | null => (
|
|
|
|
|
- type === 'req' ? (selectedReqId ? `req:${selectedReqId}` : null) :
|
|
|
|
|
- type === 'proc' ? (selectedProcId ? `proc:${selectedProcId}` : null) :
|
|
|
|
|
- type === 'cap' ? (selectedCapId ? `cap:${selectedCapId}` : null) :
|
|
|
|
|
- type === 'tool' ? (selectedToolId ? `tool:${selectedToolId}` : null) :
|
|
|
|
|
- null
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const getColumnActiveId = (type: string): string | null => null;
|
|
|
|
|
|
|
|
|
|
|
|
|
const sortedItems = (items: any[], eligibleIds?: Set<string>, frozenOrder?: string[]) => {
|
|
const sortedItems = (items: any[], eligibleIds?: Set<string>, frozenOrder?: string[]) => {
|
|
@@ -1360,7 +1364,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const jumpToTreeNodeByName = useCallback((name: string) => {
|
|
const jumpToTreeNodeByName = useCallback((name: string) => {
|
|
|
if (nameToNodeMap && nameToNodeMap[name]) {
|
|
if (nameToNodeMap && nameToNodeMap[name]) {
|
|
|
const node = nameToNodeMap[name];
|
|
const node = nameToNodeMap[name];
|
|
|
- setSelectedNode(node);
|
|
|
|
|
|
|
+ dashFilter.setSingleSelection('tree', String(node.id));
|
|
|
setManualFocusedTreeNodeId(node.id);
|
|
setManualFocusedTreeNodeId(node.id);
|
|
|
setTreeFocusTrigger(prev => prev + 1);
|
|
setTreeFocusTrigger(prev => prev + 1);
|
|
|
}
|
|
}
|
|
@@ -1368,28 +1372,18 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
|
|
const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
|
|
const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
|
|
|
const [type, id] = nodeId.split(':');
|
|
const [type, id] = nodeId.split(':');
|
|
|
- const isSameReq = type === 'req' && selectedReqId === id;
|
|
|
|
|
- const isSameProc = type === 'proc' && selectedProcId === id;
|
|
|
|
|
- const isSameCap = type === 'cap' && selectedCapId === id;
|
|
|
|
|
- const isSameTool = type === 'tool' && selectedToolId === id;
|
|
|
|
|
|
|
|
|
|
setManualFocusedTreeNodeId(null);
|
|
setManualFocusedTreeNodeId(null);
|
|
|
setTreeFocusTrigger(prev => prev + 1);
|
|
setTreeFocusTrigger(prev => prev + 1);
|
|
|
|
|
|
|
|
- // Call the actual dashboard filter engine
|
|
|
|
|
|
|
+ // Call the dashboard filter engine to toggle selection natively
|
|
|
dashFilter.toggleSelection(type as any, id);
|
|
dashFilter.toggleSelection(type as any, id);
|
|
|
|
|
|
|
|
- // Keep cosmetic states updated for legacy components
|
|
|
|
|
- if (type === 'proc') setSelectedProcId(isSameProc ? null : id);
|
|
|
|
|
- if (type === 'req') setSelectedReqId(isSameReq ? null : id);
|
|
|
|
|
- if (type === 'cap') setSelectedCapId(isSameCap ? null : id);
|
|
|
|
|
- if (type === 'tool') setSelectedToolId(isSameTool ? null : id);
|
|
|
|
|
-
|
|
|
|
|
// Update cosmetic frozen orders
|
|
// Update cosmetic frozen orders
|
|
|
setColumnFrozenOrders(prev => {
|
|
setColumnFrozenOrders(prev => {
|
|
|
const next = { ...prev };
|
|
const next = { ...prev };
|
|
|
- const isSame = (type === 'req' && isSameReq) || (type === 'proc' && isSameProc) || (type === 'cap' && isSameCap) || (type === 'tool' && isSameTool);
|
|
|
|
|
- if (isSame) delete next[type];
|
|
|
|
|
|
|
+ const isSelected = dashFilter.selections[type as any].has(id);
|
|
|
|
|
+ if (!isSelected) delete next[type];
|
|
|
else next[type] = currentOrderIds;
|
|
else next[type] = currentOrderIds;
|
|
|
return next;
|
|
return next;
|
|
|
});
|
|
});
|
|
@@ -1405,49 +1399,35 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const fallbackInputPosts: string[] = [];
|
|
const fallbackInputPosts: string[] = [];
|
|
|
|
|
|
|
|
(item.source_nodes || []).forEach((sn: any) => {
|
|
(item.source_nodes || []).forEach((sn: any) => {
|
|
|
- const sourceNodeRef = extractSourceNodeRef(sn);
|
|
|
|
|
- if (!sourceNodeRef || sourceNodeRef === '__abstract__' || sourceNodeRef === '__meta__') return;
|
|
|
|
|
- const nodes = resolveNodeRefToNodes(sourceNodeRef, true);
|
|
|
|
|
- if (nodes.length === 0) return;
|
|
|
|
|
|
|
+ if (!sn) return;
|
|
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name || sn.node) : String(sn);
|
|
|
|
|
+ if (!nodeName || nodeName === '__abstract__' || nodeName === '__meta__') return;
|
|
|
|
|
|
|
|
const explicitPosts = typeof sn === 'object'
|
|
const explicitPosts = typeof sn === 'object'
|
|
|
? [
|
|
? [
|
|
|
- ...(sn.posts || []),
|
|
|
|
|
- ...(sn.post_ids || []),
|
|
|
|
|
- ...(sn.source_posts || []),
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ ...(sn.posts || []),
|
|
|
|
|
+ ...(sn.post_ids || []),
|
|
|
|
|
+ ...(sn.source_posts || []),
|
|
|
|
|
+ ]
|
|
|
: [];
|
|
: [];
|
|
|
|
|
|
|
|
const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
|
|
const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
|
|
|
.map((entry: any) => typeof entry === 'string' ? entry : entry?.post_id)
|
|
.map((entry: any) => typeof entry === 'string' ? entry : entry?.post_id)
|
|
|
.filter(Boolean);
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
- candidatePosts.forEach((pid: string) => {
|
|
|
|
|
- if (pid && !fallbackInputPosts.includes(pid)) fallbackInputPosts.push(pid);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- nodes.forEach((node: any, nodeIdx: number) => {
|
|
|
|
|
- const nodePostIds: string[] = [];
|
|
|
|
|
- (node?.elements || []).forEach((el: any) => {
|
|
|
|
|
- (el.post_ids || []).forEach((pid: string) => {
|
|
|
|
|
- if (pid && !nodePostIds.includes(pid)) nodePostIds.push(pid);
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
- const nodePostIdSet = new Set(nodePostIds);
|
|
|
|
|
-
|
|
|
|
|
- const postIds: string[] = [];
|
|
|
|
|
|
|
+ if (candidatePosts.length > 0) {
|
|
|
|
|
+ if (!nodePostsMap[nodeName]) {
|
|
|
|
|
+ nodePostsMap[nodeName] = [];
|
|
|
|
|
+ }
|
|
|
candidatePosts.forEach((pid: string) => {
|
|
candidatePosts.forEach((pid: string) => {
|
|
|
- if (nodePostIdSet.has(pid) && !postIds.includes(pid)) postIds.push(pid);
|
|
|
|
|
|
|
+ if (pid && !nodePostsMap[nodeName].includes(pid)) {
|
|
|
|
|
+ nodePostsMap[nodeName].push(pid);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pid && !fallbackInputPosts.includes(pid)) {
|
|
|
|
|
+ fallbackInputPosts.push(pid);
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
- if (postIds.length > 0) {
|
|
|
|
|
- const displayKeyBase = node.name || sourceNodeRef;
|
|
|
|
|
- const displayKey = nodeIdx === 0 && !nodePostsMap[displayKeyBase]
|
|
|
|
|
- ? displayKeyBase
|
|
|
|
|
- : `${displayKeyBase} #${node.id}`;
|
|
|
|
|
- nodePostsMap[displayKey] = postIds;
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
|
|
const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
|
|
@@ -1470,10 +1450,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const displayedTools = dbData.tools;
|
|
const displayedTools = dbData.tools;
|
|
|
|
|
|
|
|
const columns = [
|
|
const columns = [
|
|
|
- { t: 'req', l: '业务需求', i: Target, d: displayedReqs, eligibleIds: eligibleReqIds, headerColor: DASHBOARD_COLUMN_THEME.req.headerBorder },
|
|
|
|
|
|
|
+ { t: 'req', l: '业务需求', i: Target, d: displayedReqs, eligibleIds: eligibleReqIds, headerColor: DASHBOARD_COLUMN_THEME.req.headerBorder },
|
|
|
{ t: 'proc', l: '生产工序', i: ListTree, d: displayedProcs as any[], eligibleIds: eligibleProcIds, headerColor: DASHBOARD_COLUMN_THEME.proc.headerBorder },
|
|
{ t: 'proc', l: '生产工序', i: ListTree, d: displayedProcs as any[], eligibleIds: eligibleProcIds, headerColor: DASHBOARD_COLUMN_THEME.proc.headerBorder },
|
|
|
- { t: 'cap', l: '原子能力', i: Cpu, d: displayedCaps, eligibleIds: eligibleCapIds, headerColor: DASHBOARD_COLUMN_THEME.cap.headerBorder },
|
|
|
|
|
- { t: 'tool', l: '执行工具', i: Wrench, d: displayedTools, eligibleIds: eligibleToolIds, headerColor: DASHBOARD_COLUMN_THEME.tool.headerBorder },
|
|
|
|
|
|
|
+ { t: 'cap', l: '原子能力', i: Cpu, d: displayedCaps, eligibleIds: eligibleCapIds, headerColor: DASHBOARD_COLUMN_THEME.cap.headerBorder },
|
|
|
|
|
+ { t: 'tool', l: '执行工具', i: Wrench, d: displayedTools, eligibleIds: eligibleToolIds, headerColor: DASHBOARD_COLUMN_THEME.tool.headerBorder },
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
const countPatternsByReqIds = useCallback((reqIds: Iterable<string>) => 0, []);
|
|
const countPatternsByReqIds = useCallback((reqIds: Iterable<string>) => 0, []);
|
|
@@ -1489,12 +1469,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
|
|
<div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
|
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="flex flex-col items-center gap-4">
|
|
|
<div className="w-8 h-8 flex items-center justify-center">
|
|
<div className="w-8 h-8 flex items-center justify-center">
|
|
|
- {!dashboardLoadingText.includes('失败') && (
|
|
|
|
|
- <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
|
|
- )}
|
|
|
|
|
- {dashboardLoadingText.includes('失败') && (
|
|
|
|
|
- <span className="text-xl">❌</span>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {!dashboardLoadingText.includes('失败') && (
|
|
|
|
|
+ <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {dashboardLoadingText.includes('失败') && (
|
|
|
|
|
+ <span className="text-xl">❌</span>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
<span className={cn("text-sm tracking-widest", dashboardLoadingText.includes('失败') ? "text-rose-500 max-w-xl text-center leading-relaxed" : "text-slate-400 font-bold")}>{dashboardLoadingText}</span>
|
|
<span className={cn("text-sm tracking-widest", dashboardLoadingText.includes('失败') ? "text-rose-500 max-w-xl text-center leading-relaxed" : "text-slate-400 font-bold")}>{dashboardLoadingText}</span>
|
|
|
</div>
|
|
</div>
|
|
@@ -1514,6 +1494,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
|
|
{/* 工具栏 */}
|
|
{/* 工具栏 */}
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
+ <VersionSwitcher />
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
onClick={() => setFlowBoardExpanded(v => !v)}
|
|
onClick={() => setFlowBoardExpanded(v => !v)}
|
|
@@ -1521,37 +1502,52 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
>
|
|
>
|
|
|
{flowBoardExpanded ? '收起流程图' : '展开流程图'}
|
|
{flowBoardExpanded ? '收起流程图' : '展开流程图'}
|
|
|
</button>
|
|
</button>
|
|
|
- <button
|
|
|
|
|
- onClick={() => setOnlyCoveredFilter(v => !v)}
|
|
|
|
|
- className={cn(
|
|
|
|
|
- "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
|
|
|
|
|
- onlyCoveredFilter
|
|
|
|
|
- ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
|
|
|
|
|
- : "bg-slate-200 text-slate-500 hover:bg-slate-300"
|
|
|
|
|
- )}
|
|
|
|
|
- >
|
|
|
|
|
- <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
|
|
|
|
|
- 只看覆盖需求的数据
|
|
|
|
|
- </button>
|
|
|
|
|
-
|
|
|
|
|
- {selectedItemsetId !== null && (
|
|
|
|
|
|
|
+ {/* 暂时隐藏:"只看覆盖需求的数据" —— 筛选逻辑统一由 graph engine 接管 */}
|
|
|
|
|
+ {false && (
|
|
|
<button
|
|
<button
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => setSelectedItemsetId(null)}
|
|
|
|
|
- className={cn("text-xs font-bold px-3 py-1.5 rounded-lg transition-colors", DASHBOARD_COLUMN_THEME.pattern.chip)}
|
|
|
|
|
|
|
+ onClick={() => setOnlyCoveredFilter(v => !v)}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
|
|
|
|
|
+ onlyCoveredFilter
|
|
|
|
|
+ ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
|
|
|
|
|
+ : "bg-slate-200 text-slate-500 hover:bg-slate-300"
|
|
|
|
|
+ )}
|
|
|
>
|
|
>
|
|
|
- Pattern #{selectedItemsetId} ×
|
|
|
|
|
|
|
+ <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
|
|
|
|
|
+ 只看覆盖需求的数据
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {([
|
|
|
|
|
+ { type: 'tree', label: '树节点', theme: 'bg-slate-700 text-white hover:bg-slate-600' },
|
|
|
|
|
+ { type: 'req', label: '业务需求', theme: DASHBOARD_COLUMN_THEME.req.chip },
|
|
|
|
|
+ { type: 'proc', label: '工序', theme: DASHBOARD_COLUMN_THEME.proc.chip },
|
|
|
|
|
+ { type: 'cap', label: '能力', theme: DASHBOARD_COLUMN_THEME.cap.chip },
|
|
|
|
|
+ { type: 'tool', label: '工具', theme: DASHBOARD_COLUMN_THEME.tool.chip },
|
|
|
|
|
+ { type: 'pattern', label: '模式', theme: DASHBOARD_COLUMN_THEME.pattern.chip },
|
|
|
|
|
+ ] as const).map(({ type, label, theme }) => {
|
|
|
|
|
+ const size = dashFilter.selections[type].size;
|
|
|
|
|
+ if (size === 0) return null;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={`chip-${type}`}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ dashFilter.selections[type].forEach(id => dashFilter.toggleSelection(type, id));
|
|
|
|
|
+ setColumnFrozenOrders(prev => { const n = { ...prev }; delete n[type]; return n; });
|
|
|
|
|
+ if (type === 'tree') setManualFocusedTreeNodeId(null);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={cn("text-xs font-bold px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap", theme)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 已选 {size} 项 {label} ×
|
|
|
|
|
+ </button>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+
|
|
|
{hasAnyFilter && (
|
|
{hasAnyFilter && (
|
|
|
<button
|
|
<button
|
|
|
onClick={() => {
|
|
onClick={() => {
|
|
|
- setSelectedNode(null);
|
|
|
|
|
- setSelectedProcId(null);
|
|
|
|
|
- setSelectedReqId(null);
|
|
|
|
|
- setSelectedCapId(null);
|
|
|
|
|
- setSelectedToolId(null);
|
|
|
|
|
- setSelectedItemsetId(null);
|
|
|
|
|
|
|
+ dashFilter.clearAll();
|
|
|
setManualFocusedTreeNodeId(null);
|
|
setManualFocusedTreeNodeId(null);
|
|
|
setColumnFrozenOrders({});
|
|
setColumnFrozenOrders({});
|
|
|
}}
|
|
}}
|
|
@@ -1560,6 +1556,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<X size={12} /> 清除筛选
|
|
<X size={12} /> 清除筛选
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 右对齐:复选说明 + 三态图例 */}
|
|
|
|
|
+ <div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
|
|
|
|
|
+ <span className="text-slate-400">支持同列复选取交集</span>
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <span className="inline-block w-4 h-3 rounded border-l-4 border-l-sky-400 border border-sky-300 bg-sky-50" />
|
|
|
|
|
+ <span>直接关联</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <span className="inline-block w-4 h-3 rounded border-l-4 border-l-slate-300 border border-slate-200 bg-white" />
|
|
|
|
|
+ <span>间接关联</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <span className="inline-block w-4 h-3 rounded border-l-4 border-l-slate-200 border border-slate-200 bg-slate-50 opacity-45 saturate-50" />
|
|
|
|
|
+ <span>无关联(仍可点)</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* 覆盖率统计 */}
|
|
{/* 覆盖率统计 */}
|
|
@@ -1580,41 +1593,38 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
)}
|
|
)}
|
|
|
>
|
|
>
|
|
|
<div className="flex flex-row gap-4 h-full min-w-max">
|
|
<div className="flex flex-row gap-4 h-full min-w-max">
|
|
|
- {/* 内容树 */}
|
|
|
|
|
- <div className={cn("shrink-0 min-h-0", treeWideMode ? "w-[900px]" : "w-[420px]")}>
|
|
|
|
|
|
|
+ {/* 内容树 —— 宽度可拖拽 */}
|
|
|
|
|
+ <div className="shrink-0 min-h-0 relative" style={{ width: treeWidth }}>
|
|
|
<CategoryTree
|
|
<CategoryTree
|
|
|
data={treeData}
|
|
data={treeData}
|
|
|
onSelect={(node) => {
|
|
onSelect={(node) => {
|
|
|
setManualFocusedTreeNodeId(null);
|
|
setManualFocusedTreeNodeId(null);
|
|
|
- const isSameNode = selectedNode && String(selectedNode.id) === String(node.id);
|
|
|
|
|
- if (isSameNode) {
|
|
|
|
|
- setSelectedNode(null);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- setSelectedNode(node);
|
|
|
|
|
|
|
+ if (node) dashFilter.toggleSelection('tree', String(node.id));
|
|
|
}}
|
|
}}
|
|
|
onOpenDetail={(node) => {
|
|
onOpenDetail={(node) => {
|
|
|
setDrawerItem({ type: 'node', data: node });
|
|
setDrawerItem({ type: 'node', data: node });
|
|
|
}}
|
|
}}
|
|
|
- selectedId={selectedNode ? selectedNode.id : null}
|
|
|
|
|
|
|
+ selectedIds={dashFilter.selections.tree}
|
|
|
subtreeHighlightNodeIds={treeNavigableNodeIds}
|
|
subtreeHighlightNodeIds={treeNavigableNodeIds}
|
|
|
highlightLeafNames={
|
|
highlightLeafNames={
|
|
|
selectedSubtreeLeafNames
|
|
selectedSubtreeLeafNames
|
|
|
? selectedSubtreeLeafNames
|
|
? selectedSubtreeLeafNames
|
|
|
: relationFilterHighlightLeafNames
|
|
: relationFilterHighlightLeafNames
|
|
|
- ? relationFilterHighlightLeafNames
|
|
|
|
|
- : onlyCoveredFilter
|
|
|
|
|
- ? coveredLeafNames
|
|
|
|
|
- : null
|
|
|
|
|
|
|
+ ? relationFilterHighlightLeafNames
|
|
|
|
|
+ : onlyCoveredFilter
|
|
|
|
|
+ ? coveredLeafNames
|
|
|
|
|
+ : null
|
|
|
}
|
|
}
|
|
|
- filterLabel={selectedNode ? selectedNode.name : null}
|
|
|
|
|
- onClearFilter={selectedNode ? () => setSelectedNode(null) : undefined}
|
|
|
|
|
|
|
+ filterLabel={dashFilter.selections.tree.size > 0 ? Array.from(dashFilter.selections.tree).map(id => idToNodeMap?.[id]?.name || id).join(', ') : null}
|
|
|
|
|
+ onClearFilter={dashFilter.selections.tree.size > 0 ? () => {
|
|
|
|
|
+ dashFilter.selections.tree.forEach(name => dashFilter.toggleSelection('tree', name));
|
|
|
|
|
+ } : undefined}
|
|
|
sourceNodeIds={undefined}
|
|
sourceNodeIds={undefined}
|
|
|
patternNodeIds={undefined}
|
|
patternNodeIds={undefined}
|
|
|
nodeMetricsMap={{}}
|
|
nodeMetricsMap={{}}
|
|
|
totalNodeCount={totalNodeCount}
|
|
totalNodeCount={totalNodeCount}
|
|
|
wideMode={treeWideMode}
|
|
wideMode={treeWideMode}
|
|
|
- onToggleWideMode={() => setTreeWideMode(m => !m)}
|
|
|
|
|
|
|
+ onToggleWideMode={() => setTreeWidth(treeWideMode ? TREE_WIDTH_PRESET_NARROW : TREE_WIDTH_PRESET_WIDE)}
|
|
|
treeFocusIndex={treeFocusIndex}
|
|
treeFocusIndex={treeFocusIndex}
|
|
|
treeMatchedCount={treeMatchedCount}
|
|
treeMatchedCount={treeMatchedCount}
|
|
|
focusedTreeNodeId={manualFocusedTreeNodeId ?? focusedTreeNode?.id}
|
|
focusedTreeNodeId={manualFocusedTreeNodeId ?? focusedTreeNode?.id}
|
|
@@ -1622,11 +1632,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
|
|
onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
|
|
|
onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
|
|
onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
|
|
|
/>
|
|
/>
|
|
|
|
|
+ {/* 拖拽调节宽度的把手 */}
|
|
|
|
|
+ <div
|
|
|
|
|
+ onMouseDown={beginTreeDrag}
|
|
|
|
|
+ className="absolute top-0 right-0 bottom-0 w-1.5 -mr-0.5 cursor-col-resize bg-transparent hover:bg-sky-300/60 active:bg-sky-400 transition-colors z-20"
|
|
|
|
|
+ title="拖拽调整内容树宽度"
|
|
|
|
|
+ />
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* 频繁模式列 */}
|
|
{/* 频繁模式列 */}
|
|
|
<PatternColumn
|
|
<PatternColumn
|
|
|
- selectedItemsetId={selectedItemsetId}
|
|
|
|
|
|
|
+ selectedItemsetIds={dashFilter.selections.pattern}
|
|
|
itemsets={allItemsets}
|
|
itemsets={allItemsets}
|
|
|
eligibleItemsetIds={relatedItemsetIds}
|
|
eligibleItemsetIds={relatedItemsetIds}
|
|
|
metricsMap={patternMetricsMap || {}}
|
|
metricsMap={patternMetricsMap || {}}
|
|
@@ -1639,25 +1655,24 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
focusTrigger={columnFocusTrigger.pattern ?? 0}
|
|
focusTrigger={columnFocusTrigger.pattern ?? 0}
|
|
|
onSelectItemset={(itemsetId, currentOrderIds) => {
|
|
onSelectItemset={(itemsetId, currentOrderIds) => {
|
|
|
setManualFocusedTreeNodeId(null);
|
|
setManualFocusedTreeNodeId(null);
|
|
|
-
|
|
|
|
|
- // Toggle dashFilter properly
|
|
|
|
|
|
|
+ setTreeFocusTrigger(prev => prev + 1);
|
|
|
|
|
+
|
|
|
if (itemsetId !== null) {
|
|
if (itemsetId !== null) {
|
|
|
dashFilter.toggleSelection('pattern', String(itemsetId));
|
|
dashFilter.toggleSelection('pattern', String(itemsetId));
|
|
|
- } else if (selectedItemsetId !== null) {
|
|
|
|
|
- dashFilter.toggleSelection('pattern', String(selectedItemsetId));
|
|
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 取消信号:清空 pattern 全部选中
|
|
|
|
|
+ Array.from(dashFilter.selections.pattern).forEach(id =>
|
|
|
|
|
+ dashFilter.toggleSelection('pattern', id)
|
|
|
|
|
+ );
|
|
|
|
|
+ setDrawerItem(null);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- setSelectedItemsetId(itemsetId);
|
|
|
|
|
- if (itemsetId !== null) {
|
|
|
|
|
- setTreeFocusTrigger(prev => prev + 1);
|
|
|
|
|
- }
|
|
|
|
|
setColumnFrozenOrders(prev => {
|
|
setColumnFrozenOrders(prev => {
|
|
|
const next = { ...prev };
|
|
const next = { ...prev };
|
|
|
if (itemsetId === null) delete next.pattern;
|
|
if (itemsetId === null) delete next.pattern;
|
|
|
else next.pattern = currentOrderIds;
|
|
else next.pattern = currentOrderIds;
|
|
|
return next;
|
|
return next;
|
|
|
});
|
|
});
|
|
|
- if (itemsetId === null) setDrawerItem(null);
|
|
|
|
|
}}
|
|
}}
|
|
|
onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
|
|
onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
|
|
|
onNodeClick={jumpToTreeNodeByName}
|
|
onNodeClick={jumpToTreeNodeByName}
|
|
@@ -1666,7 +1681,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
|
}}
|
|
}}
|
|
|
onFocusNext={() => {
|
|
onFocusNext={() => {
|
|
|
- const matchedCount = Array.from(relatedItemsetIds).length + (selectedItemsetId ? (relatedItemsetIds.has(String(selectedItemsetId)) ? 0 : 1) : 0);
|
|
|
|
|
|
|
+ const matchedCount = Array.from(relatedItemsetIds).length + Array.from(dashFilter.selections.pattern).filter(x => !relatedItemsetIds.has(x)).length;
|
|
|
setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
|
|
setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
|
|
|
setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
|
}}
|
|
}}
|
|
@@ -1674,96 +1689,64 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
|
|
{/* 关系列:每列固定宽度 */}
|
|
{/* 关系列:每列固定宽度 */}
|
|
|
{columns.map(col => {
|
|
{columns.map(col => {
|
|
|
- const activeId = getColumnActiveId(col.t);
|
|
|
|
|
- const orderedItems = sortedItems(col.d, col.eligibleIds, activeId !== null ? columnFrozenOrders[col.t] : undefined);
|
|
|
|
|
-
|
|
|
|
|
- // 计算所有可用匹配项的索引(按原始顺序)
|
|
|
|
|
- const matchedIndices: number[] = [];
|
|
|
|
|
- orderedItems.forEach((item: any, idx: number) => {
|
|
|
|
|
- const nodeId = `${col.t}:${item.id}`;
|
|
|
|
|
- const isSelected = activeId === nodeId;
|
|
|
|
|
- const isEligible = col.eligibleIds?.has(item.id) ?? false;
|
|
|
|
|
- if (isSelected || isEligible) {
|
|
|
|
|
- matchedIndices.push(idx);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const orderedItems = sortedItems(col.d, col.eligibleIds, dashFilter.selections[col.t as any]?.size > 0 ? columnFrozenOrders[col.t] : undefined);
|
|
|
|
|
+
|
|
|
|
|
+ // 计算所有可用匹配项的索引(按原始顺序)
|
|
|
|
|
+ const matchedIndices: number[] = [];
|
|
|
|
|
+ orderedItems.forEach((item: any, idx: number) => {
|
|
|
|
|
+ const isSelected = dashFilter.selections[col.t as any]?.has(String(item.id)) ?? false;
|
|
|
|
|
+ const isEligible = col.eligibleIds?.has(item.id) ?? false;
|
|
|
|
|
+ if (isSelected || isEligible) {
|
|
|
|
|
+ matchedIndices.push(idx);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- const focusIdx = columnFocusIndex[col.t] ?? 0;
|
|
|
|
|
- const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIdx, matchedIndices.length - 1) : 0;
|
|
|
|
|
- const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
|
|
+ const focusIdx = columnFocusIndex[col.t] ?? 0;
|
|
|
|
|
+ const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIdx, matchedIndices.length - 1) : 0;
|
|
|
|
|
+ const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
|
|
|
|
|
- <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
|
|
|
|
|
- <div className="flex justify-between items-center">
|
|
|
|
|
- <span>{col.l}</span>
|
|
|
|
|
- <div className="flex items-center gap-2">
|
|
|
|
|
- {matchedIndices.length > 1 && (
|
|
|
|
|
- <div className="flex items-center gap-1">
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => {
|
|
|
|
|
- setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.max(0, (prev[col.t] ?? 0) - 1) }));
|
|
|
|
|
- setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
|
|
|
|
|
- }}
|
|
|
|
|
- disabled={clampedFocusIdx === 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">
|
|
|
|
|
- {clampedFocusIdx + 1}/{matchedIndices.length}
|
|
|
|
|
- </span>
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() => {
|
|
|
|
|
- setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.min(matchedIndices.length - 1, (prev[col.t] ?? 0) + 1) }));
|
|
|
|
|
- setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
|
|
|
|
|
- }}
|
|
|
|
|
- disabled={clampedFocusIdx === matchedIndices.length - 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>
|
|
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
|
|
|
|
|
+ <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
|
|
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
|
|
+ <span>{col.l}</span>
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-slate-400">
|
|
|
|
|
+ {`${col.eligibleIds?.size ?? 0}/${col.d.length}`}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
- <span className="text-slate-400">
|
|
|
|
|
- {`${col.eligibleIds?.size ?? 0}/${col.d.length}`}
|
|
|
|
|
- </span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
|
|
- {orderedItems.map((item: any, idx: number) => (
|
|
|
|
|
- <RelationCard
|
|
|
|
|
- key={item.id}
|
|
|
|
|
- type={col.t}
|
|
|
|
|
- item={item}
|
|
|
|
|
- reqPlanBData={reqPlanBData}
|
|
|
|
|
- metrics={undefined}
|
|
|
|
|
- dimmed={!col.eligibleIds?.has(item.id)}
|
|
|
|
|
- activeId={activeId}
|
|
|
|
|
- shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
|
|
|
|
|
- selectedLeafNames={selectedSubtreeLeafNames || undefined}
|
|
|
|
|
- directMatch={dashFilter.getItemState(col.t as any, item.id) === 'active'}
|
|
|
|
|
- relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
|
|
|
|
|
- showAllSourceTags={col.t === 'req'}
|
|
|
|
|
- onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
|
|
|
|
|
- onSourceNodeClick={(name) => {
|
|
|
|
|
- jumpToTreeNodeByName(name);
|
|
|
|
|
- }}
|
|
|
|
|
- onOpenDetail={(item) => handleOpenDetailClick(item, col.t)}
|
|
|
|
|
- />
|
|
|
|
|
- ))}
|
|
|
|
|
- {col.d.length === 0 && (
|
|
|
|
|
- <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
|
|
|
|
|
- {hasAnyFilter ? '当前筛选下无数据' : '无数据'}
|
|
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
|
|
+ {orderedItems.map((item: any, idx: number) => (
|
|
|
|
|
+ <RelationCard
|
|
|
|
|
+ key={item.id}
|
|
|
|
|
+ type={col.t}
|
|
|
|
|
+ item={item}
|
|
|
|
|
+ metrics={undefined}
|
|
|
|
|
+ dimmed={dashFilter.getItemState(col.t as any, item.id) === 'dimmed'}
|
|
|
|
|
+ isSelected={dashFilter.selections[col.t as any]?.has(String(item.id)) ?? false}
|
|
|
|
|
+ shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
|
|
|
|
|
+ selectedLeafNames={selectedSubtreeLeafNames || undefined}
|
|
|
|
|
+ directMatch={dashFilter.getItemState(col.t as any, item.id) === 'matched'}
|
|
|
|
|
+ relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
|
|
|
|
|
+ showAllSourceTags={col.t === 'req'}
|
|
|
|
|
+ onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
|
|
|
|
|
+ onSourceNodeClick={(name) => {
|
|
|
|
|
+ jumpToTreeNodeByName(name);
|
|
|
|
|
+ }}
|
|
|
|
|
+ onOpenDetail={(item) => handleOpenDetailClick(item, col.t)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {col.d.length === 0 && (
|
|
|
|
|
+ <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
|
|
|
|
|
+ {hasAnyFilter ? '当前筛选下无数据' : '无数据'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -1786,7 +1769,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
dbData={{ ...dbData, caps: allCaps }}
|
|
dbData={{ ...dbData, caps: allCaps }}
|
|
|
nodePostsMap={drawerItem.nodePostsMap}
|
|
nodePostsMap={drawerItem.nodePostsMap}
|
|
|
onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
|
|
onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
|
|
|
- selectedReqId={selectedReqId}
|
|
|
|
|
|
|
+
|
|
|
/>
|
|
/>
|
|
|
)
|
|
)
|
|
|
)}
|
|
)}
|