|
|
@@ -472,7 +472,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
|
|
|
|
|
|
// ─── 关系列卡片 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
-function RelationCard({ type, item, activeId, shouldScrollIntoView = false, selectedLeafNames, directMatch = false, dimmed = false, showAllSourceTags = false, metrics, reqPlanBData, onSingleClick, onSourceNodeClick }: {
|
|
|
+function RelationCard({ type, item, activeId, shouldScrollIntoView = false, selectedLeafNames, directMatch = false, dimmed = false, showAllSourceTags = false, metrics, relationTags = [], reqPlanBData, onSingleClick, onSourceNodeClick }: {
|
|
|
type: string;
|
|
|
item: any;
|
|
|
activeId: string | null;
|
|
|
@@ -482,6 +482,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
dimmed?: boolean;
|
|
|
showAllSourceTags?: boolean;
|
|
|
metrics?: { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number };
|
|
|
+ relationTags?: Array<{ label: string; tone?: 'pattern' | 'direct' }>;
|
|
|
reqPlanBData?: any;
|
|
|
onSingleClick: (nodeId: string) => void;
|
|
|
onSourceNodeClick?: (nodeName: string) => void;
|
|
|
@@ -548,7 +549,10 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
return (
|
|
|
<div
|
|
|
ref={cardRef}
|
|
|
- onClick={() => onSingleClick(nodeId)}
|
|
|
+ onClick={() => {
|
|
|
+ if (dimmed) return;
|
|
|
+ onSingleClick(nodeId);
|
|
|
+ }}
|
|
|
className={cn(
|
|
|
"group p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
|
|
|
tc.accent,
|
|
|
@@ -559,6 +563,8 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
: dimmed
|
|
|
? "border border-slate-200 border-l-4 bg-slate-50 opacity-45 saturate-50"
|
|
|
: "border border-transparent border-l-4 hover:border-slate-200"
|
|
|
+ ,
|
|
|
+ dimmed && "cursor-not-allowed"
|
|
|
)}
|
|
|
>
|
|
|
<div className="flex items-start gap-2">
|
|
|
@@ -580,11 +586,25 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
来源节点
|
|
|
</span>
|
|
|
)}
|
|
|
+ {relationTags.map((tag) => (
|
|
|
+ <span
|
|
|
+ key={`${nodeId}-${tag.label}`}
|
|
|
+ className={cn(
|
|
|
+ "text-[9px] px-1.5 py-0.5 rounded-md font-bold border",
|
|
|
+ tag.tone === 'pattern'
|
|
|
+ ? "bg-amber-50 text-amber-700 border-amber-200"
|
|
|
+ : "bg-sky-50 text-sky-700 border-sky-200"
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {tag.label}
|
|
|
+ </span>
|
|
|
+ ))}
|
|
|
{sourceNodeTags.map((name: string) => {
|
|
|
const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
|
|
|
return (
|
|
|
<span key={name}
|
|
|
onClick={(e) => {
|
|
|
+ if (dimmed) return;
|
|
|
if (onSourceNodeClick) {
|
|
|
e.stopPropagation();
|
|
|
onSourceNodeClick(name);
|
|
|
@@ -593,7 +613,7 @@ function RelationCard({ type, item, activeId, shouldScrollIntoView = false, sele
|
|
|
className={cn(
|
|
|
"text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
|
|
|
isHighlighted ? "bg-sky-100 text-sky-700 ring-1 ring-sky-400 font-bold" : cn(tc.tagBg, tc.tagText),
|
|
|
- onSourceNodeClick && "cursor-pointer hover:ring-1 hover:ring-indigo-300"
|
|
|
+ onSourceNodeClick && !dimmed && "cursor-pointer hover:ring-1 hover:ring-indigo-300"
|
|
|
)}>
|
|
|
{name}
|
|
|
</span>
|
|
|
@@ -1613,26 +1633,32 @@ function PatternColumn({
|
|
|
selectedItemsetId,
|
|
|
itemsets,
|
|
|
eligibleItemsetIds,
|
|
|
+ frozenOrder,
|
|
|
contextNodeNames,
|
|
|
patternMatchedNodesMap,
|
|
|
nodeRoleByName,
|
|
|
hasAnyFilter,
|
|
|
focusIndex,
|
|
|
+ focusTrigger,
|
|
|
onSelectItemset,
|
|
|
onOpenDrawer,
|
|
|
+ onNodeClick,
|
|
|
onFocusPrev,
|
|
|
onFocusNext,
|
|
|
}: {
|
|
|
selectedItemsetId: number | null;
|
|
|
itemsets: any[];
|
|
|
eligibleItemsetIds: Set<string>;
|
|
|
+ frozenOrder?: string[];
|
|
|
contextNodeNames: Set<string>;
|
|
|
patternMatchedNodesMap?: Record<string, Set<string>>;
|
|
|
nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
|
|
|
hasAnyFilter: boolean;
|
|
|
focusIndex: number;
|
|
|
- onSelectItemset: (itemsetId: number | null) => void;
|
|
|
+ focusTrigger: number;
|
|
|
+ onSelectItemset: (itemsetId: number | null, currentOrderIds: string[]) => void;
|
|
|
onOpenDrawer: (itemset: any) => void;
|
|
|
+ onNodeClick?: (nodeName: string) => void;
|
|
|
onFocusPrev: () => void;
|
|
|
onFocusNext: () => void;
|
|
|
}) {
|
|
|
@@ -1649,18 +1675,24 @@ function PatternColumn({
|
|
|
}
|
|
|
return { ...itemset, matched_nodes: matchedNodes };
|
|
|
});
|
|
|
+ if (selectedItemsetId !== null && frozenOrder && frozenOrder.length > 0) {
|
|
|
+ const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
|
|
|
+ return [...withMatches].sort((a, b) => {
|
|
|
+ const aRank = rank.get(String(a.id));
|
|
|
+ const bRank = rank.get(String(b.id));
|
|
|
+ if (aRank === undefined && bRank === undefined) return 0;
|
|
|
+ if (aRank === undefined) return 1;
|
|
|
+ if (bRank === undefined) return -1;
|
|
|
+ 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;
|
|
|
-
|
|
|
- const aMatchCount = (a.matched_nodes || []).length;
|
|
|
- const bMatchCount = (b.matched_nodes || []).length;
|
|
|
- if (aMatchCount !== bMatchCount) return bMatchCount - aMatchCount;
|
|
|
-
|
|
|
- return b.absolute_support - a.absolute_support;
|
|
|
+ return 0;
|
|
|
});
|
|
|
- }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds]);
|
|
|
+ }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds, selectedItemsetId, frozenOrder]);
|
|
|
|
|
|
const matchedIndices = useMemo(() => {
|
|
|
const indices: number[] = [];
|
|
|
@@ -1676,10 +1708,10 @@ function PatternColumn({
|
|
|
const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (focusedItemIndex >= 0) {
|
|
|
+ if (focusTrigger > 0 && focusedItemIndex >= 0) {
|
|
|
itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
}
|
|
|
- }, [focusedItemIndex]);
|
|
|
+ }, [focusedItemIndex, focusTrigger]);
|
|
|
|
|
|
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-amber-400">
|
|
|
@@ -1725,8 +1757,9 @@ function PatternColumn({
|
|
|
itemRefs.current[idx] = el;
|
|
|
}}
|
|
|
onClick={() => {
|
|
|
+ if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
const next = isSelected ? null : itemset.id;
|
|
|
- onSelectItemset(next);
|
|
|
+ onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
|
|
|
if (next !== null) onOpenDrawer(itemset);
|
|
|
}}
|
|
|
className={cn(
|
|
|
@@ -1736,7 +1769,7 @@ function PatternColumn({
|
|
|
: isEligible
|
|
|
? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
|
|
|
: "border border-slate-100 hover:border-amber-200",
|
|
|
- hasAnyFilter && !isEligible && !isSelected && "opacity-40"
|
|
|
+ hasAnyFilter && !isEligible && !isSelected && "opacity-40 cursor-not-allowed"
|
|
|
)}
|
|
|
>
|
|
|
<div className="flex items-center justify-between">
|
|
|
@@ -1746,12 +1779,21 @@ function PatternColumn({
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
{(itemset.leaf_names || []).map((name: string) => {
|
|
|
const isMatched = itemset.matched_nodes.includes(name);
|
|
|
- const role = nodeRoleByName[name];
|
|
|
return (
|
|
|
- <span key={name} className={cn(
|
|
|
+ <span
|
|
|
+ key={name}
|
|
|
+ onClick={(e) => {
|
|
|
+ if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
+ if (!onNodeClick) return;
|
|
|
+ e.stopPropagation();
|
|
|
+ onNodeClick(name);
|
|
|
+ }}
|
|
|
+ className={cn(
|
|
|
"px-2 py-0.5 rounded-full text-xs border",
|
|
|
- isMatched ? "bg-orange-50 text-orange-700 border-orange-200 font-bold" :
|
|
|
- "bg-slate-50 text-slate-500 border-slate-200"
|
|
|
+ (isSelected || isEligible) && isMatched
|
|
|
+ ? "bg-sky-50 text-sky-700 border-sky-200 font-bold"
|
|
|
+ : "bg-slate-50 text-slate-500 border-slate-200",
|
|
|
+ onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-sky-300"
|
|
|
)}>{name}</span>
|
|
|
);
|
|
|
})}
|
|
|
@@ -1792,8 +1834,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const [selectedCapId, setSelectedCapId] = useState<string | null>(null);
|
|
|
const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
|
|
|
const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
|
|
|
+ const [columnFocusTrigger, setColumnFocusTrigger] = useState<Record<string, number>>({});
|
|
|
+ const [columnFrozenOrders, setColumnFrozenOrders] = useState<Record<string, string[]>>({});
|
|
|
const [treeFocusIndex, setTreeFocusIndex] = useState(0);
|
|
|
const [treeFocusTrigger, setTreeFocusTrigger] = useState(0);
|
|
|
+ const [manualFocusedTreeNodeId, setManualFocusedTreeNodeId] = useState<string | number | null>(null);
|
|
|
const [dashboardLoadingText, setDashboardLoadingText] = useState<string | null>('开始初始化底座...');
|
|
|
const [flowBoardExpanded, setFlowBoardExpanded] = useState(false);
|
|
|
const [allItemsets, setAllItemsets] = useState<any[]>([]);
|
|
|
@@ -2136,6 +2181,13 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return mapping;
|
|
|
}, [treeData]);
|
|
|
|
|
|
+ const jumpToTreeNodeByName = useCallback((nodeName: string) => {
|
|
|
+ const targetNode = (nameToNodesMap[nodeName] && nameToNodesMap[nodeName][0]) || nameToNodeMap[nodeName];
|
|
|
+ if (!targetNode) return;
|
|
|
+ setManualFocusedTreeNodeId(targetNode.id);
|
|
|
+ setTreeFocusTrigger(prev => prev + 1);
|
|
|
+ }, [nameToNodeMap, nameToNodesMap]);
|
|
|
+
|
|
|
const nodeIdToExtractionSubtree = useMemo((): Record<string, { parentId: string; memberIds: Set<string>; leafIds: Set<string> }> => {
|
|
|
const mapping: Record<string, { parentId: string; memberIds: Set<string>; leafIds: Set<string> }> = {};
|
|
|
extractionSubtreePartitions.forEach((partition) => {
|
|
|
@@ -2213,8 +2265,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return collectDirectNodeIdsFromReqs(reqs);
|
|
|
}, [reqById, collectDirectNodeIdsFromReqs]);
|
|
|
|
|
|
- const nodeMetricsMap = useMemo((): Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }> => {
|
|
|
- const metricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number }> = {};
|
|
|
+ const nodeMetricsMap = useMemo((): Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }> => {
|
|
|
+ const metricsMap: Record<string, { reqCount: number; procCount: number; capCount: number; toolCount: number; nodeCount: number; patternCount: number }> = {};
|
|
|
if (!treeData) return metricsMap;
|
|
|
|
|
|
const countDescendantNodes = (node: any): number => {
|
|
|
@@ -2231,6 +2283,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const capIds = new Set<string>();
|
|
|
const toolIds = new Set<string>();
|
|
|
const procIds = new Set<string>();
|
|
|
+ const patternIds = new Set<string>();
|
|
|
|
|
|
dbData.reqs.forEach((req: any) => {
|
|
|
const isDirectNode = (req.source_nodes || []).some((sn: any) => {
|
|
|
@@ -2261,6 +2314,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+ allItemsets.forEach((itemset: any) => {
|
|
|
+ if ((itemset.leaf_names || []).includes(node.name)) {
|
|
|
+ patternIds.add(String(itemset.id));
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
if (node.id !== undefined && node.id !== null) {
|
|
|
metricsMap[String(node.id)] = {
|
|
|
reqCount: reqIds.size,
|
|
|
@@ -2268,6 +2327,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
capCount: capIds.size,
|
|
|
toolCount: toolIds.size,
|
|
|
nodeCount: countDescendantNodes(node),
|
|
|
+ patternCount: patternIds.size,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -2276,7 +2336,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
walk(treeData);
|
|
|
return metricsMap;
|
|
|
- }, [treeData, dbData.reqs, dbData.tools, nameToNodeMap, virtualCaps]);
|
|
|
+ }, [treeData, dbData.reqs, dbData.tools, virtualCaps, dbData.procs, allItemsets]);
|
|
|
|
|
|
// ── 树节点过滤:选中节点后只显示关联数据(基于所有共享需求的节点)───────────────────────────────────
|
|
|
const selectedReqCapabilityIds = useMemo(() => {
|
|
|
@@ -2390,7 +2450,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
));
|
|
|
(req.patterns || []).forEach((pattern: any) => {
|
|
|
const hasValidRepresents = (pattern.judgments || []).some((j: any) => j.represents && sourceNodes.has(j.node));
|
|
|
- if (!hasValidRepresents) return;
|
|
|
+ const hasDirectLeafMatch = (pattern.leaf_names || []).some((name: string) => sourceNodes.has(name));
|
|
|
+ if (!hasValidRepresents && !hasDirectLeafMatch) return;
|
|
|
|
|
|
const patternId = String(pattern.pattern_id);
|
|
|
if (!map.has(patternId)) map.set(patternId, new Set<string>());
|
|
|
@@ -2436,6 +2497,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return itemsetReqIdsMap.get(String(selectedItemset.id)) || new Set<string>();
|
|
|
}, [itemsetReqIdsMap, selectedItemset]);
|
|
|
|
|
|
+ const isPlanBPatternAssociatedWithReq = useCallback((reqInfo: any, pattern: any) => {
|
|
|
+ const sourceNodes = new Set<string>(
|
|
|
+ (reqInfo?.source_nodes || []).map((sn: any) =>
|
|
|
+ typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
|
|
|
+ ).filter((name: string) => !!name && name !== '__meta__' && name !== '__abstract__')
|
|
|
+ );
|
|
|
+ const hasValidRepresents = (pattern?.judgments || []).some((j: any) => j.represents && sourceNodes.has(j.node));
|
|
|
+ const hasDirectLeafMatch = (pattern?.leaf_names || []).some((name: string) => sourceNodes.has(name));
|
|
|
+ return hasValidRepresents || hasDirectLeafMatch;
|
|
|
+ }, []);
|
|
|
+
|
|
|
const selectedItemsetDirectNodeIds = useMemo((): Set<string> | null => {
|
|
|
if (!selectedItemset) return null;
|
|
|
const ids = collectDirectNodeIdsFromNames((selectedItemset.leaf_names || []).filter(Boolean), true);
|
|
|
@@ -2449,13 +2521,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
(selectedNodeDirectNames || new Set<string>()).forEach((name) => names.add(name));
|
|
|
if (selectedReqId) {
|
|
|
const req = dbData.reqs.find((r: any) => r.id === selectedReqId);
|
|
|
- const meta = (req.source_nodes || []).find((sn: any) => {
|
|
|
+ (req?.source_nodes || []).forEach((sn: any) => {
|
|
|
const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
- return nodeName === '__meta__';
|
|
|
+ if (nodeName && nodeName !== '__meta__' && nodeName !== '__abstract__') names.add(nodeName);
|
|
|
});
|
|
|
- const ctx = meta?.extraction_context || {};
|
|
|
- (ctx.substance?.nodes || []).forEach((name: string) => name && names.add(name));
|
|
|
- (ctx.form?.nodes || []).forEach((name: string) => name && names.add(name));
|
|
|
}
|
|
|
return names;
|
|
|
}, [selectedNodeDirectNames, selectedReqId, dbData.reqs]);
|
|
|
@@ -2465,15 +2534,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
|
|
|
if (!reqInfo) return undefined;
|
|
|
|
|
|
- const sourceNodes = new Set<string>((reqInfo.source_nodes || []).map((sn: any) =>
|
|
|
- typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
|
|
|
- ));
|
|
|
-
|
|
|
const map: Record<string, Set<string>> = {};
|
|
|
(reqInfo.patterns || []).forEach((pattern: any) => {
|
|
|
+ if (!isPlanBPatternAssociatedWithReq(reqInfo, pattern)) return;
|
|
|
const set = new Set<string>();
|
|
|
(pattern.judgments || []).forEach((j: any) => {
|
|
|
- if (j.represents && sourceNodes.has(j.node)) {
|
|
|
+ if (j.represents) {
|
|
|
set.add(j.node);
|
|
|
}
|
|
|
});
|
|
|
@@ -2482,7 +2548,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
});
|
|
|
return map;
|
|
|
- }, [reqPlanBData, selectedReqId]);
|
|
|
+ }, [reqPlanBData, selectedReqId, isPlanBPatternAssociatedWithReq]);
|
|
|
|
|
|
const requirementVisible = (req: any) => {
|
|
|
if (onlyCoveredFilter) {
|
|
|
@@ -2508,12 +2574,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const requirementMatches = (req: any) => {
|
|
|
if (!requirementVisible(req)) return false;
|
|
|
if (selectedNode) {
|
|
|
- const selectedNodeName = selectedNode.name;
|
|
|
- const hasDirectSourceNode = (req.source_nodes || []).some((sn: any) => {
|
|
|
- const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
- return nodeName === selectedNodeName;
|
|
|
- });
|
|
|
- if (!hasDirectSourceNode) return false;
|
|
|
+ if (!selectedNodeReqIds.has(req.id)) return false;
|
|
|
}
|
|
|
if (selectedProcId) {
|
|
|
const proc = dbData.procs.find((item) => item.id === selectedProcId);
|
|
|
@@ -2576,18 +2637,36 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return ids;
|
|
|
}, [selectedNode, visibleData.reqs]);
|
|
|
|
|
|
+ const patternNodeReqIds = useMemo((): Set<string> => {
|
|
|
+ if (!selectedNode || !selectedNodeDirectNames || selectedNodeDirectNames.size === 0) return new Set<string>();
|
|
|
+ const ids = new Set<string>();
|
|
|
+ allItemsets.forEach((itemset: any) => {
|
|
|
+ const hasNodeMatch = (itemset.leaf_names || []).some((name: string) => selectedNodeDirectNames.has(name));
|
|
|
+ if (!hasNodeMatch) return;
|
|
|
+ (itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>()).forEach((reqId) => ids.add(reqId));
|
|
|
+ });
|
|
|
+ return ids;
|
|
|
+ }, [selectedNode, selectedNodeDirectNames, allItemsets, itemsetReqIdsMap]);
|
|
|
+
|
|
|
+ const selectedNodeReqIds = useMemo((): Set<string> => {
|
|
|
+ const ids = new Set<string>();
|
|
|
+ directNodeReqIds.forEach((id) => ids.add(id));
|
|
|
+ patternNodeReqIds.forEach((id) => ids.add(id));
|
|
|
+ return ids;
|
|
|
+ }, [directNodeReqIds, patternNodeReqIds]);
|
|
|
+
|
|
|
const directNodeCapIds = useMemo((): Set<string> => {
|
|
|
const ids = new Set<string>();
|
|
|
- if (directNodeReqIds.size === 0) return ids;
|
|
|
+ if (selectedNodeReqIds.size === 0) return ids;
|
|
|
visibleData.reqs.forEach((req: any) => {
|
|
|
- if (!directNodeReqIds.has(req.id)) return;
|
|
|
+ if (!selectedNodeReqIds.has(req.id)) return;
|
|
|
(req.capability_ids || []).forEach((cid: string) => ids.add(cid));
|
|
|
virtualCaps.forEach((cap: any) => {
|
|
|
if ((cap.requirement_ids || []).includes(req.id)) ids.add(cap.id);
|
|
|
});
|
|
|
});
|
|
|
return ids;
|
|
|
- }, [directNodeReqIds, visibleData.reqs, virtualCaps]);
|
|
|
+ }, [selectedNodeReqIds, visibleData.reqs, virtualCaps]);
|
|
|
|
|
|
const directNodeToolIds = useMemo((): Set<string> => {
|
|
|
const ids = new Set<string>();
|
|
|
@@ -2600,14 +2679,14 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
const directNodeProcIds = useMemo((): Set<string> => {
|
|
|
const ids = new Set<string>();
|
|
|
- if (directNodeReqIds.size === 0) return ids;
|
|
|
+ if (selectedNodeReqIds.size === 0) return ids;
|
|
|
dbData.procs.forEach((workflow) => {
|
|
|
- if ((workflow.requirement_ids || []).some((reqId: string) => directNodeReqIds.has(reqId))) {
|
|
|
+ if ((workflow.requirement_ids || []).some((reqId: string) => selectedNodeReqIds.has(reqId))) {
|
|
|
ids.add(workflow.id);
|
|
|
}
|
|
|
});
|
|
|
return ids;
|
|
|
- }, [directNodeReqIds]);
|
|
|
+ }, [selectedNodeReqIds]);
|
|
|
|
|
|
const unionActiveSets = (...sets: Array<Set<string> | null | undefined>) => {
|
|
|
const activeSets = sets.filter((set): set is Set<string> => !!set);
|
|
|
@@ -2639,7 +2718,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
};
|
|
|
|
|
|
const isGlobalConflict = useMemo(() => {
|
|
|
- if (selectedNode && selectedReqId && !directNodeReqIds.has(selectedReqId)) return true;
|
|
|
+ if (selectedNode && selectedReqId && !selectedNodeReqIds.has(selectedReqId)) return true;
|
|
|
if (selectedNode && selectedProcId && !directNodeProcIds.has(selectedProcId)) return true;
|
|
|
if (selectedNode && selectedCapId && !directNodeCapIds.has(selectedCapId)) return true;
|
|
|
if (selectedNode && selectedToolId && !directNodeToolIds.has(selectedToolId)) return true;
|
|
|
@@ -2681,7 +2760,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return false;
|
|
|
}, [
|
|
|
selectedNode, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedItemsetId,
|
|
|
- directNodeReqIds, directNodeProcIds, directNodeCapIds, directNodeToolIds,
|
|
|
+ selectedNodeReqIds, directNodeProcIds, directNodeCapIds, directNodeToolIds,
|
|
|
selectedReqProcIds, selectedReqCapabilityIds, selectedReqToolIds, itemsetReqIdsMap,
|
|
|
selectedProcCapabilityIds, visibleData.tools, selectedItemsetReqIds, dbData.procs, allCaps
|
|
|
]);
|
|
|
@@ -2747,7 +2826,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
|
|
|
const relatedReqIds = useMemo((): Set<string> => {
|
|
|
if (isGlobalConflict) return new Set<string>();
|
|
|
- const fromNode = selectedNode ? directNodeReqIds : null;
|
|
|
+ const fromNode = selectedNode ? selectedNodeReqIds : null;
|
|
|
const fromProc = selectedProcId ? selectedProcRequirementIds : null;
|
|
|
const fromReq = selectedReqId ? new Set<string>([selectedReqId]) : null;
|
|
|
const fromCap = selectedCapId
|
|
|
@@ -2756,7 +2835,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const fromTool = selectedToolId ? selectedToolRequirementIds : null;
|
|
|
const fromItemset = selectedItemsetId && selectedItemsetReqIds ? selectedItemsetReqIds : null;
|
|
|
return intersectWithAllowed(intersectActiveSets(fromNode, fromProc, fromReq, fromCap, fromTool, fromItemset), visibleReqIds);
|
|
|
- }, [directNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
|
|
|
+ }, [selectedNodeReqIds, visibleReqIds, selectedNode, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedItemsetId, selectedItemsetReqIds]);
|
|
|
|
|
|
const relatedProcIds = useMemo((): Set<string> => {
|
|
|
if (isGlobalConflict) return new Set<string>();
|
|
|
@@ -2824,6 +2903,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (selectedReqId && !selectedReqCapabilityIds.has(cap.id)) return false;
|
|
|
if (selectedCapId && cap.id !== selectedCapId) return false;
|
|
|
if (selectedToolId && !selectedToolCapabilityIds.has(cap.id)) return false;
|
|
|
+ if (selectedItemsetId && !relatedCapIds.has(cap.id)) return false;
|
|
|
// 如果有节点选择,能力必须在 relatedCapIds 中
|
|
|
if (selectedNode && !relatedCapIds.has(cap.id)) return false;
|
|
|
return true;
|
|
|
@@ -2838,6 +2918,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (!hasProcCap) return false;
|
|
|
}
|
|
|
if (selectedToolId && tool.id !== selectedToolId) return false;
|
|
|
+ if (selectedItemsetId && !relatedToolIds.has(tool.id)) return false;
|
|
|
// 如果有节点选择,工具必须在 relatedToolIds 中
|
|
|
if (selectedNode && !relatedToolIds.has(tool.id)) return false;
|
|
|
return true;
|
|
|
@@ -2856,6 +2937,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
selectedReqId,
|
|
|
selectedCapId,
|
|
|
selectedToolId,
|
|
|
+ selectedItemsetId,
|
|
|
selectedProcCapabilityIds,
|
|
|
selectedReqCapabilityIds,
|
|
|
selectedReqToolIds,
|
|
|
@@ -2885,13 +2967,22 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const form = new Set<string>();
|
|
|
|
|
|
relatedReqs.forEach((req: any) => {
|
|
|
+ const directSourceNames = new Set<string>();
|
|
|
+ (req.source_nodes || []).forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ if (nodeName && nodeName !== '__meta__' && nodeName !== '__abstract__') directSourceNames.add(nodeName);
|
|
|
+ });
|
|
|
const meta = (req.source_nodes || []).find((sn: any) => {
|
|
|
const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
return nodeName === '__meta__';
|
|
|
});
|
|
|
const ctx = meta?.extraction_context || {};
|
|
|
- (ctx.substance?.nodes || []).forEach((name: string) => name && substance.add(name));
|
|
|
- (ctx.form?.nodes || []).forEach((name: string) => name && form.add(name));
|
|
|
+ (ctx.substance?.nodes || []).forEach((name: string) => {
|
|
|
+ if (name && directSourceNames.has(name)) substance.add(name);
|
|
|
+ });
|
|
|
+ (ctx.form?.nodes || []).forEach((name: string) => {
|
|
|
+ if (name && directSourceNames.has(name)) form.add(name);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
const result: Record<string, 'substance' | 'form' | 'both'> = {};
|
|
|
@@ -2904,23 +2995,35 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
return result;
|
|
|
}, [filteredData.reqs, selectedProcId, selectedProcRequirementIds, selectedReqId, selectedCapId, selectedCapRequirementIds, selectedToolId, selectedToolRequirementIds, selectedNode, directNodeReqIds]);
|
|
|
|
|
|
- const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
|
|
|
- if (!selectedReqId) return null;
|
|
|
+ const collectPatternNodeIdsFromReqIds = useCallback((reqIds: Set<string>): Set<string> | null => {
|
|
|
+ if (reqIds.size === 0) return null;
|
|
|
+
|
|
|
const relatedLeafNames = new Set<string>();
|
|
|
|
|
|
if (reqPlanBData && reqPlanBData.requirements) {
|
|
|
- const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === selectedReqId);
|
|
|
- if (reqInfo) {
|
|
|
+ reqIds.forEach((reqId) => {
|
|
|
+ const reqInfo = reqPlanBData.requirements.find((r: any) => r.requirement_id === reqId);
|
|
|
+ if (!reqInfo) return;
|
|
|
(reqInfo.patterns || []).forEach((pattern: any) => {
|
|
|
- (pattern.leaf_names || []).forEach((name: string) => {
|
|
|
- if (name) relatedLeafNames.add(name);
|
|
|
+ if (!isPlanBPatternAssociatedWithReq(reqInfo, pattern)) return;
|
|
|
+ let hasTrueJudgment = false;
|
|
|
+ (pattern.judgments || []).forEach((j: any) => {
|
|
|
+ if (j.represents) {
|
|
|
+ hasTrueJudgment = true;
|
|
|
+ relatedLeafNames.add(j.node);
|
|
|
+ }
|
|
|
});
|
|
|
+ if (!hasTrueJudgment) {
|
|
|
+ (pattern.leaf_names || []).forEach((name: string) => {
|
|
|
+ if (name) relatedLeafNames.add(name);
|
|
|
+ });
|
|
|
+ }
|
|
|
});
|
|
|
- }
|
|
|
+ });
|
|
|
} else {
|
|
|
allItemsets.forEach((itemset: any) => {
|
|
|
- const reqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
- if (!reqIds.has(selectedReqId)) return;
|
|
|
+ const itemsetReqIds = itemsetReqIdsMap.get(String(itemset.id)) || new Set<string>();
|
|
|
+ if (!Array.from(itemsetReqIds).some((reqId) => reqIds.has(reqId))) return;
|
|
|
(itemset.leaf_names || []).forEach((name: string) => {
|
|
|
if (name) relatedLeafNames.add(name);
|
|
|
});
|
|
|
@@ -2930,7 +3033,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (relatedLeafNames.size === 0) return null;
|
|
|
const ids = collectDirectNodeIdsFromNames(relatedLeafNames);
|
|
|
return ids.size > 0 ? ids : null;
|
|
|
- }, [reqPlanBData, allItemsets, itemsetReqIdsMap, selectedReqId, collectDirectNodeIdsFromNames]);
|
|
|
+ }, [reqPlanBData, allItemsets, itemsetReqIdsMap, collectDirectNodeIdsFromNames, isPlanBPatternAssociatedWithReq]);
|
|
|
+
|
|
|
+ const selectedReqPatternNodeIds = useMemo((): Set<string> | null => {
|
|
|
+ if (!selectedReqId) return null;
|
|
|
+ return collectPatternNodeIdsFromReqIds(new Set<string>([selectedReqId]));
|
|
|
+ }, [selectedReqId, collectPatternNodeIdsFromReqIds]);
|
|
|
|
|
|
const filteredProcItems = useMemo(() => {
|
|
|
if (isGlobalConflict) return [];
|
|
|
@@ -2939,11 +3047,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (selectedProcId && workflow.id !== selectedProcId) return false;
|
|
|
if (selectedCapId && !(workflow.capability_ids || []).includes(selectedCapId)) return false;
|
|
|
if (selectedToolId && !(workflow.capability_ids || []).some((capId: string) => selectedToolCapabilityIds.has(capId))) return false;
|
|
|
+ if (selectedItemsetId && !relatedProcIds.has(workflow.id)) return false;
|
|
|
// 如果有节点选择,工序必须在 relatedProcIds 中
|
|
|
if (selectedNode && !relatedProcIds.has(workflow.id)) return false;
|
|
|
return true;
|
|
|
});
|
|
|
- }, [visibleProcItems, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedToolCapabilityIds, selectedNode, relatedProcIds]);
|
|
|
+ }, [visibleProcItems, selectedReqId, selectedProcId, selectedCapId, selectedToolId, selectedToolCapabilityIds, selectedItemsetId, selectedNode, relatedProcIds]);
|
|
|
|
|
|
const relationFilterHighlightLeafNames = useMemo((): Set<string> | null => null, []);
|
|
|
|
|
|
@@ -2952,43 +3061,57 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
const filteredToolIds = useMemo(() => new Set(filteredData.tools.map((tool: any) => tool.id)), [filteredData.tools]);
|
|
|
const filteredProcIds = useMemo(() => new Set(filteredProcItems.map((workflow) => workflow.id)), [filteredProcItems]);
|
|
|
|
|
|
+ const reqRelationTagsMap = useMemo((): Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> => {
|
|
|
+ const map: Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> = {};
|
|
|
+ if (!selectedNode) return map;
|
|
|
+
|
|
|
+ filteredData.reqs.forEach((req: any) => {
|
|
|
+ const tags: Array<{ label: string; tone?: 'pattern' | 'direct' }> = [];
|
|
|
+ if (directNodeReqIds.has(req.id)) tags.push({ label: '来源', tone: 'direct' });
|
|
|
+ if (patternNodeReqIds.has(req.id)) tags.push({ label: 'Pattern', tone: 'pattern' });
|
|
|
+ if (tags.length > 0) map[req.id] = tags;
|
|
|
+ });
|
|
|
+
|
|
|
+ return map;
|
|
|
+ }, [selectedNode, filteredData.reqs, directNodeReqIds, patternNodeReqIds]);
|
|
|
+
|
|
|
const selectedReq = dbData.reqs.find((r: any) => r.id === selectedReqId) ?? null;
|
|
|
const selectedProc = dbData.procs.find((p) => p.id === selectedProcId) ?? null;
|
|
|
const selectedCap = allCaps.find((c: any) => c.id === selectedCapId) ?? null;
|
|
|
const selectedTool = dbData.tools.find((t: any) => t.id === selectedToolId) ?? null;
|
|
|
|
|
|
const selectedReqSourceNodeIds = useMemo((): Set<string> | null => {
|
|
|
- const relatedReqs =
|
|
|
- selectedProcId
|
|
|
- ? filteredData.reqs.filter((req: any) => selectedProcRequirementIds.has(req.id))
|
|
|
- : selectedReqId
|
|
|
- ? filteredData.reqs.filter((req: any) => req.id === selectedReqId)
|
|
|
- : selectedCapId
|
|
|
- ? filteredData.reqs.filter((req: any) => selectedCapRequirementIds.has(req.id))
|
|
|
- : selectedToolId
|
|
|
- ? filteredData.reqs.filter((req: any) => selectedToolRequirementIds.has(req.id))
|
|
|
- : [];
|
|
|
+ const hasReqSideFilter = !!(selectedReqId || selectedProcId || selectedCapId || selectedToolId);
|
|
|
+ if (!hasReqSideFilter || filteredData.reqs.length === 0) return null;
|
|
|
|
|
|
- if (relatedReqs.length === 0) return null;
|
|
|
- const ids = collectDirectNodeIdsFromReqs(relatedReqs);
|
|
|
+ const patternIds = collectPatternNodeIdsFromReqIds(new Set<string>(filteredData.reqs.map((req: any) => req.id)));
|
|
|
+ if (patternIds && patternIds.size > 0) return patternIds;
|
|
|
+ const ids = collectDirectNodeIdsFromReqs(filteredData.reqs);
|
|
|
return ids.size > 0 ? ids : null;
|
|
|
}, [
|
|
|
filteredData.reqs,
|
|
|
selectedProcId,
|
|
|
- selectedProcRequirementIds,
|
|
|
selectedReqId,
|
|
|
selectedCapId,
|
|
|
- selectedCapRequirementIds,
|
|
|
selectedToolId,
|
|
|
- selectedToolRequirementIds,
|
|
|
+ collectPatternNodeIdsFromReqIds,
|
|
|
collectDirectNodeIdsFromReqs,
|
|
|
]);
|
|
|
|
|
|
+ const activeTreeNodeIds = useMemo((): Set<string> | null => {
|
|
|
+ const fromNode = selectedNode ? new Set<string>([String(selectedNode.id)]) : null;
|
|
|
+ const fromReqChain = selectedReqSourceNodeIds;
|
|
|
+ const fromItemset = selectedItemsetId ? selectedItemsetDirectNodeIds : null;
|
|
|
+
|
|
|
+ const ids = intersectActiveSets(fromNode, fromReqChain, fromItemset);
|
|
|
+ return ids.size > 0 ? ids : null;
|
|
|
+ }, [selectedNode, selectedReqSourceNodeIds, selectedItemsetId, selectedItemsetDirectNodeIds]);
|
|
|
+
|
|
|
const selectedPatternNodeIds = useMemo((): Set<string> | null => {
|
|
|
- if (selectedItemsetDirectNodeIds) return selectedItemsetDirectNodeIds;
|
|
|
- if (selectedReqPatternNodeIds) return selectedReqPatternNodeIds;
|
|
|
+ if (activeTreeNodeIds) return activeTreeNodeIds;
|
|
|
+ if (selectedReqId) return selectedReqPatternNodeIds;
|
|
|
return null;
|
|
|
- }, [selectedItemsetDirectNodeIds, selectedReqPatternNodeIds]);
|
|
|
+ }, [activeTreeNodeIds, selectedReqId, selectedReqPatternNodeIds]);
|
|
|
|
|
|
const selectedSubtreeLeafNamesKey = useMemo(
|
|
|
() => (selectedSubtreeLeafNames ? Array.from(selectedSubtreeLeafNames).sort().join('|') : ''),
|
|
|
@@ -3325,26 +3448,73 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
false
|
|
|
);
|
|
|
|
|
|
- const sortedItems = (items: any[], eligibleIds?: Set<string>) => {
|
|
|
+ const sortedItems = (items: any[], eligibleIds?: Set<string>, frozenOrder?: string[]) => {
|
|
|
+ if (frozenOrder && frozenOrder.length > 0) {
|
|
|
+ const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
|
|
|
+ return [...items].sort((a, b) => {
|
|
|
+ const aRank = rank.get(String(a.id));
|
|
|
+ const bRank = rank.get(String(b.id));
|
|
|
+ if (aRank === undefined && bRank === undefined) return 0;
|
|
|
+ if (aRank === undefined) return 1;
|
|
|
+ if (bRank === undefined) return -1;
|
|
|
+ return aRank - bRank;
|
|
|
+ });
|
|
|
+ }
|
|
|
if (!eligibleIds || eligibleIds.size === 0) return [...items];
|
|
|
return [...items].sort((a, b) => {
|
|
|
const aEligible = eligibleIds.has(a.id) ? 0 : 1;
|
|
|
const bEligible = eligibleIds.has(b.id) ? 0 : 1;
|
|
|
- return aEligible - bEligible;
|
|
|
+ if (aEligible !== bEligible) return aEligible - bEligible;
|
|
|
+ return 0;
|
|
|
});
|
|
|
};
|
|
|
|
|
|
- const handleSingleClick = (nodeId: string, item: any) => {
|
|
|
+ const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
|
|
|
const [type, id] = nodeId.split(':');
|
|
|
const isSameProc = type === 'proc' && selectedProcId === id;
|
|
|
const isSameReq = type === 'req' && selectedReqId === id;
|
|
|
const isSameCap = type === 'cap' && selectedCapId === id;
|
|
|
const isSameTool = type === 'tool' && selectedToolId === id;
|
|
|
|
|
|
- if (type === 'proc') { setSelectedProcId(isSameProc ? null : id); setColumnFocusIndex(prev => ({ ...prev, proc: 0 })); }
|
|
|
- if (type === 'req') { setSelectedReqId(isSameReq ? null : id); setColumnFocusIndex(prev => ({ ...prev, req: 0 })); }
|
|
|
- if (type === 'cap') { setSelectedCapId(isSameCap ? null : id); setColumnFocusIndex(prev => ({ ...prev, cap: 0 })); }
|
|
|
- if (type === 'tool') { setSelectedToolId(isSameTool ? null : id); setColumnFocusIndex(prev => ({ ...prev, tool: 0 })); }
|
|
|
+ setManualFocusedTreeNodeId(null);
|
|
|
+ setTreeFocusTrigger(prev => prev + 1);
|
|
|
+
|
|
|
+ if (type === 'proc') {
|
|
|
+ setSelectedProcId(isSameProc ? null : id);
|
|
|
+ setColumnFrozenOrders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ if (isSameProc) delete next.proc;
|
|
|
+ else next.proc = currentOrderIds;
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (type === 'req') {
|
|
|
+ setSelectedReqId(isSameReq ? null : id);
|
|
|
+ setColumnFrozenOrders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ if (isSameReq) delete next.req;
|
|
|
+ else next.req = currentOrderIds;
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (type === 'cap') {
|
|
|
+ setSelectedCapId(isSameCap ? null : id);
|
|
|
+ setColumnFrozenOrders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ if (isSameCap) delete next.cap;
|
|
|
+ else next.cap = currentOrderIds;
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (type === 'tool') {
|
|
|
+ setSelectedToolId(isSameTool ? null : id);
|
|
|
+ setColumnFrozenOrders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ if (isSameTool) delete next.tool;
|
|
|
+ else next.tool = currentOrderIds;
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
if (type === 'req') {
|
|
|
if (isSameReq) {
|
|
|
@@ -3424,25 +3594,6 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
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]);
|
|
|
|
|
|
- // 同步 columnFocusIndex.req 到 selectedReqId 的实际位置
|
|
|
- useEffect(() => {
|
|
|
- if (!selectedReqId) return;
|
|
|
- const displayedReqs = visibleData.reqs;
|
|
|
- const matchedIndices: number[] = [];
|
|
|
- displayedReqs.forEach((req: any, idx: number) => {
|
|
|
- const isSelected = req.id === selectedReqId;
|
|
|
- const isEligible = eligibleReqIds.has(req.id);
|
|
|
- if (isSelected || isEligible) {
|
|
|
- matchedIndices.push(idx);
|
|
|
- }
|
|
|
- });
|
|
|
- const selectedReqIdx = displayedReqs.findIndex((r: any) => r.id === selectedReqId);
|
|
|
- const positionInMatched = matchedIndices.indexOf(selectedReqIdx);
|
|
|
- if (positionInMatched >= 0) {
|
|
|
- setColumnFocusIndex(prev => ({ ...prev, req: positionInMatched }));
|
|
|
- }
|
|
|
- }, [selectedReqId, visibleData.reqs, eligibleReqIds]);
|
|
|
-
|
|
|
const displayedReqs = visibleData.reqs;
|
|
|
const displayedProcs = visibleProcItems;
|
|
|
const displayedCaps = visibleData.caps;
|
|
|
@@ -3611,6 +3762,51 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
|
|
|
只看覆盖需求的数据
|
|
|
</button>
|
|
|
+ {selectedReq && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setSelectedReqId(null)}
|
|
|
+ className="text-xs font-bold px-3 py-1.5 rounded-lg bg-indigo-100 text-indigo-700 hover:bg-indigo-200 transition-colors"
|
|
|
+ >
|
|
|
+ {selectedReq.description?.slice(0, 18) || selectedReq.id} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedProc && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setSelectedProcId(null)}
|
|
|
+ className="text-xs font-bold px-3 py-1.5 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200 transition-colors"
|
|
|
+ >
|
|
|
+ {selectedProc.id} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedCap && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setSelectedCapId(null)}
|
|
|
+ className="text-xs font-bold px-3 py-1.5 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 transition-colors"
|
|
|
+ >
|
|
|
+ {selectedCap.name || selectedCap.id} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedTool && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setSelectedToolId(null)}
|
|
|
+ className="text-xs font-bold px-3 py-1.5 rounded-lg bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
|
|
+ >
|
|
|
+ {selectedTool.name || selectedTool.id} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {selectedItemset && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setSelectedItemsetId(null)}
|
|
|
+ className="text-xs font-bold px-3 py-1.5 rounded-lg bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors"
|
|
|
+ >
|
|
|
+ Pattern #{selectedItemset.id} ×
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
{hasAnyFilter && (
|
|
|
<button
|
|
|
onClick={() => {
|
|
|
@@ -3620,6 +3816,8 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
setSelectedCapId(null);
|
|
|
setSelectedToolId(null);
|
|
|
setSelectedItemsetId(null);
|
|
|
+ setManualFocusedTreeNodeId(null);
|
|
|
+ setColumnFrozenOrders({});
|
|
|
}}
|
|
|
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1 bg-slate-200 hover:bg-slate-300 px-3 py-1.5 rounded-lg transition-colors font-bold"
|
|
|
>
|
|
|
@@ -3644,6 +3842,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<CategoryTree
|
|
|
data={treeData}
|
|
|
onSelect={(node) => {
|
|
|
+ setManualFocusedTreeNodeId(null);
|
|
|
const isSameNode = selectedNode && String(selectedNode.id) === String(node.id);
|
|
|
if (isSameNode) {
|
|
|
setSelectedNode(null);
|
|
|
@@ -3669,7 +3868,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
if (selectedNode) setSelectedNode(null);
|
|
|
if (!selectedNode && selectedReqId) setSelectedReqId(null);
|
|
|
} : undefined}
|
|
|
- sourceNodeIds={selectedReqSourceNodeIds}
|
|
|
+ sourceNodeIds={selectedPatternNodeIds || selectedReqSourceNodeIds}
|
|
|
patternNodeIds={selectedPatternNodeIds}
|
|
|
nodeMetricsMap={nodeMetricsMap}
|
|
|
totalNodeCount={totalNodeCount}
|
|
|
@@ -3677,7 +3876,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
onToggleWideMode={() => setTreeWideMode(m => !m)}
|
|
|
treeFocusIndex={treeFocusIndex}
|
|
|
treeMatchedCount={treeMatchedCount}
|
|
|
- focusedTreeNodeId={focusedTreeNode?.id}
|
|
|
+ focusedTreeNodeId={manualFocusedTreeNodeId ?? focusedTreeNode?.id}
|
|
|
focusTrigger={treeFocusTrigger}
|
|
|
onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
|
|
|
onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
|
|
|
@@ -3689,31 +3888,44 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
selectedItemsetId={selectedItemsetId}
|
|
|
itemsets={allItemsets}
|
|
|
eligibleItemsetIds={relatedItemsetIds}
|
|
|
+ frozenOrder={columnFrozenOrders.pattern}
|
|
|
contextNodeNames={patternContextNodeNames}
|
|
|
patternMatchedNodesMap={patternMatchedNodesMap}
|
|
|
nodeRoleByName={patternRoleByName}
|
|
|
hasAnyFilter={hasAnyFilter}
|
|
|
focusIndex={columnFocusIndex.pattern ?? 0}
|
|
|
- onSelectItemset={(itemsetId) => {
|
|
|
+ focusTrigger={columnFocusTrigger.pattern ?? 0}
|
|
|
+ onSelectItemset={(itemsetId, currentOrderIds) => {
|
|
|
+ setManualFocusedTreeNodeId(null);
|
|
|
setSelectedItemsetId(itemsetId);
|
|
|
if (itemsetId !== null) {
|
|
|
setTreeFocusTrigger(prev => prev + 1);
|
|
|
}
|
|
|
- setColumnFocusIndex(prev => ({ ...prev, pattern: 0 }));
|
|
|
+ setColumnFrozenOrders(prev => {
|
|
|
+ const next = { ...prev };
|
|
|
+ if (itemsetId === null) delete next.pattern;
|
|
|
+ else next.pattern = currentOrderIds;
|
|
|
+ return next;
|
|
|
+ });
|
|
|
if (itemsetId === null) setDrawerItem(null);
|
|
|
}}
|
|
|
onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
|
|
|
- onFocusPrev={() => setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (prev.pattern ?? 0) - 1) }))}
|
|
|
+ onNodeClick={jumpToTreeNodeByName}
|
|
|
+ onFocusPrev={() => {
|
|
|
+ setColumnFocusIndex(prev => ({ ...prev, pattern: Math.max(0, (prev.pattern ?? 0) - 1) }));
|
|
|
+ setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
|
+ }}
|
|
|
onFocusNext={() => {
|
|
|
const matchedCount = Array.from(relatedItemsetIds).length + (selectedItemsetId ? (relatedItemsetIds.has(String(selectedItemsetId)) ? 0 : 1) : 0);
|
|
|
setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
|
|
|
+ setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
|
|
|
}}
|
|
|
/>
|
|
|
|
|
|
{/* 关系列:每列固定宽度 */}
|
|
|
{columns.map(col => {
|
|
|
const activeId = getColumnActiveId(col.t);
|
|
|
- const orderedItems = sortedItems(col.d, col.eligibleIds);
|
|
|
+ const orderedItems = sortedItems(col.d, col.eligibleIds, activeId !== null ? columnFrozenOrders[col.t] : undefined);
|
|
|
|
|
|
// 计算所有可用匹配项的索引(按原始顺序)
|
|
|
const matchedIndices: number[] = [];
|
|
|
@@ -3740,7 +3952,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
<div className="flex items-center gap-1">
|
|
|
<button
|
|
|
type="button"
|
|
|
- onClick={() => setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.max(0, (prev[col.t] ?? 0) - 1) }))}
|
|
|
+ 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"
|
|
|
>
|
|
|
@@ -3751,7 +3966,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
</span>
|
|
|
<button
|
|
|
type="button"
|
|
|
- onClick={() => setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.min(matchedIndices.length - 1, (prev[col.t] ?? 0) + 1) }))}
|
|
|
+ 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"
|
|
|
>
|
|
|
@@ -3764,42 +3982,6 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
- {col.t === 'req' && selectedReq && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedReqId(null)}
|
|
|
- className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-indigo-100 text-indigo-700 hover:bg-indigo-200 transition-colors"
|
|
|
- >
|
|
|
- {selectedReq.description?.slice(0, 18) || selectedReq.id} ×
|
|
|
- </button>
|
|
|
- )}
|
|
|
- {col.t === 'proc' && selectedProc && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedProcId(null)}
|
|
|
- className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-purple-100 text-purple-700 hover:bg-purple-200 transition-colors"
|
|
|
- >
|
|
|
- {selectedProc.id} ×
|
|
|
- </button>
|
|
|
- )}
|
|
|
- {col.t === 'cap' && selectedCap && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedCapId(null)}
|
|
|
- className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-rose-100 text-rose-700 hover:bg-rose-200 transition-colors"
|
|
|
- >
|
|
|
- {selectedCap.name || selectedCap.id} ×
|
|
|
- </button>
|
|
|
- )}
|
|
|
- {col.t === 'tool' && selectedTool && (
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- onClick={() => setSelectedToolId(null)}
|
|
|
- className="mt-2 text-[10px] font-bold px-2 py-1 rounded-md bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
|
|
- >
|
|
|
- {selectedTool.name || selectedTool.id} ×
|
|
|
- </button>
|
|
|
- )}
|
|
|
</div>
|
|
|
<div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
{orderedItems.map((item: any, idx: number) => (
|
|
|
@@ -3817,7 +3999,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
dimmed={!col.eligibleIds?.has(item.id)}
|
|
|
activeId={activeId}
|
|
|
- shouldScrollIntoView={idx === focusedItemIndex}
|
|
|
+ shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
|
|
|
selectedLeafNames={selectedSubtreeLeafNames || undefined}
|
|
|
directMatch={
|
|
|
col.t === 'req' ? relatedReqIds.has(item.id) :
|
|
|
@@ -3826,14 +4008,11 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
col.t === 'tool' ? relatedToolIds.has(item.id) :
|
|
|
false
|
|
|
}
|
|
|
+ relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
|
|
|
showAllSourceTags={col.t === 'req' && !!selectedTreePartition}
|
|
|
- onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
|
|
|
+ onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
|
|
|
onSourceNodeClick={(name) => {
|
|
|
- const node = nameToNodeMap[name];
|
|
|
- if (node) {
|
|
|
- setSelectedNode(node);
|
|
|
- setDrawerItem({ type: 'node', data: node });
|
|
|
- }
|
|
|
+ jumpToTreeNodeByName(name);
|
|
|
}}
|
|
|
/>
|
|
|
))}
|