|
@@ -0,0 +1,1207 @@
|
|
|
|
|
+import { useState, useEffect, useMemo, useRef, Fragment, ReactNode, WheelEvent } from 'react';
|
|
|
|
|
+import { createPortal } from 'react-dom';
|
|
|
|
|
+import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
|
|
+import { cn } from '../../../lib/utils';
|
|
|
|
|
+import { getResource, batchGetPosts } from '../../../services/api';
|
|
|
|
|
+
|
|
|
|
|
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
|
|
|
|
|
+export function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap, selectedReqId }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]>; selectedReqId?: string | null }) {
|
|
|
|
|
+ if (type === 'itemset') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ItemsetPostsDrawer
|
|
|
|
|
+ itemset={data}
|
|
|
|
|
+ onOpenPost={onOpenPost}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (type === 'req') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <RequirementPostsDrawer
|
|
|
|
|
+ requirement={data}
|
|
|
|
|
+ nodePostsMap={nodePostsMap || {}}
|
|
|
|
|
+ onOpenPost={onOpenPost}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const capNameById = new Map((dbData.caps || []).map((cap: any) => [cap.id, cap.name || cap.description || cap.id]));
|
|
|
|
|
+
|
|
|
|
|
+ const renderStructuredValue = (value: any, path: string): ReactNode => {
|
|
|
|
|
+ if (value === null || value === undefined || value === '') {
|
|
|
|
|
+ return <span className="text-slate-400">空</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (Array.isArray(value)) {
|
|
|
|
|
+ if (value.length === 0) return <span className="text-slate-400">[]</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {value.map((item, index) => (
|
|
|
|
|
+ <div key={`${path}-${index}`} className="rounded-lg border border-slate-200 bg-white p-2">
|
|
|
|
|
+ <div className="text-[10px] font-mono text-slate-400 mb-1">[{index}]</div>
|
|
|
|
|
+ {renderStructuredValue(item, `${path}-${index}`)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof value === 'object') {
|
|
|
|
|
+ const entries = Object.entries(value);
|
|
|
|
|
+ if (entries.length === 0) return <span className="text-slate-400">{'{}'}</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {entries.map(([key, nestedValue]) => (
|
|
|
|
|
+ <div key={`${path}-${key}`} className="rounded-lg border border-slate-200 bg-white p-3">
|
|
|
|
|
+ <div className="text-[10px] font-bold text-slate-400 mb-1 break-all">{key}</div>
|
|
|
|
|
+ {renderStructuredValue(nestedValue, `${path}-${key}`)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof value === 'boolean') {
|
|
|
|
|
+ return <span className="text-sm text-slate-700 font-medium">{value ? 'true' : 'false'}</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return <div className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</div>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const normalizeList = (value: any): any[] => {
|
|
|
|
|
+ if (!value) return [];
|
|
|
|
|
+ if (Array.isArray(value)) return value;
|
|
|
|
|
+ if (typeof value === 'object') return Object.values(value);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const parseWorkflowBody = (rawBody: any, dataItem?: any) => {
|
|
|
|
|
+ let parsed: any = {};
|
|
|
|
|
+ let parseError = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ parsed = typeof rawBody === 'string' ? JSON.parse(rawBody || '{}') : (rawBody || {});
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ parseError = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let workflowOutlineRaw: any[] = [];
|
|
|
|
|
+ if (dataItem?.fine_steps && Array.isArray(dataItem.fine_steps) && dataItem.fine_steps.length > 0) {
|
|
|
|
|
+ workflowOutlineRaw = dataItem.fine_steps;
|
|
|
|
|
+ } else if (Array.isArray(parsed)) {
|
|
|
|
|
+ workflowOutlineRaw = parsed;
|
|
|
|
|
+ } else if (parsed && typeof parsed === 'object') {
|
|
|
|
|
+ workflowOutlineRaw =
|
|
|
|
|
+ parsed.workflow
|
|
|
|
|
+ || parsed.phases
|
|
|
|
|
+ || parsed.workflow_outline
|
|
|
|
|
+ || parsed.selected_strategy?.workflow_outline
|
|
|
|
|
+ || parsed.steps
|
|
|
|
|
+ || parsed.process_flow
|
|
|
|
|
+ || parsed.flow
|
|
|
|
|
+ || [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const workflowOutline = normalizeList(workflowOutlineRaw).map((phase: any, index: number) => {
|
|
|
|
|
+ const phaseObj = typeof phase === 'string' ? { name: phase } : (phase || {});
|
|
|
|
|
+ const title = phaseObj.phase || phaseObj.name || phaseObj.title || phaseObj.step || phaseObj.module_label || `步骤 ${index + 1}`;
|
|
|
|
|
+ const description = phaseObj.description || phaseObj.desc || phaseObj.details || '';
|
|
|
|
|
+ const capsRaw = phaseObj.capabilities || phaseObj.capability || phaseObj.capability_ids || [];
|
|
|
|
|
+
|
|
|
|
|
+ const capabilities = normalizeList(capsRaw).map((c: any) => {
|
|
|
|
|
+ if (typeof c === 'string') return { capability_name: c, capability_id: c, is_new: false };
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...c,
|
|
|
|
|
+ capability_name: c.capability_name || c.name,
|
|
|
|
|
+ capability_id: c.capability_id || c.id,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...phaseObj,
|
|
|
|
|
+ __title: title,
|
|
|
|
|
+ __description: description,
|
|
|
|
|
+ capabilities,
|
|
|
|
|
+ __index: index,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { parsed, parseError, workflowOutline };
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const formatFieldLabel = (key: string) =>
|
|
|
|
|
+ key
|
|
|
|
|
+ .replace(/_/g, ' ')
|
|
|
|
|
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
|
|
+ .trim();
|
|
|
|
|
+
|
|
|
|
|
+ const renderCompactFieldValue = (value: any): ReactNode => {
|
|
|
|
|
+ if (value === null || value === undefined || value === '') {
|
|
|
|
|
+ return <span className="text-slate-400">空</span>;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Array.isArray(value)) {
|
|
|
|
|
+ if (value.length === 0) return <span className="text-slate-400">[]</span>;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1.5">
|
|
|
|
|
+ {value.map((item, index) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={`field-${index}-${typeof item === 'object' ? 'obj' : String(item)}`}
|
|
|
|
|
+ className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200"
|
|
|
|
|
+ >
|
|
|
|
|
+ {typeof item === 'object' ? JSON.stringify(item) : String(item)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ if (typeof value === 'object') {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1.5">
|
|
|
|
|
+ {Object.entries(value).map(([nestedKey, nestedValue]) => (
|
|
|
|
|
+ <span key={nestedKey} className="px-2 py-0.5 rounded-full text-[11px] bg-slate-100 text-slate-700 border border-slate-200">
|
|
|
|
|
+ <span className="font-semibold text-slate-500">{formatFieldLabel(nestedKey)}:</span>{' '}
|
|
|
|
|
+ {typeof nestedValue === 'object' ? JSON.stringify(nestedValue) : String(nestedValue)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return <span className="text-sm text-slate-700 whitespace-pre-wrap break-words leading-relaxed">{String(value)}</span>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {/* 主内容 */}
|
|
|
|
|
+ {type === 'req' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-cyan-50 p-4 rounded-xl border border-cyan-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-cyan-500 mb-2">需求描述</div>
|
|
|
|
|
+ <p className="text-cyan-800 text-sm leading-relaxed whitespace-pre-wrap">{data.description}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">追踪 ID</div>
|
|
|
|
|
+ <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {type === 'cap' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-amber-600 mb-2">能力定义</div>
|
|
|
|
|
+ <p className="text-amber-800 text-sm leading-relaxed">{data.description || '暂无描述'}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">能力 ID</div>
|
|
|
|
|
+ <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {type === 'tool' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-emerald-50 p-4 rounded-xl border border-emerald-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-emerald-600 mb-2">工具介绍</div>
|
|
|
|
|
+ <p className="text-emerald-800 text-sm leading-relaxed">{data.introduction || '暂无介绍'}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {data.status && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex items-center justify-between">
|
|
|
|
|
+ <span className="text-[10px] text-slate-400">接入状态</span>
|
|
|
|
|
+ <span className={cn("text-xs font-bold px-2 py-1 rounded-full",
|
|
|
|
|
+ (data.status === '已接入' || data.status === '正常') ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-500'
|
|
|
|
|
+ )}>{data.status}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">执行端 ID</div>
|
|
|
|
|
+ <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {type === 'proc' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-green-50 p-4 rounded-xl border border-green-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-green-600 mb-2">工序说明</div>
|
|
|
|
|
+ <p className="text-green-900 text-sm leading-relaxed whitespace-pre-wrap">{data.description || '暂无说明'}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {data.resource_ids?.length > 0 && (
|
|
|
|
|
+ <StrategyResourcesGrid resourceIds={data.resource_ids} onOpenPost={onOpenPost} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">工序 ID</div>
|
|
|
|
|
+ <div className="font-mono text-slate-700 text-[11px] break-all">{data.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {data.category && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">所属品类</div>
|
|
|
|
|
+ <div className="text-sm text-slate-800 font-semibold">{data.category}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {data.path_labels?.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">工序链路</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ {data.path_labels.map((label: string, idx: number) => (
|
|
|
|
|
+ <span key={`${data.id}-step-${idx}`} className="text-[11px] px-2.5 py-1 rounded-lg bg-white text-green-700 font-semibold border border-green-200 shadow-sm">
|
|
|
|
|
+ {data.path[idx]} · {label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const { parsed, parseError, workflowOutline } = parseWorkflowBody(data.body, data);
|
|
|
|
|
+
|
|
|
|
|
+ const source = parsed.selected_strategy?.source || parsed.source;
|
|
|
|
|
+ const excludedBodyKeys = new Set([
|
|
|
|
|
+ 'workflow',
|
|
|
|
|
+ 'process_flow',
|
|
|
|
|
+ 'flow',
|
|
|
|
|
+ 'phases',
|
|
|
|
|
+ 'workflow_outline',
|
|
|
|
|
+ 'steps',
|
|
|
|
|
+ 'selected_strategy',
|
|
|
|
|
+ 'source',
|
|
|
|
|
+ 'reasoning',
|
|
|
|
|
+ 'highlight_coverage',
|
|
|
|
|
+ 'baseline_coverage',
|
|
|
|
|
+ 'vs_alternatives',
|
|
|
|
|
+ 'uncovered_requirements',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ const globalExtraFields = Object.entries(parsed || {}).filter(([key]) => !excludedBodyKeys.has(key));
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {source && (
|
|
|
|
|
+ <div className="bg-blue-50 p-3 rounded-xl border border-blue-100">
|
|
|
|
|
+ <div className="text-[10px] text-blue-400 mb-1">灵感来源</div>
|
|
|
|
|
+ <p className="text-sm text-blue-800 leading-relaxed">{source}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {workflowOutline.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400">工序流程</div>
|
|
|
|
|
+ <div className="text-[10px] font-bold text-green-600">{workflowOutline.length} 步</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {workflowOutline.map((phase: any, index: number) => (
|
|
|
|
|
+ <div key={`${data.id}-phase-${index}`} className="rounded-lg border border-slate-200 bg-white p-3">
|
|
|
|
|
+ <div className="flex items-start gap-3">
|
|
|
|
|
+ <div className="shrink-0 w-6 h-6 rounded-full bg-green-100 text-green-700 text-[11px] font-black flex items-center justify-center">
|
|
|
|
|
+ {index + 1}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="min-w-0 flex-1">
|
|
|
|
|
+ <div className="text-sm font-bold text-slate-800 leading-snug mb-1">
|
|
|
|
|
+ {phase.__title}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {phase.__description && (
|
|
|
|
|
+ <p className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap">{phase.__description}</p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {phase.capabilities && phase.capabilities.length > 0 && (
|
|
|
|
|
+ <div className="mt-3 pl-9 space-y-2">
|
|
|
|
|
+ {phase.capabilities.map((cap: any, capIdx: number) => (
|
|
|
|
|
+ <div key={`${data.id}-phase-${index}-cap-${capIdx}`} className="bg-slate-50 p-2 rounded border border-slate-200">
|
|
|
|
|
+ <div className="flex items-start gap-2">
|
|
|
|
|
+ <span className={cn(
|
|
|
|
|
+ "text-[10px] px-1.5 py-0.5 rounded font-bold",
|
|
|
|
|
+ cap.is_new ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
|
|
|
|
|
+ )}>
|
|
|
|
|
+ {cap.is_new ? "新能力" : "关联能力"}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <div className="flex-1">
|
|
|
|
|
+ <div className="text-xs font-medium text-slate-800">
|
|
|
|
|
+ {cap.capability_id && cap.capability_id !== cap.capability_name && (
|
|
|
|
|
+ <span className="font-mono text-amber-600 mr-1">{cap.capability_id}</span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {cap.capability_name || cap.capability_id || '未命名能力'}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {cap.suggested_tools && cap.suggested_tools.length > 0 && (
|
|
|
|
|
+ <div className="mt-1 flex flex-wrap gap-1">
|
|
|
|
|
+ {cap.suggested_tools.map((tool: string, toolIdx: number) => (
|
|
|
|
|
+ <span key={`tool-${toolIdx}`} className="text-[10px] px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
|
|
|
|
|
+ {tool}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {cap.case_references && cap.case_references.length > 0 && (
|
|
|
|
|
+ <div className="mt-1 text-[10px] text-slate-500">
|
|
|
|
|
+ <span className="font-medium">案例引用:</span>
|
|
|
|
|
+ {cap.case_references.join(' · ')}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!parseError && globalExtraFields.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">流程补充信息</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ {globalExtraFields.map(([fieldKey, fieldValue]) => (
|
|
|
|
|
+ <div key={`${data.id}-body-field-${fieldKey}`} className="rounded-lg border border-slate-200 bg-white px-3 py-2 min-w-[180px] max-w-full">
|
|
|
|
|
+ <div className="text-[10px] font-bold text-slate-400 mb-1">{formatFieldLabel(fieldKey)}</div>
|
|
|
|
|
+ {renderCompactFieldValue(fieldValue)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {parseError && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">原始 Body</div>
|
|
|
|
|
+ <pre className="text-[12px] text-slate-700 whitespace-pre-wrap break-words leading-relaxed bg-white rounded-lg border border-slate-200 p-3">
|
|
|
|
|
+ {typeof data.body === 'string'
|
|
|
|
|
+ ? (data.body || '无 body')
|
|
|
|
|
+ : JSON.stringify(data.body ?? {}, null, 2)}
|
|
|
|
|
+ </pre>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ {data.requirement_texts?.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">绑定需求</div>
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {data.requirement_ids.map((reqId: string, idx: number) => (
|
|
|
|
|
+ <div key={`${data.id}-req-${reqId}`} className="text-sm text-slate-700">
|
|
|
|
|
+ <span className="font-mono text-[11px] text-cyan-600 mr-2">{reqId}</span>
|
|
|
|
|
+ <span>{data.requirement_texts[idx]}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const { parsed } = parseWorkflowBody(data.body, data);
|
|
|
|
|
+
|
|
|
|
|
+ const rationale = data.rationale || parsed.selected_strategy?.reasoning || parsed.reasoning;
|
|
|
|
|
+ const highlightCoverage = parsed.selected_strategy?.highlight_coverage || parsed.highlight_coverage || [];
|
|
|
|
|
+ const baselineCoverage = parsed.selected_strategy?.baseline_coverage || parsed.baseline_coverage || [];
|
|
|
|
|
+ const vsAlternatives = parsed.vs_alternatives || [];
|
|
|
|
|
+ const uncoveredRequirements = parsed.uncovered_requirements || [];
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {rationale && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">因果说明 / 决策推理</div>
|
|
|
|
|
+ <p className="text-sm text-slate-700 leading-relaxed">{rationale}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {highlightCoverage.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">高光覆盖点表现在</div>
|
|
|
|
|
+ <ul className="list-disc list-inside space-y-1">
|
|
|
|
|
+ {highlightCoverage.map((item: string, idx: number) => (
|
|
|
|
|
+ <li key={`hc-${idx}`} className="text-sm text-slate-700">{item}</li>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {baselineCoverage.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">基础要求满足</div>
|
|
|
|
|
+ <ul className="list-disc list-inside space-y-1">
|
|
|
|
|
+ {baselineCoverage.map((item: string, idx: number) => (
|
|
|
|
|
+ <li key={`bc-${idx}`} className="text-sm text-slate-700">{item}</li>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {vsAlternatives.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">备选方案比对</div>
|
|
|
|
|
+ <div className="space-y-2">
|
|
|
|
|
+ {vsAlternatives.map((alt: any, idx: number) => (
|
|
|
|
|
+ <div key={`alt-${idx}`} className="bg-white p-2 border border-slate-200 rounded-lg text-sm">
|
|
|
|
|
+ <div className="font-bold text-slate-800">{alt.alternative}</div>
|
|
|
|
|
+ <div className="text-slate-600 mt-1"><span className="text-rose-500 font-medium">未能选中原因:</span>{alt.why_not}</div>
|
|
|
|
|
+ {alt.could_switch_if && (
|
|
|
|
|
+ <div className="text-slate-500 mt-1 text-xs"><span className="text-sky-500 font-medium">触发何种条件才切换:</span>{alt.could_switch_if}</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {uncoveredRequirements.length > 0 && (
|
|
|
|
|
+ <div className="bg-rose-50 p-3 rounded-xl border border-rose-100">
|
|
|
|
|
+ <div className="text-[10px] text-rose-400 mb-2">未覆盖的需求风险</div>
|
|
|
|
|
+ <ul className="list-disc list-inside space-y-1">
|
|
|
|
|
+ {uncoveredRequirements.map((item: string, idx: number) => (
|
|
|
|
|
+ <li key={`ur-${idx}`} className="text-sm text-rose-700">{item}</li>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
|
|
+ {data.source_workflows?.length > 0 && (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">来源工作流</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ {data.source_workflows.map((wfId: string) => (
|
|
|
|
|
+ <span key={`${data.id}-${wfId}`} className="text-[11px] px-2 py-1 rounded-md bg-slate-200 text-slate-700 font-medium">
|
|
|
|
|
+ {wfId}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {type === 'know' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="bg-violet-50 p-4 rounded-xl border border-violet-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-violet-600 mb-2">知识正文</div>
|
|
|
|
|
+ <p className="text-violet-800 text-sm leading-relaxed whitespace-pre-wrap">{data.content}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">知识库 ID</div>
|
|
|
|
|
+ <div className="font-mono text-slate-600 text-[11px] break-all">{data.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function PostDetailModal({ post, postId, onClose }: { post: any; postId: string; onClose: () => void }) {
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ const onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
+ if (e.key === 'Escape') onClose();
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener('keydown', onKeyDown);
|
|
|
|
|
+ return () => window.removeEventListener('keydown', onKeyDown);
|
|
|
|
|
+ }, [onClose]);
|
|
|
|
|
+
|
|
|
|
|
+ const images: string[] = post?.images || [];
|
|
|
|
|
+
|
|
|
|
|
+ return createPortal(
|
|
|
|
|
+ <div className="fixed inset-0 z-[260] flex items-center justify-center p-6">
|
|
|
|
|
+ <button className="absolute inset-0 bg-slate-900/45 backdrop-blur-[1px]" onClick={onClose} aria-label="关闭帖子详情" />
|
|
|
|
|
+ <div className="relative z-[261] w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl flex flex-col">
|
|
|
|
|
+ <div className="flex items-start justify-between gap-4 px-6 py-4 border-b border-slate-100 shrink-0">
|
|
|
|
|
+ <div className="min-w-0">
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-400 mb-1">帖子详情</div>
|
|
|
|
|
+ <div className="text-base font-bold text-slate-800 leading-snug">{post?.title || '无标题'}</div>
|
|
|
|
|
+ <div className="text-[11px] text-slate-400 mt-1 break-all">{postId}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
|
|
|
|
+ <X size={16} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="overflow-y-auto px-6 py-5 space-y-5">
|
|
|
|
|
+ {(post?.platform || post?.platform_account_name || post?.publish_date) && (
|
|
|
|
|
+ <div className="flex flex-wrap gap-2">
|
|
|
|
|
+ {post?.platform && <span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-600 font-medium">{post.platform}</span>}
|
|
|
|
|
+ {post?.platform_account_name && <span className="text-xs px-2 py-1 rounded-full bg-indigo-50 text-indigo-700 font-medium">{post.platform_account_name}</span>}
|
|
|
|
|
+ {post?.publish_date && <span className="text-xs px-2 py-1 rounded-full bg-emerald-50 text-emerald-700 font-medium">{post.publish_date}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {images.length > 0 && (
|
|
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
|
|
|
+ {images.map((url: string, i: number) => (
|
|
|
|
|
+ <div key={i} className="rounded-2xl overflow-hidden bg-slate-100 border border-slate-100">
|
|
|
|
|
+ <img src={url} alt="" className="w-full h-full object-cover" loading="lazy" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {post?.body_text && (
|
|
|
|
|
+ <div className="bg-slate-50 rounded-2xl border border-slate-100 p-4">
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-500 mb-2">正文</div>
|
|
|
|
|
+ <p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{post.body_text}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {post?.decode_result && (
|
|
|
|
|
+ <div className="bg-indigo-50 rounded-2xl border border-indigo-100 p-4 space-y-3">
|
|
|
|
|
+ <div className="text-xs font-bold text-indigo-600">解析信息</div>
|
|
|
|
|
+ {Object.entries(post.decode_result).map(([key, value]) => (
|
|
|
|
|
+ value ? (
|
|
|
|
|
+ <div key={key}>
|
|
|
|
|
+ <div className="text-[11px] font-bold text-indigo-400 uppercase tracking-wide">{key}</div>
|
|
|
|
|
+ <div className="text-sm text-indigo-900 whitespace-pre-wrap leading-relaxed">{String(value)}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>,
|
|
|
|
|
+ document.body
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function PostCard({ postId, post, loading, onClick, compact = false }: { postId: string; post?: any; loading?: boolean; onClick?: () => void; compact?: boolean }) {
|
|
|
|
|
+ const images: string[] = post?.images || [];
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={post ? onClick : undefined}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ compact ? "w-full" : "w-[220px] shrink-0",
|
|
|
|
|
+ "bg-white rounded-xl border border-slate-100 overflow-hidden shadow-sm flex flex-col text-left",
|
|
|
|
|
+ post ? "cursor-pointer hover:border-indigo-200 hover:shadow-md transition-all" : "cursor-default"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ {post ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {images.length > 0 && (
|
|
|
|
|
+ <div className={cn("shrink-0", compact ? "grid grid-cols-2 gap-0.5" : "grid grid-cols-3 gap-0.5")}>
|
|
|
|
|
+ {images.slice(0, compact ? 2 : 3).map((url: string, i: number) => (
|
|
|
|
|
+ <div key={i} className={cn("relative overflow-hidden bg-slate-100", compact ? "aspect-[4/3]" : "aspect-square")}>
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={url}
|
|
|
|
|
+ alt=""
|
|
|
|
|
+ className="w-full h-full object-cover"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ onError={(e) => { (e.target as HTMLImageElement).parentElement!.style.display = 'none'; }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div className={cn("shrink-0", compact ? "px-3 pt-3 pb-1.5" : "px-2.5 pt-2 pb-1")}>
|
|
|
|
|
+ <div className={cn("font-bold text-slate-800 leading-snug", compact ? "text-xs line-clamp-2" : "text-[11px] line-clamp-2")}>{post.title || '无标题'}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {post.body_text && (
|
|
|
|
|
+ <div className={cn("flex-1 overflow-hidden", compact ? "px-3 pb-3" : "px-2.5 pb-2")}>
|
|
|
|
|
+ <p className={cn("text-slate-400 leading-relaxed whitespace-pre-wrap", compact ? "text-[11px] line-clamp-3" : "text-[10px] line-clamp-4")}>{post.body_text}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : !loading ? (
|
|
|
|
|
+ <div className={cn("font-mono text-slate-300 break-all", compact ? "p-4 text-[11px]" : "p-3 text-[10px]")}>{postId}</div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className={cn("h-full bg-slate-50 animate-pulse", compact ? "min-h-[220px]" : "min-h-[160px]")}></div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function HorizontalPostScroller({ children, className = '' }: { children: ReactNode; className?: string }) {
|
|
|
|
|
+ const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
|
|
|
+ const [canScrollRight, setCanScrollRight] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const updateScrollState = () => {
|
|
|
|
|
+ const el = scrollRef.current;
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ const maxScrollLeft = el.scrollWidth - el.clientWidth;
|
|
|
|
|
+ setCanScrollLeft(el.scrollLeft > 4);
|
|
|
|
|
+ setCanScrollRight(el.scrollLeft < maxScrollLeft - 4);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ updateScrollState();
|
|
|
|
|
+ const el = scrollRef.current;
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ const onResize = () => updateScrollState();
|
|
|
|
|
+ window.addEventListener('resize', onResize);
|
|
|
|
|
+ return () => window.removeEventListener('resize', onResize);
|
|
|
|
|
+ }, [children]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
|
|
|
|
|
+ const el = scrollRef.current;
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+
|
|
|
|
|
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
|
|
|
+ if (delta === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ const canScroll = el.scrollWidth > el.clientWidth;
|
|
|
|
|
+ if (!canScroll) return;
|
|
|
|
|
+
|
|
|
|
|
+ const maxScrollLeft = el.scrollWidth - el.clientWidth;
|
|
|
|
|
+ const nextLeft = el.scrollLeft + delta;
|
|
|
|
|
+ const willScrollWithinBounds = nextLeft > 0 && nextLeft < maxScrollLeft;
|
|
|
|
|
+
|
|
|
|
|
+ if (willScrollWithinBounds || (delta < 0 && el.scrollLeft > 0) || (delta > 0 && el.scrollLeft < maxScrollLeft)) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ el.scrollLeft += delta;
|
|
|
|
|
+ window.requestAnimationFrame(updateScrollState);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const scrollByPage = (direction: -1 | 1) => {
|
|
|
|
|
+ const el = scrollRef.current;
|
|
|
|
|
+ if (!el) return;
|
|
|
|
|
+ el.scrollBy({ left: direction * Math.max(el.clientWidth * 0.8, 240), behavior: 'smooth' });
|
|
|
|
|
+ window.setTimeout(updateScrollState, 250);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className={cn("relative min-w-0 max-w-full overflow-hidden", className)}>
|
|
|
|
|
+ {canScrollLeft && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => scrollByPage(-1)}
|
|
|
|
|
+ className="absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
|
|
|
|
+ aria-label="向左滚动帖子"
|
|
|
|
|
+ >
|
|
|
|
|
+ <ChevronLeft size={16} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {canScrollRight && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => scrollByPage(1)}
|
|
|
|
|
+ className="absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-full bg-white/95 border border-slate-200 shadow-md p-1.5 text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
|
|
|
|
+ aria-label="向右滚动帖子"
|
|
|
|
|
+ >
|
|
|
|
|
+ <ChevronRight size={16} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={scrollRef}
|
|
|
|
|
+ onWheel={handleWheel}
|
|
|
|
|
+ onScroll={updateScrollState}
|
|
|
|
|
+ className="w-full overflow-x-auto overflow-y-hidden scrollbar-thin"
|
|
|
|
|
+ >
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── 工序与策略来源资源聚合抽屉 ───────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+export function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: string[], onOpenPost: (postId: string, post: any) => void }) {
|
|
|
|
|
+ const [posts, setPosts] = useState<Record<string, any>>({});
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!resourceIds || resourceIds.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ let isMounted = true;
|
|
|
|
|
+
|
|
|
|
|
+ async function loadResources() {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ const map: Record<string, any> = {};
|
|
|
|
|
+
|
|
|
|
|
+ for (const rid of resourceIds) {
|
|
|
|
|
+ if (!isMounted) break;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getResource(encodeURIComponent(rid));
|
|
|
|
|
+ if (res) {
|
|
|
|
|
+ map[rid] = {
|
|
|
|
|
+ title: res.title,
|
|
|
|
|
+ body_text: res.body,
|
|
|
|
|
+ images: res.images || [],
|
|
|
|
|
+ platform: res.metadata?.platform,
|
|
|
|
|
+ publish_date: res.metadata?.last_seen || res.metadata?.acquired_at,
|
|
|
|
|
+ decode_result: {
|
|
|
|
|
+ '原链接': res.metadata?.source_url,
|
|
|
|
|
+ '本地 Case': res.metadata?.local_case_id,
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ // 每次加载完立刻更新 UI,提供渐进式呈现
|
|
|
|
|
+ setPosts({ ...map });
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error(`Failed to fetch resource ${rid}`, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (isMounted) {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ loadResources();
|
|
|
|
|
+
|
|
|
|
|
+ return () => { isMounted = false; };
|
|
|
|
|
+ }, [resourceIds]);
|
|
|
|
|
+
|
|
|
|
|
+ if (!resourceIds || resourceIds.length === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-2">参考资料 ({resourceIds.length})</div>
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-2 mt-2">
|
|
|
|
|
+ {loading && (
|
|
|
|
|
+ <div className="flex items-center gap-2 text-xs text-slate-400 py-2">
|
|
|
|
|
+ <div className="w-3 h-3 border-2 border-purple-200 border-t-purple-500 rounded-full animate-spin"></div>
|
|
|
|
|
+ 正在加载参考资料...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && resourceIds.map(pid => {
|
|
|
|
|
+ const post = posts[pid];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={pid} className="w-full bg-white rounded-lg border border-slate-200 overflow-hidden relative group">
|
|
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+export function ItemsetPostsDrawer({
|
|
|
|
|
+ itemset,
|
|
|
|
|
+ onOpenPost,
|
|
|
|
|
+}: {
|
|
|
|
|
+ itemset: any;
|
|
|
|
|
+ onOpenPost: (postId: string, post: any) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const [posts, setPosts] = useState<Record<string, any>>({});
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const postIds = itemset.post_ids || [];
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (postIds.length === 0) return;
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setPosts({});
|
|
|
|
|
+ batchGetPosts(postIds)
|
|
|
|
|
+ .then(map => setPosts(map))
|
|
|
|
|
+ .catch(err => { console.error('Failed to load itemset posts:', err); })
|
|
|
|
|
+ .finally(() => setLoading(false));
|
|
|
|
|
+ }, [itemset.id, postIds]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
|
|
|
|
|
+ <div className="shrink-0 bg-blue-50 p-4 rounded-xl border border-blue-100">
|
|
|
|
|
+ <div className="text-xs font-bold text-blue-600 mb-2">Pattern 节点组合</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-1.5">
|
|
|
|
|
+ {(itemset.leaf_names || []).map((name: string) => (
|
|
|
|
|
+ <span key={name} className="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200 font-medium">{name}</span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="mt-3 grid grid-cols-2 gap-2">
|
|
|
|
|
+ <div className="rounded-lg border border-blue-200 bg-white/70 px-3 py-2">
|
|
|
|
|
+ <div className="text-[10px] font-bold text-blue-500 mb-1">Pattern ID</div>
|
|
|
|
|
+ <div className="font-mono text-[11px] text-blue-800">#{itemset.id}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="rounded-lg border border-blue-200 bg-white/70 px-3 py-2">
|
|
|
|
|
+ <div className="text-[10px] font-bold text-blue-500 mb-1">支持度</div>
|
|
|
|
|
+ <div className="text-[11px] font-bold text-blue-800">{itemset.absolute_support}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-500 mb-2">关联帖子 ({postIds.length})</div>
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-3">
|
|
|
|
|
+ {loading && (
|
|
|
|
|
+ <div className="flex items-center justify-center gap-2 text-slate-400 rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
|
|
|
|
|
+ <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
|
|
+ 加载中...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && postIds.length === 0 && (
|
|
|
|
|
+ <div className="flex items-center justify-center text-xs text-slate-300 font-bold rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
|
|
|
|
|
+ 该 Pattern 暂无关联帖子
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && postIds.map((pid: string) => {
|
|
|
|
|
+ const post = posts[pid];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={pid} className="w-full">
|
|
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function RequirementPostsDrawer({
|
|
|
|
|
+ requirement,
|
|
|
|
|
+ nodePostsMap,
|
|
|
|
|
+ onOpenPost,
|
|
|
|
|
+}: {
|
|
|
|
|
+ requirement: any;
|
|
|
|
|
+ nodePostsMap: Record<string, string[]>;
|
|
|
|
|
+ onOpenPost: (postId: string, post: any) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
|
|
|
|
|
+ const [posts, setPosts] = useState<Record<string, any>>({});
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const nodeNames = Object.keys(nodePostsMap);
|
|
|
|
|
+ const allPostIds = useMemo(() => {
|
|
|
|
|
+ const ids: string[] = [];
|
|
|
|
|
+ Object.values(nodePostsMap).forEach(pids => pids.forEach(pid => { if (!ids.includes(pid)) ids.push(pid); }));
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ }, [nodePostsMap]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (allPostIds.length === 0) return;
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setPosts({});
|
|
|
|
|
+ batchGetPosts(allPostIds)
|
|
|
|
|
+ .then(map => setPosts(map))
|
|
|
|
|
+ .catch(err => { console.error('Failed to load requirement posts:', err); })
|
|
|
|
|
+ .finally(() => setLoading(false));
|
|
|
|
|
+ }, [allPostIds, requirement.id]);
|
|
|
|
|
+
|
|
|
|
|
+ const displayPostIds = selectedNodeName ? (nodePostsMap[selectedNodeName] || []) : allPostIds;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
|
|
|
|
|
+ <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
|
|
|
|
|
+ <div className="flex flex-wrap gap-2 max-h-[140px] overflow-y-auto">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setSelectedNodeName(null)}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors",
|
|
|
|
|
+ selectedNodeName === null ? "bg-indigo-100 text-indigo-700 font-bold" : "bg-white hover:bg-slate-100 text-slate-600 border border-slate-200"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ 全部 ({allPostIds.length})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {nodeNames.map(name => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={name}
|
|
|
|
|
+ onClick={() => setSelectedNodeName(name)}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors truncate max-w-full",
|
|
|
|
|
+ selectedNodeName === name ? "bg-indigo-100 text-indigo-700 font-bold" : "bg-white hover:bg-slate-100 text-slate-600 border border-slate-200"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ {name === '__unmatched__' ? '未定位帖子' : name} ({nodePostsMap[name].length})
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-3">
|
|
|
|
|
+ {loading && (
|
|
|
|
|
+ <div className="flex items-center justify-center flex-1 gap-2 text-slate-400">
|
|
|
|
|
+ <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
|
|
+ 加载中...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && displayPostIds.length === 0 && (
|
|
|
|
|
+ <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">暂无帖子</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && displayPostIds.map(pid => {
|
|
|
|
|
+ const post = posts[pid];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={pid} className="w-full">
|
|
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+export function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId: string, post: any) => void }) {
|
|
|
|
|
+ const [posts, setPosts] = useState<Record<string, any>>({});
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const postIds: string[] = useMemo(() => {
|
|
|
|
|
+ const ids: string[] = [];
|
|
|
|
|
+ const walk = (current: any) => {
|
|
|
|
|
+ (current.elements || []).forEach((el: any) => {
|
|
|
|
|
+ (el.post_ids || []).forEach((pid: string) => {
|
|
|
|
|
+ if (!ids.includes(pid)) ids.push(pid);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ (current.children || []).forEach((child: any) => walk(child));
|
|
|
|
|
+ };
|
|
|
|
|
+ walk(node);
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ }, [node]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (postIds.length === 0) return;
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setPosts({});
|
|
|
|
|
+ batchGetPosts(postIds)
|
|
|
|
|
+ .then(map => setPosts(map))
|
|
|
|
|
+ .catch(err => {
|
|
|
|
|
+ console.error('Failed to load leaf node posts:', err);
|
|
|
|
|
+ })
|
|
|
|
|
+ .finally(() => setLoading(false));
|
|
|
|
|
+ }, [node.name, postIds]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
|
|
|
|
|
+ <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
|
|
|
|
|
+ <div className="text-xs font-bold text-slate-600 mb-2">节点概览</div>
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-3">
|
|
|
|
|
+ <div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">帖子总数</div>
|
|
|
|
|
+ <div className="text-xl font-black text-slate-800">{node.total_posts_count || 0}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
|
|
|
+ <div className="text-[10px] text-slate-400 mb-1">去重帖子</div>
|
|
|
|
|
+ <div className="text-xl font-black text-slate-800">{postIds.length}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex-1 min-h-0 overflow-y-auto pr-1">
|
|
|
|
|
+ <div className="grid grid-cols-1 gap-3">
|
|
|
|
|
+ {loading && (
|
|
|
|
|
+ <div className="flex items-center justify-center gap-2 text-slate-400 rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
|
|
|
|
|
+ <div className="w-4 h-4 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
|
|
+ 加载中...
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && postIds.length === 0 && (
|
|
|
|
|
+ <div className="flex items-center justify-center text-xs text-slate-300 font-bold rounded-xl border border-slate-100 bg-slate-50 min-h-[160px]">
|
|
|
|
|
+ 该节点暂无帖子
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!loading && postIds.map(pid => {
|
|
|
|
|
+ const post = posts[pid];
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={pid} className="w-full">
|
|
|
|
|
+ <PostCard postId={pid} post={post} compact onClick={() => onOpenPost(pid, post)} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── 频繁模式列 ────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+function PatternColumn({
|
|
|
|
|
+ selectedItemsetId,
|
|
|
|
|
+ itemsets,
|
|
|
|
|
+ eligibleItemsetIds,
|
|
|
|
|
+ metricsMap,
|
|
|
|
|
+ frozenOrder,
|
|
|
|
|
+ contextNodeNames,
|
|
|
|
|
+ patternMatchedNodesMap,
|
|
|
|
|
+ nodeRoleByName,
|
|
|
|
|
+ hasAnyFilter,
|
|
|
|
|
+ focusIndex,
|
|
|
|
|
+ focusTrigger,
|
|
|
|
|
+ onSelectItemset,
|
|
|
|
|
+ onOpenDrawer,
|
|
|
|
|
+ onNodeClick,
|
|
|
|
|
+ onFocusPrev,
|
|
|
|
|
+ onFocusNext,
|
|
|
|
|
+}: {
|
|
|
|
|
+ selectedItemsetId: number | null;
|
|
|
|
|
+ itemsets: any[];
|
|
|
|
|
+ eligibleItemsetIds: Set<string>;
|
|
|
|
|
+ metricsMap: Record<string, { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number }>;
|
|
|
|
|
+ frozenOrder?: string[];
|
|
|
|
|
+ contextNodeNames: Set<string>;
|
|
|
|
|
+ patternMatchedNodesMap?: Record<string, Set<string>>;
|
|
|
|
|
+ nodeRoleByName: Record<string, 'substance' | 'form' | 'both'>;
|
|
|
|
|
+ hasAnyFilter: boolean;
|
|
|
|
|
+ focusIndex: number;
|
|
|
|
|
+ focusTrigger: number;
|
|
|
|
|
+ onSelectItemset: (itemsetId: number | null, currentOrderIds: string[]) => void;
|
|
|
|
|
+ onOpenDrawer: (itemset: any) => void;
|
|
|
|
|
+ onNodeClick?: (nodeName: string) => void;
|
|
|
|
|
+ onFocusPrev: () => void;
|
|
|
|
|
+ onFocusNext: () => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
|
|
|
|
+ const displayItemsets = useMemo(() => {
|
|
|
|
|
+ const withMatches = [...itemsets].map(itemset => {
|
|
|
|
|
+ const leafNames: string[] = itemset.leaf_names || [];
|
|
|
|
|
+ let matchedNodes: string[] = [];
|
|
|
|
|
+ if (patternMatchedNodesMap && patternMatchedNodesMap[String(itemset.id)]) {
|
|
|
|
|
+ const specificMatches = patternMatchedNodesMap[String(itemset.id)];
|
|
|
|
|
+ matchedNodes = leafNames.filter(n => specificMatches.has(n));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ matchedNodes = contextNodeNames.size > 0 ? leafNames.filter(n => contextNodeNames.has(n)) : [];
|
|
|
|
|
+ }
|
|
|
|
|
+ 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;
|
|
|
|
|
+ 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]);
|
|
|
|
|
+
|
|
|
|
|
+ const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
|
|
|
|
|
+ const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (focusTrigger > 0 && focusedItemIndex >= 0) {
|
|
|
|
|
+ itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [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-blue-500">
|
|
|
|
|
+ <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>Pattern</span>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
|
|
+ {displayItemsets.map((itemset, idx) => {
|
|
|
|
|
+ const isSelected = selectedItemsetId === itemset.id;
|
|
|
|
|
+ const isEligible = eligibleItemsetIds.has(String(itemset.id));
|
|
|
|
|
+ const metrics = metricsMap[String(itemset.id)] || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
|
|
|
|
|
+ const metricItems = [
|
|
|
|
|
+ { key: 'node', count: metrics.nodeCount, className: 'bg-slate-500 text-white' },
|
|
|
|
|
+ { key: 'pattern', count: 0, className: DASHBOARD_COLUMN_THEME.pattern.metric },
|
|
|
|
|
+ { key: 'req', count: metrics.reqCount, className: DASHBOARD_COLUMN_THEME.req.metric },
|
|
|
|
|
+ { key: 'proc', count: metrics.procCount, className: DASHBOARD_COLUMN_THEME.proc.metric },
|
|
|
|
|
+ { key: 'cap', count: metrics.capCount, className: DASHBOARD_COLUMN_THEME.cap.metric },
|
|
|
|
|
+ { key: 'tool', count: metrics.toolCount, className: DASHBOARD_COLUMN_THEME.tool.metric },
|
|
|
|
|
+ ];
|
|
|
|
|
+ const metricDots = (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {metricItems.map((metric) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={`${itemset.id}-${metric.key}`}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-black flex items-center justify-center",
|
|
|
|
|
+ metric.count > 0 ? metric.className : "opacity-0"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ {metric.count > 0 ? metric.count : '0'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ key={itemset.id}
|
|
|
|
|
+ ref={(el) => {
|
|
|
|
|
+ itemRefs.current[idx] = el;
|
|
|
|
|
+ }}
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
|
|
+ const next = isSelected ? null : itemset.id;
|
|
|
|
|
+ onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
|
|
|
|
|
+ if (next !== null) onOpenDrawer(itemset);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4 border-l-blue-400",
|
|
|
|
|
+ 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"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-start gap-2">
|
|
|
|
|
+ <div className="min-w-0 flex-1">
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mt-1.5">
|
|
|
|
|
+ {(itemset.leaf_names || []).map((name: string) => {
|
|
|
|
|
+ const isMatched = itemset.matched_nodes.includes(name);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={name}
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ if (hasAnyFilter && !isEligible && !isSelected) return;
|
|
|
|
|
+ if (!onNodeClick) return;
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ onNodeClick(name);
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={cn(
|
|
|
|
|
+ "text-xs px-2 py-0.5 rounded-md font-medium truncate max-w-[120px] border",
|
|
|
|
|
+ (isSelected || isEligible) && isMatched
|
|
|
|
|
+ ? "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",
|
|
|
|
|
+ onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-blue-300"
|
|
|
|
|
+ )}
|
|
|
|
|
+ >
|
|
|
|
|
+ {name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </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>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|