|
|
@@ -1,83 +1,656 @@
|
|
|
-import { useState, useEffect } from 'react';
|
|
|
-import { FolderTree, Target, Wrench, ChevronRight, Brain, FileText } from 'lucide-react';
|
|
|
+import { useState, useEffect, useMemo, useRef, type ReactNode, type WheelEvent } from 'react';
|
|
|
+import { createPortal } from 'react-dom';
|
|
|
+import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
|
import { CategoryTree } from '../components/dashboard/CategoryTree';
|
|
|
+import { SideDrawer } from '../components/common/SideDrawer';
|
|
|
import { cn } from '../lib/utils';
|
|
|
-import { getRequirements, getCapabilities, getTools, getKnowledge } from '../services/api';
|
|
|
+import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts } from '../services/api';
|
|
|
+
|
|
|
+// ─── 覆盖率统计 ────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function CoverageStats({ stats, weightTab, setWeightTab }: any) {
|
|
|
+ const data = weightTab === 'unweighted' ? [
|
|
|
+ { label: '全局节点', value: stats.totalLeaves, percent: '100%', color: 'bg-slate-400' },
|
|
|
+ { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
|
|
|
+ { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
|
|
|
+ { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
|
|
|
+ { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
|
|
|
+ ] : [
|
|
|
+ { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
|
|
|
+ { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
|
|
|
+ { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
|
|
|
+ { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
|
|
|
+ { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
|
|
|
+ ];
|
|
|
|
|
|
-function RelationGroup({ title, count, colorClass, borderClass, children, defaultOpen = true }: any) {
|
|
|
- const [open, setOpen] = useState(defaultOpen);
|
|
|
return (
|
|
|
- <div className="mt-8 mb-4">
|
|
|
- <div
|
|
|
- className={cn("flex items-center gap-3 font-black text-[13px] tracking-wide mb-4 cursor-pointer select-none", colorClass)}
|
|
|
- onClick={() => setOpen(!open)}
|
|
|
- >
|
|
|
- <div className={cn("w-6 h-[2px]", borderClass)}></div>
|
|
|
- {title} ({count})
|
|
|
+ <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden shrink-0">
|
|
|
+ <div className="flex justify-between items-center mb-6">
|
|
|
+ <div className="flex bg-slate-100 p-1 rounded-lg">
|
|
|
+ <button
|
|
|
+ className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
|
|
|
+ onClick={() => setWeightTab('unweighted')}
|
|
|
+ >无权重 (节点数)</button>
|
|
|
+ <button
|
|
|
+ className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
|
|
|
+ onClick={() => setWeightTab('weighted')}
|
|
|
+ >带权重 (帖子数)</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex justify-center items-center h-36">
|
|
|
+ <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
|
|
|
+ {data.map((item, idx) => {
|
|
|
+ const prevWidth = idx === 0 ? 100 : (data[idx - 1].value / Math.max(data[0].value, 1) * 100);
|
|
|
+ const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
|
|
|
+ const leftHeight = prevWidth;
|
|
|
+ const rightHeight = currWidth;
|
|
|
+ const clipPath = `polygon(0 ${50 - leftHeight / 2}%, 100% ${50 - rightHeight / 2}%, 100% ${50 + rightHeight / 2}%, 0 ${50 + leftHeight / 2}%)`;
|
|
|
+ return (
|
|
|
+ <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
|
|
|
+ <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
|
|
|
+ <div className="z-10 flex flex-col items-center text-slate-900">
|
|
|
+ <span className="text-2xl font-black tracking-tight">{item.value}</span>
|
|
|
+ <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
|
|
|
+ </div>
|
|
|
+ {idx > 0 && (
|
|
|
+ <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
|
|
|
+ 转化率 {item.percent}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- {open && <div className="pl-1 mb-8">{children}</div>}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-function CompactListCard({ data, type, onDrillDown }: { data: any, type: 'req' | 'cap' | 'tool' | 'know', onDrillDown: (t: any, d: any) => void }) {
|
|
|
- let Icon: any = Target;
|
|
|
- let iconColor = "text-indigo-500";
|
|
|
- let title = "";
|
|
|
- let status = "";
|
|
|
-
|
|
|
- if (type === 'req') {
|
|
|
- Icon = Target; iconColor = "text-indigo-500";
|
|
|
- title = data.description || data.id;
|
|
|
- status = data.status || '未满足';
|
|
|
- } else if (type === 'cap') {
|
|
|
- Icon = Brain; iconColor = "text-amber-500";
|
|
|
- title = data.name || data.id;
|
|
|
- } else if (type === 'tool') {
|
|
|
- Icon = Wrench; iconColor = "text-emerald-500";
|
|
|
- title = data.name || data.id;
|
|
|
- status = data.status;
|
|
|
- } else if (type === 'know') {
|
|
|
- Icon = FileText; iconColor = "text-violet-500";
|
|
|
- title = data.task || data.content?.substring(0, 40) || data.id;
|
|
|
- }
|
|
|
+// ─── 关系列卡片 ────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function RelationCard({ type, item, activeId, isLeafActive, relatedIds, selectedLeafNames, onSingleClick, onDoubleClick }: {
|
|
|
+ type: string;
|
|
|
+ item: any;
|
|
|
+ activeId: string | null;
|
|
|
+ isLeafActive?: boolean;
|
|
|
+ relatedIds: Set<string>;
|
|
|
+ selectedLeafNames?: Set<string>;
|
|
|
+ onSingleClick: (nodeId: string) => void;
|
|
|
+ onDoubleClick: (type: string, data: any) => void;
|
|
|
+}) {
|
|
|
+ const nodeId = `${type}:${item.id}`;
|
|
|
+ const isSelected = activeId === nodeId;
|
|
|
+ const hasActive = !!activeId || !!isLeafActive;
|
|
|
+ const isRelated = !hasActive || relatedIds.has(nodeId);
|
|
|
+ const dimmed = hasActive && !isRelated;
|
|
|
+
|
|
|
+ const lastClickRef = useRef<{ time: number } | null>(null);
|
|
|
+
|
|
|
+ const handleClick = () => {
|
|
|
+ const now = Date.now();
|
|
|
+ if (lastClickRef.current && now - lastClickRef.current.time < 300) {
|
|
|
+ lastClickRef.current = null;
|
|
|
+ onDoubleClick(type, item);
|
|
|
+ } else {
|
|
|
+ lastClickRef.current = { time: now };
|
|
|
+ onSingleClick(nodeId);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const sourceNodeTags: string[] = type === 'req'
|
|
|
+ ? (item.source_nodes || []).slice(0, 3).map((sn: any) =>
|
|
|
+ typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn
|
|
|
+ ).filter((name: string) => Boolean(name) && name !== '__abstract__')
|
|
|
+ : [];
|
|
|
+ const totalSourceNodes = type === 'req'
|
|
|
+ ? (item.source_nodes || []).filter((sn: any) => {
|
|
|
+ const name = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
|
|
|
+ return Boolean(name) && name !== '__abstract__';
|
|
|
+ }).length
|
|
|
+ : 0;
|
|
|
+ const extraCount = type === 'req' ? Math.max(0, totalSourceNodes - 3) : 0;
|
|
|
+
|
|
|
+ const label = item.name || item.description || item.task || item.id;
|
|
|
+
|
|
|
+ // 语义颜色:每种类型对应一套颜色
|
|
|
+ const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
|
|
|
+ req: { accent: 'border-l-indigo-400', tagBg: 'bg-indigo-50', tagText: 'text-indigo-700', leftBar: 'bg-indigo-400' },
|
|
|
+ cap: { accent: 'border-l-teal-400', tagBg: 'bg-teal-50', tagText: 'text-teal-700', leftBar: 'bg-teal-400' },
|
|
|
+ tool: { accent: 'border-l-green-400', tagBg: 'bg-green-50', tagText: 'text-green-700', leftBar: 'bg-green-400' },
|
|
|
+ know: { accent: 'border-l-purple-400', tagBg: 'bg-purple-50', tagText: 'text-purple-700', leftBar: 'bg-purple-400' },
|
|
|
+ };
|
|
|
+ const tc = typeColors[type] ?? typeColors.req;
|
|
|
|
|
|
return (
|
|
|
- <div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:border-indigo-300 hover:shadow-md transition-all mb-2 w-full text-left">
|
|
|
- <div
|
|
|
- className="flex justify-between items-center p-3 cursor-pointer hover:bg-slate-50 transition-colors group"
|
|
|
- onClick={() => onDrillDown(type, data)}
|
|
|
- >
|
|
|
- <div className="flex items-center gap-2 font-bold text-sm text-slate-800 flex-1 pr-2">
|
|
|
- <Icon size={14} className={iconColor} />
|
|
|
- <span className="truncate">{title}</span>
|
|
|
+ <div
|
|
|
+ onClick={handleClick}
|
|
|
+ className={cn(
|
|
|
+ "p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
|
|
|
+ tc.accent,
|
|
|
+ isSelected
|
|
|
+ ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
|
|
|
+ : isRelated && hasActive
|
|
|
+ ? "border border-orange-300 border-l-4"
|
|
|
+ : dimmed
|
|
|
+ ? "border border-transparent border-l-4 opacity-20 grayscale scale-95"
|
|
|
+ : "border border-transparent border-l-4"
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ <div className="flex items-start gap-2">
|
|
|
+ <div className="min-w-0 flex-1">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className={cn("text-xs font-bold leading-snug", isSelected ? "text-orange-800" : "text-slate-700")}>
|
|
|
+ {label}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {sourceNodeTags.length > 0 && (
|
|
|
+ <div className="flex flex-wrap gap-1 mt-1.5">
|
|
|
+ {sourceNodeTags.map((name: string) => {
|
|
|
+ const isHighlighted = selectedLeafNames && selectedLeafNames.has(name);
|
|
|
+ return (
|
|
|
+ <span key={name} className={cn(
|
|
|
+ "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
|
|
|
+ isHighlighted ? "bg-orange-100 text-orange-700 ring-1 ring-orange-400" : cn(tc.tagBg, tc.tagText)
|
|
|
+ )}>
|
|
|
+ {name}
|
|
|
+ </span>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ {extraCount > 0 && (
|
|
|
+ <span className="text-[9px] px-1.5 py-0.5 rounded-md bg-slate-100 text-slate-400">+{extraCount}</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function DrawerContent({ type, data, dbData }: { type: string; data: any; dbData: any }) {
|
|
|
+ const relReqs = type === 'cap' ? dbData.reqs.filter((r: any) => (data.requirement_ids || []).includes(r.id))
|
|
|
+ : type === 'tool' ? [] : [];
|
|
|
+ const relCaps = type === 'req' ? dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(data.id))
|
|
|
+ : type === 'tool' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id))
|
|
|
+ : type === 'know' ? dbData.caps.filter((c: any) => (data.capability_ids || []).includes(c.id)) : [];
|
|
|
+ const relTools = type === 'cap' ? dbData.tools.filter((t: any) => (t.capability_ids || []).includes(data.id))
|
|
|
+ : type === 'know' ? dbData.tools.filter((t: any) => (data.tool_ids || []).includes(t.id)) : [];
|
|
|
+ const relKnow = type === 'cap' ? dbData.know.filter((k: any) => (k.capability_ids || []).includes(data.id))
|
|
|
+ : type === 'tool' ? dbData.know.filter((k: any) => (k.tool_ids || []).includes(data.id)) : [];
|
|
|
+
|
|
|
+ const Section = ({ title, color, items, renderItem }: any) =>
|
|
|
+ items.length > 0 ? (
|
|
|
+ <div className="mt-6">
|
|
|
+ <div className={cn("text-xs font-black tracking-wide mb-3", color)}>{title} ({items.length})</div>
|
|
|
+ <div className="space-y-2">{items.map(renderItem)}</div>
|
|
|
+ </div>
|
|
|
+ ) : null;
|
|
|
+
|
|
|
+ const MiniCard = ({ icon: Icon, label, iconColor }: any) => (
|
|
|
+ <div className="flex items-center gap-2 bg-white rounded-lg p-2.5 border border-slate-100 text-xs font-bold text-slate-700">
|
|
|
+ <Icon size={12} className={iconColor} />
|
|
|
+ <span className="truncate">{label}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {/* 主内容 */}
|
|
|
+ {type === 'req' && (
|
|
|
+ <>
|
|
|
+ <div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
|
|
|
+ <div className="text-xs font-bold text-indigo-500 mb-2">需求描述</div>
|
|
|
+ <p className="text-indigo-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 === '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>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 关联数据 */}
|
|
|
+ <Section title="关联需求" color="text-indigo-600" items={relReqs}
|
|
|
+ renderItem={(r: any) => <MiniCard key={r.id} icon={Target} label={r.description || r.id} iconColor="text-indigo-400" />} />
|
|
|
+ <Section title="关联能力" color="text-amber-600" items={relCaps}
|
|
|
+ renderItem={(c: any) => <MiniCard key={c.id} icon={Cpu} label={c.name || c.id} iconColor="text-amber-400" />} />
|
|
|
+ <Section title="关联工具" color="text-emerald-600" items={relTools}
|
|
|
+ renderItem={(t: any) => <MiniCard key={t.id} icon={Wrench} label={t.name || t.id} iconColor="text-emerald-400" />} />
|
|
|
+ <Section title="关联知识" color="text-violet-600" items={relKnow}
|
|
|
+ renderItem={(k: any) => <MiniCard key={k.id} icon={FileText} label={k.task || k.id} iconColor="text-violet-400" />} />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+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="flex items-center gap-2 shrink-0">
|
|
|
- {status && (
|
|
|
- <span className={cn("text-[10px] px-2 py-0.5 rounded-full font-bold",
|
|
|
- (status==='已满足'||status==='已接入'||status==='正常') ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500')}>
|
|
|
- {status}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- <ChevronRight size={14} className="text-slate-300 group-hover:text-indigo-500 transition-colors"/>
|
|
|
+ <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
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function PostCard({ postId, post, loading, onClick }: { postId: string; post?: any; loading?: boolean; onClick?: () => void }) {
|
|
|
+ const images: string[] = post?.images || [];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={post ? onClick : undefined}
|
|
|
+ className={cn(
|
|
|
+ "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="grid grid-cols-3 gap-0.5 shrink-0">
|
|
|
+ {images.slice(0, 3).map((url: string, i: number) => (
|
|
|
+ <div key={i} className="relative aspect-square overflow-hidden bg-slate-100">
|
|
|
+ <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="px-2.5 pt-2 pb-1 shrink-0">
|
|
|
+ <div className="text-[11px] font-bold text-slate-800 leading-snug line-clamp-2">{post.title || '无标题'}</div>
|
|
|
+ </div>
|
|
|
+ {post.body_text && (
|
|
|
+ <div className="px-2.5 pb-2 flex-1 overflow-hidden">
|
|
|
+ <p className="text-[10px] text-slate-400 leading-relaxed line-clamp-4 whitespace-pre-wrap">{post.body_text}</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ ) : !loading ? (
|
|
|
+ <div className="p-3 font-mono text-[10px] text-slate-300 break-all">{postId}</div>
|
|
|
+ ) : (
|
|
|
+ <div className="h-full min-h-[160px] bg-slate-50 animate-pulse"></div>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+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>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+// ─── 业务需求帖子聚合抽屉 ──────────────────────────────────────────────────────
|
|
|
+
|
|
|
+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-row gap-3 h-full overflow-hidden min-w-0">
|
|
|
+ {/* 左侧:节点列表 */}
|
|
|
+ <div className="w-[200px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 overflow-y-auto">
|
|
|
+ <div className="text-xs font-bold text-slate-600 mb-2">关联节点 ({nodeNames.length})</div>
|
|
|
+ <button
|
|
|
+ onClick={() => setSelectedNodeName(null)}
|
|
|
+ className={cn(
|
|
|
+ "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors",
|
|
|
+ selectedNodeName === null ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ 全部 ({allPostIds.length})
|
|
|
+ </button>
|
|
|
+ {nodeNames.map(name => (
|
|
|
+ <button
|
|
|
+ key={name}
|
|
|
+ onClick={() => setSelectedNodeName(name)}
|
|
|
+ className={cn(
|
|
|
+ "w-full text-left px-2 py-1.5 rounded-lg text-xs mb-1 transition-colors truncate",
|
|
|
+ selectedNodeName === name ? "bg-indigo-100 text-indigo-700 font-bold" : "hover:bg-slate-100 text-slate-600"
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ {name === '__unmatched__' ? '未定位帖子' : name} ({nodePostsMap[name].length})
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 右侧:帖子横向滚动 */}
|
|
|
+ <HorizontalPostScroller className="flex-1 min-w-0">
|
|
|
+ <div className="inline-flex gap-3 px-4 py-3 min-w-max">
|
|
|
+ {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 (
|
|
|
+ <PostCard key={pid} postId={pid} post={post} onClick={() => onOpenPost(pid, post)} />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </HorizontalPostScroller>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+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[] = [];
|
|
|
+ (node.elements || []).forEach((el: any) => {
|
|
|
+ (el.post_ids || []).forEach((pid: string) => {
|
|
|
+ if (!ids.includes(pid)) ids.push(pid);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return ids;
|
|
|
+ }, [node.name]);
|
|
|
+
|
|
|
+ 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 (
|
|
|
+ <HorizontalPostScroller className="h-full w-full min-w-0">
|
|
|
+ <div className="inline-flex flex-row gap-3 h-full px-4 py-3 min-w-max">
|
|
|
+ {/* 节点统计卡 */}
|
|
|
+ <div className="w-[160px] shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3 flex flex-col gap-2 justify-center">
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-2xl font-black text-slate-800">{node.total_posts_count || 0}</div>
|
|
|
+ <div className="text-[10px] text-slate-400">帖子总数</div>
|
|
|
+ </div>
|
|
|
+ <div className="text-center">
|
|
|
+ <div className="text-xl font-black text-slate-600">{postIds.length}</div>
|
|
|
+ <div className="text-[10px] text-slate-400">去重帖子</div>
|
|
|
+ </div>
|
|
|
+ {loading && (
|
|
|
+ <div className="flex items-center justify-center gap-1 text-[10px] text-slate-400 mt-1">
|
|
|
+ <div className="w-3 h-3 border-2 border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
|
|
|
+ 加载中
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 帖子卡片横向列表 */}
|
|
|
+ {postIds.length === 0 ? (
|
|
|
+ <div className="flex items-center justify-center flex-1 text-xs text-slate-300 font-bold">该节点暂无帖子</div>
|
|
|
+ ) : (
|
|
|
+ postIds.map(pid => {
|
|
|
+ const post = posts[pid];
|
|
|
+ return (
|
|
|
+ <PostCard key={pid} postId={pid} post={post} loading={loading} onClick={() => onOpenPost(pid, post)} />
|
|
|
+ );
|
|
|
+ })
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </HorizontalPostScroller>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Dashboard 主体 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: string | null, onPendingConsumed?: () => void }) {
|
|
|
- type NavItem = { type: 'node' | 'req' | 'cap' | 'tool' | 'know', data: any };
|
|
|
const [treeData, setTreeData] = useState<any>(null);
|
|
|
const [selectedNode, setSelectedNode] = useState<any>(null);
|
|
|
- const [navStack, setNavStack] = useState<NavItem[]>([]);
|
|
|
const [nameToNodeMap, setNameToNodeMap] = useState<Record<string, any>>({});
|
|
|
const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
|
|
|
reqs: [], caps: [], tools: [], know: []
|
|
|
});
|
|
|
+ const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
|
|
|
+ const [coverageStats, setCoverageStats] = useState({
|
|
|
+ totalLeaves: 0, reqCoveredNodes: 0, reqCoveragePerc: 0,
|
|
|
+ toolCoveredNodes: 0, toolCoveragePerc: 0, verifiedNodes: 0,
|
|
|
+ verifiedCoveragePerc: 0, weightedCoveragePerc: 0, coveredPostsCnt: 0,
|
|
|
+ totalPostsCnt: 0, toolCoveredPostsCnt: 0, toolWeightedCoveragePerc: 0,
|
|
|
+ verifiedPostsCnt: 0, verifiedWeightedCoveragePerc: 0
|
|
|
+ });
|
|
|
|
|
|
- // 处理来自其他页面的跳转请求
|
|
|
+ // 关系列状态
|
|
|
+ const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
+ const [activeLeafNode, setActiveLeafNode] = useState<any>(null); // 点击叶子节点时存储
|
|
|
+ const [drawerItem, setDrawerItem] = useState<{ type: string; data: any } | null>(null);
|
|
|
+ const [leafDetailNode, setLeafDetailNode] = useState<any>(null);
|
|
|
+ const [treeWideMode, setTreeWideMode] = useState(true);
|
|
|
+ const [requirementPostsData, setRequirementPostsData] = useState<{ requirement: any; nodePostsMap: Record<string, string[]> } | null>(null);
|
|
|
+ const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
|
|
|
+ const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false); // 只看覆盖需求的数据
|
|
|
+
|
|
|
+ // 来自其他页面的跳转
|
|
|
useEffect(() => {
|
|
|
if (pendingNode && nameToNodeMap[pendingNode]) {
|
|
|
setSelectedNode(nameToNodeMap[pendingNode]);
|
|
|
@@ -85,207 +658,120 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
}
|
|
|
}, [pendingNode, nameToNodeMap]);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (selectedNode) setNavStack([{ type: 'node', data: selectedNode }]);
|
|
|
- else setNavStack([]);
|
|
|
- }, [selectedNode]);
|
|
|
-
|
|
|
- const handleDrillDown = (type: NavItem['type'], data: any) => {
|
|
|
- setNavStack(prev => [...prev, { type, data }]);
|
|
|
- };
|
|
|
-
|
|
|
- const handleBreadcrumbClick = (idx: number) => {
|
|
|
- setNavStack(prev => prev.slice(0, idx + 1));
|
|
|
- };
|
|
|
-
|
|
|
- const [coverageStats, setCoverageStats] = useState({
|
|
|
- totalLeaves: 0,
|
|
|
- reqCoveredNodes: 0,
|
|
|
- reqCoveragePerc: 0,
|
|
|
- toolCoveredNodes: 0,
|
|
|
- toolCoveragePerc: 0,
|
|
|
- verifiedNodes: 0,
|
|
|
- verifiedCoveragePerc: 0,
|
|
|
- weightedCoveragePerc: 0,
|
|
|
- coveredPostsCnt: 0,
|
|
|
- totalPostsCnt: 0,
|
|
|
- toolCoveredPostsCnt: 0,
|
|
|
- toolWeightedCoveragePerc: 0,
|
|
|
- verifiedPostsCnt: 0,
|
|
|
- verifiedWeightedCoveragePerc: 0
|
|
|
- });
|
|
|
-
|
|
|
- const [weightTab, setWeightTab] = useState<'unweighted' | 'weighted'>('unweighted');
|
|
|
-
|
|
|
const getLeafNodes = (nodes: any[]): any[] => {
|
|
|
let leaves: any[] = [];
|
|
|
nodes.forEach(n => {
|
|
|
- if (!n.children || n.children.length === 0) {
|
|
|
- leaves.push(n);
|
|
|
- } else {
|
|
|
- leaves = leaves.concat(getLeafNodes(n.children));
|
|
|
- }
|
|
|
+ if (!n.children || n.children.length === 0) leaves.push(n);
|
|
|
+ else leaves = leaves.concat(getLeafNodes(n.children));
|
|
|
});
|
|
|
return leaves;
|
|
|
};
|
|
|
|
|
|
+ const collectNodePostIds = (node: any): string[] => {
|
|
|
+ const ids: string[] = [];
|
|
|
+ const walk = (current: any) => {
|
|
|
+ (current?.elements || []).forEach((el: any) => {
|
|
|
+ (el.post_ids || []).forEach((pid: string) => {
|
|
|
+ if (pid && !ids.includes(pid)) ids.push(pid);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ (current?.children || []).forEach((child: any) => walk(child));
|
|
|
+ };
|
|
|
+ walk(node);
|
|
|
+ return ids;
|
|
|
+ };
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
async function loadStats() {
|
|
|
try {
|
|
|
- // 1. Fetch Tree
|
|
|
const treeRes = await fetch('/category_tree.json');
|
|
|
const data = await treeRes.json();
|
|
|
setTreeData(data);
|
|
|
const leaves = getLeafNodes([data]);
|
|
|
|
|
|
- // 2. Fetch associations
|
|
|
const [reqRes, capRes, toolRes] = await Promise.all([
|
|
|
- getRequirements(1000, 0),
|
|
|
- getCapabilities(1000, 0),
|
|
|
- getTools(1000, 0)
|
|
|
+ getRequirements(1000, 0), getCapabilities(1000, 0), getTools(1000, 0)
|
|
|
]);
|
|
|
-
|
|
|
let knowRes: any = { results: [] };
|
|
|
- try {
|
|
|
- knowRes = await getKnowledge(1, 1000);
|
|
|
- } catch (e) {
|
|
|
- console.warn('knowledge API not available or failed', e);
|
|
|
- }
|
|
|
+ try { knowRes = await getKnowledge(1, 1000); } catch (e) { /* optional */ }
|
|
|
|
|
|
const reqs = reqRes.results || [];
|
|
|
const caps = capRes.results || [];
|
|
|
const tools = toolRes.results || [];
|
|
|
const know = knowRes.results || [];
|
|
|
-
|
|
|
setDbData({ reqs, caps, tools, know });
|
|
|
|
|
|
- // 3. Map reqs to nodes by name
|
|
|
- const nodeToReqs: Record<string, any[]> = {};
|
|
|
- leaves.forEach(l => { nodeToReqs[l.name] = []; });
|
|
|
-
|
|
|
const nameToNode: Record<string, any> = {};
|
|
|
const buildNameMap = (nodes: any[]) => {
|
|
|
- nodes.forEach(n => {
|
|
|
- nameToNode[n.name] = n;
|
|
|
- if (n.children) buildNameMap(n.children);
|
|
|
- });
|
|
|
+ nodes.forEach(n => { nameToNode[n.name] = n; if (n.children) buildNameMap(n.children); });
|
|
|
};
|
|
|
buildNameMap([data]);
|
|
|
setNameToNodeMap(nameToNode);
|
|
|
|
|
|
+ const nodeToReqs: Record<string, any[]> = {};
|
|
|
+ leaves.forEach(l => { nodeToReqs[l.name] = []; });
|
|
|
+
|
|
|
reqs.forEach((r: any) => {
|
|
|
(r.source_nodes || []).forEach((sn: any) => {
|
|
|
const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
if (nodeName && nameToNode[nodeName]) {
|
|
|
- const matchedLeaves = getLeafNodes([nameToNode[nodeName]]);
|
|
|
- matchedLeaves.forEach(ml => {
|
|
|
- if (nodeToReqs[ml.name]) {
|
|
|
- nodeToReqs[ml.name].push(r);
|
|
|
- }
|
|
|
+ getLeafNodes([nameToNode[nodeName]]).forEach(ml => {
|
|
|
+ if (nodeToReqs[ml.name]) nodeToReqs[ml.name].push(r);
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
|
|
|
- // Mark has_requirement flag and complex node_status on all leaves
|
|
|
leaves.forEach(l => {
|
|
|
const attachedReqs = nodeToReqs[l.name];
|
|
|
l.has_requirement = !!(attachedReqs && attachedReqs.length > 0);
|
|
|
-
|
|
|
- if (!l.has_requirement) {
|
|
|
- l.node_status = 0; // 灰色 (没有需求)
|
|
|
- } else {
|
|
|
- const rIds = new Set(attachedReqs.map(r => r.id));
|
|
|
- const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
|
|
|
-
|
|
|
- // 检查是否所有挂载的需求都有对应的原子能力
|
|
|
- const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
|
|
|
- if (reqsWithCaps.length < attachedReqs.length) {
|
|
|
- l.node_status = 1; // 蓝色 (有需求,但没满足,缺能力覆盖)
|
|
|
- } else {
|
|
|
- const cIds = new Set(relCaps.map((c: any) => c.id));
|
|
|
- const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
|
|
|
-
|
|
|
- if (relTools.length === 0) {
|
|
|
- l.node_status = 2; // 黄色 (有需求且全部被能力满足,但工具未接入/没有工具)
|
|
|
- } else {
|
|
|
- const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
|
|
|
- if (hasDisconnected) {
|
|
|
- l.node_status = 2; // 黄色 (工具状态有未接入)
|
|
|
- } else {
|
|
|
- l.node_status = 3; // 绿色 (全部满足且工具全接入)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ if (!l.has_requirement) { l.node_status = 0; }
|
|
|
+ else {
|
|
|
+ const rIds = new Set(attachedReqs.map(r => r.id));
|
|
|
+ const relCaps = caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => rIds.has(rid)));
|
|
|
+ const reqsWithCaps = attachedReqs.filter((r: any) => caps.some((c: any) => (c.requirement_ids || []).includes(r.id)));
|
|
|
+ if (reqsWithCaps.length < attachedReqs.length) { l.node_status = 1; }
|
|
|
+ else {
|
|
|
+ const cIds = new Set(relCaps.map((c: any) => c.id));
|
|
|
+ const relTools = tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => cIds.has(cid)));
|
|
|
+ if (relTools.length === 0) { l.node_status = 2; }
|
|
|
+ else {
|
|
|
+ const hasDisconnected = relTools.some((t: any) => t.status !== '已接入' && t.status !== '正常' && t.status !== '已上线' && t.status !== 'active');
|
|
|
+ l.node_status = hasDisconnected ? 2 : 3;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
});
|
|
|
- setTreeData({...data});
|
|
|
+ setTreeData({ ...data });
|
|
|
|
|
|
- // 4. Calculate metrics based strictly on nodes with Xiaohongshu posts (total_posts_count > 0)
|
|
|
const activeLeaves = leaves.filter(l => l.total_posts_count && l.total_posts_count > 0);
|
|
|
const totalLeavesCount = activeLeaves.length;
|
|
|
-
|
|
|
- let reqCoveredNodes = 0;
|
|
|
- let toolCoveredNodes = 0;
|
|
|
- let verifiedNodes = 0;
|
|
|
- let totalWeight = 0;
|
|
|
- let coveredWeight = 0;
|
|
|
- let toolCoveredWeight = 0;
|
|
|
- let verifiedWeight = 0;
|
|
|
-
|
|
|
+ let reqCoveredNodes = 0, toolCoveredNodes = 0, verifiedNodes = 0;
|
|
|
+ let totalWeight = 0, coveredWeight = 0, toolCoveredWeight = 0, verifiedWeight = 0;
|
|
|
activeLeaves.forEach(l => {
|
|
|
const count = l.total_posts_count || 0;
|
|
|
totalWeight += count;
|
|
|
const attachedReqs = nodeToReqs[l.name];
|
|
|
- let isToolCovered = false;
|
|
|
- let isVerified = false;
|
|
|
-
|
|
|
if (attachedReqs && attachedReqs.length > 0) {
|
|
|
- reqCoveredNodes++;
|
|
|
- coveredWeight += count;
|
|
|
-
|
|
|
- if (l.node_status >= 2) {
|
|
|
- isToolCovered = true;
|
|
|
- }
|
|
|
- if (l.node_status === 3) {
|
|
|
- isVerified = true;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (isToolCovered) {
|
|
|
- toolCoveredNodes++;
|
|
|
- toolCoveredWeight += count;
|
|
|
- }
|
|
|
- if (isVerified) {
|
|
|
- verifiedNodes++;
|
|
|
- verifiedWeight += count;
|
|
|
+ reqCoveredNodes++; coveredWeight += count;
|
|
|
+ if (l.node_status >= 2) { toolCoveredNodes++; toolCoveredWeight += count; }
|
|
|
+ if (l.node_status === 3) { verifiedNodes++; verifiedWeight += count; }
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
- const reqCoveragePerc = totalLeavesCount > 0 ? (reqCoveredNodes / totalLeavesCount) * 100 : 0;
|
|
|
- const toolCoveragePerc = reqCoveredNodes > 0 ? (toolCoveredNodes / reqCoveredNodes) * 100 : 0;
|
|
|
- const verifiedCoveragePerc = totalLeavesCount > 0 ? (verifiedNodes / totalLeavesCount) * 100 : 0;
|
|
|
- const weightedCoveragePerc = totalWeight > 0 ? (coveredWeight / totalWeight) * 100 : 0;
|
|
|
- const toolWeightedCoveragePerc = coveredWeight > 0 ? (toolCoveredWeight / coveredWeight) * 100 : 0;
|
|
|
- const verifiedWeightedCoveragePerc = totalWeight > 0 ? (verifiedWeight / totalWeight) * 100 : 0;
|
|
|
-
|
|
|
setCoverageStats({
|
|
|
- totalLeaves: totalLeavesCount,
|
|
|
- reqCoveredNodes,
|
|
|
- reqCoveragePerc: Number(reqCoveragePerc.toFixed(1)),
|
|
|
+ totalLeaves: totalLeavesCount, reqCoveredNodes,
|
|
|
+ reqCoveragePerc: Number((totalLeavesCount > 0 ? reqCoveredNodes / totalLeavesCount * 100 : 0).toFixed(1)),
|
|
|
toolCoveredNodes,
|
|
|
- toolCoveragePerc: Number(toolCoveragePerc.toFixed(1)),
|
|
|
+ toolCoveragePerc: Number((reqCoveredNodes > 0 ? toolCoveredNodes / reqCoveredNodes * 100 : 0).toFixed(1)),
|
|
|
verifiedNodes,
|
|
|
- verifiedCoveragePerc: Number(verifiedCoveragePerc.toFixed(1)),
|
|
|
- weightedCoveragePerc: Number(weightedCoveragePerc.toFixed(1)),
|
|
|
- coveredPostsCnt: coveredWeight,
|
|
|
- totalPostsCnt: totalWeight,
|
|
|
+ verifiedCoveragePerc: Number((totalLeavesCount > 0 ? verifiedNodes / totalLeavesCount * 100 : 0).toFixed(1)),
|
|
|
+ weightedCoveragePerc: Number((totalWeight > 0 ? coveredWeight / totalWeight * 100 : 0).toFixed(1)),
|
|
|
+ coveredPostsCnt: coveredWeight, totalPostsCnt: totalWeight,
|
|
|
toolCoveredPostsCnt: toolCoveredWeight,
|
|
|
- toolWeightedCoveragePerc: Number(toolWeightedCoveragePerc.toFixed(1)),
|
|
|
+ toolWeightedCoveragePerc: Number((coveredWeight > 0 ? toolCoveredWeight / coveredWeight * 100 : 0).toFixed(1)),
|
|
|
verifiedPostsCnt: verifiedWeight,
|
|
|
- verifiedWeightedCoveragePerc: Number(verifiedWeightedCoveragePerc.toFixed(1))
|
|
|
+ verifiedWeightedCoveragePerc: Number((totalWeight > 0 ? verifiedWeight / totalWeight * 100 : 0).toFixed(1))
|
|
|
});
|
|
|
-
|
|
|
} catch (err) {
|
|
|
console.error("Failed to load dashboard stats", err);
|
|
|
}
|
|
|
@@ -293,301 +779,437 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
|
|
|
loadStats();
|
|
|
}, []);
|
|
|
|
|
|
+ // ── 点击叶子节点时,找到它关联的所有需求 ID ────────────────────────────────
|
|
|
+ const activeLeafReqIds = useMemo((): Set<string> => {
|
|
|
+ if (!activeLeafNode) return new Set();
|
|
|
+ const leafName = activeLeafNode.name;
|
|
|
+ const relatedReqs = dbData.reqs.filter((r: any) =>
|
|
|
+ (r.source_nodes || []).some((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ return nodeName === leafName;
|
|
|
+ })
|
|
|
+ );
|
|
|
+ return new Set(relatedReqs.map((r: any) => r.id));
|
|
|
+ }, [activeLeafNode, dbData.reqs]);
|
|
|
+
|
|
|
+ // ── 点击叶子节点时,收集所有共享需求的叶子节点名称(用于树高亮)────────────────────────────────
|
|
|
+ const highlightLeafNamesFromActiveLeaf = useMemo((): Set<string> => {
|
|
|
+ if (activeLeafReqIds.size === 0) return new Set();
|
|
|
+ const relatedReqs = dbData.reqs.filter((r: any) => activeLeafReqIds.has(r.id));
|
|
|
+ const leafNames = new Set<string>();
|
|
|
+ relatedReqs.forEach(req => {
|
|
|
+ (req.source_nodes || []).forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
|
|
|
+ getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
|
|
|
+ leafNames.add(leaf.name);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return leafNames;
|
|
|
+ }, [activeLeafReqIds, dbData.reqs, nameToNodeMap]);
|
|
|
+
|
|
|
+ // ── 选中节点时,计算所有共享需求的节点名称(展开到叶子节点)────────────────────────────────
|
|
|
+ const selectedLeafNames = useMemo((): Set<string> => {
|
|
|
+ if (!selectedNode) return new Set();
|
|
|
+ const clickedLeafNames = new Set(getLeafNodes([selectedNode]).map(l => l.name));
|
|
|
+ const relatedReqs = dbData.reqs.filter((r: any) =>
|
|
|
+ (r.source_nodes || []).some((sn: any) =>
|
|
|
+ clickedLeafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn)
|
|
|
+ )
|
|
|
+ );
|
|
|
+ const allRelatedLeafNames = new Set<string>();
|
|
|
+ relatedReqs.forEach(req => {
|
|
|
+ (req.source_nodes || []).forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
|
|
|
+ // 展开到叶子节点
|
|
|
+ getLeafNodes([nameToNodeMap[nodeName]]).forEach(leaf => {
|
|
|
+ allRelatedLeafNames.add(leaf.name);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return allRelatedLeafNames;
|
|
|
+ }, [selectedNode, dbData.reqs, nameToNodeMap]);
|
|
|
+
|
|
|
+ // ── 树节点过滤:选中节点后只显示关联数据(基于所有共享需求的节点)───────────────────────────────────
|
|
|
+ const filteredData = useMemo(() => {
|
|
|
+ let baseReqs = dbData.reqs;
|
|
|
+
|
|
|
+ // 如果开启"只看覆盖需求的数据",先过滤出有 source_nodes 的需求
|
|
|
+ if (onlyCoveredFilter) {
|
|
|
+ baseReqs = dbData.reqs.filter((r: any) => {
|
|
|
+ const nodes = r.source_nodes || [];
|
|
|
+ return nodes.some((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ return nodeName && nodeName !== '__abstract__';
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果选中了节点,进一步过滤
|
|
|
+ if (selectedNode) {
|
|
|
+ const leafNames = selectedLeafNames.size > 0 ? selectedLeafNames : new Set(getLeafNodes([selectedNode]).map(l => l.name));
|
|
|
+ baseReqs = baseReqs.filter((r: any) =>
|
|
|
+ (r.source_nodes || []).some((sn: any) => leafNames.has(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const reqIds = new Set(baseReqs.map((r: any) => r.id));
|
|
|
+ const filteredCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => reqIds.has(rid)));
|
|
|
+ const capIds = new Set(filteredCaps.map((c: any) => c.id));
|
|
|
+ const filteredTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => capIds.has(cid)));
|
|
|
+ const toolIds = new Set(filteredTools.map((t: any) => t.id));
|
|
|
+ const filteredKnow = dbData.know.filter((k: any) =>
|
|
|
+ (k.capability_ids || []).some((cid: string) => capIds.has(cid)) ||
|
|
|
+ (k.tool_ids || []).some((tid: string) => toolIds.has(tid))
|
|
|
+ );
|
|
|
+ return { reqs: baseReqs, caps: filteredCaps, tools: filteredTools, know: filteredKnow };
|
|
|
+ }, [selectedNode, selectedLeafNames, dbData, onlyCoveredFilter]);
|
|
|
+
|
|
|
+ // ── 反向联动:activeId 对应的叶子节点名称集合,用于高亮内容树 ──────────────
|
|
|
+ const highlightLeafNames = useMemo((): Set<string> | null => {
|
|
|
+ if (!activeId) return null;
|
|
|
+ const [type, id] = activeId.split(':');
|
|
|
+ let sourceNodes: any[] = [];
|
|
|
+ if (type === 'req') {
|
|
|
+ const req = dbData.reqs.find((r: any) => r.id === id);
|
|
|
+ sourceNodes = req?.source_nodes || [];
|
|
|
+ } else if (type === 'cap') {
|
|
|
+ const cap = dbData.caps.find((c: any) => c.id === id);
|
|
|
+ const reqIds: string[] = cap?.requirement_ids || [];
|
|
|
+ reqIds.forEach(rid => {
|
|
|
+ const req = dbData.reqs.find((r: any) => r.id === rid);
|
|
|
+ if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
|
|
|
+ });
|
|
|
+ } else if (type === 'tool') {
|
|
|
+ const tool = dbData.tools.find((t: any) => t.id === id);
|
|
|
+ const capIds: string[] = tool?.capability_ids || [];
|
|
|
+ capIds.forEach(cid => {
|
|
|
+ const cap = dbData.caps.find((c: any) => c.id === cid);
|
|
|
+ const reqIds: string[] = cap?.requirement_ids || [];
|
|
|
+ reqIds.forEach(rid => {
|
|
|
+ const req = dbData.reqs.find((r: any) => r.id === rid);
|
|
|
+ if (req) sourceNodes = sourceNodes.concat(req.source_nodes || []);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (sourceNodes.length === 0) return null;
|
|
|
+ const leafNames = new Set<string>();
|
|
|
+ sourceNodes.forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ if (nodeName && nameToNodeMap[nodeName]) {
|
|
|
+ getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => leafNames.add(l.name));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return leafNames.size > 0 ? leafNames : null;
|
|
|
+ }, [activeId, dbData, nameToNodeMap]);
|
|
|
+
|
|
|
+ // ── 全局节点总数(叶子节点数)──────────────────────────────────────────────
|
|
|
+
|
|
|
+ // ── 有需求覆盖的叶子节点名称集合(用于 onlyCoveredFilter)──────────────────
|
|
|
+ const coveredLeafNames = useMemo((): Set<string> => {
|
|
|
+ const names = new Set<string>();
|
|
|
+ dbData.reqs.forEach((r: any) => {
|
|
|
+ (r.source_nodes || []).forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
|
|
|
+ if (nodeName && nodeName !== '__abstract__' && nameToNodeMap[nodeName]) {
|
|
|
+ getLeafNodes([nameToNodeMap[nodeName]]).forEach(l => names.add(l.name));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return names;
|
|
|
+ }, [dbData.reqs, nameToNodeMap]);
|
|
|
+ const totalNodeCount = useMemo(() => {
|
|
|
+ if (!treeData) return 0;
|
|
|
+ return getLeafNodes([treeData]).filter((l: any) => l.total_posts_count && l.total_posts_count > 0).length;
|
|
|
+ }, [treeData]);
|
|
|
+
|
|
|
+ // ── BFS 关联高亮(与 Relations.tsx 逻辑一致)──────────────────────────────
|
|
|
+ const adjacencyMap = useMemo(() => {
|
|
|
+ const map = new Map<string, Set<string>>();
|
|
|
+ const add = (a: string, b: string) => {
|
|
|
+ if (!map.has(a)) map.set(a, new Set());
|
|
|
+ if (!map.has(b)) map.set(b, new Set());
|
|
|
+ map.get(a)!.add(b);
|
|
|
+ map.get(b)!.add(a);
|
|
|
+ };
|
|
|
+ filteredData.reqs.forEach(r => {
|
|
|
+ (r.capability_ids || []).forEach((cid: string) => add(`req:${r.id}`, `cap:${cid}`));
|
|
|
+ });
|
|
|
+ filteredData.caps.forEach(c => {
|
|
|
+ (c.tool_ids || c.capability_ids || []).forEach((tid: string) => add(`cap:${c.id}`, `tool:${tid}`));
|
|
|
+ (c.requirement_ids || []).forEach((rid: string) => add(`cap:${c.id}`, `req:${rid}`));
|
|
|
+ });
|
|
|
+ filteredData.tools.forEach(t => {
|
|
|
+ (t.capability_ids || []).forEach((cid: string) => add(`tool:${t.id}`, `cap:${cid}`));
|
|
|
+ });
|
|
|
+ return map;
|
|
|
+ }, [filteredData]);
|
|
|
+
|
|
|
+ const colOrder: Record<string, number> = { req: 0, proc: 1, cap: 2, tool: 3 };
|
|
|
+ const getColType = (nodeId: string) => nodeId.split(':')[0];
|
|
|
+
|
|
|
+ const relatedIds = useMemo(() => {
|
|
|
+ // 如果点击了叶子节点,把它关联的所有需求作为起点
|
|
|
+ const startNodes: string[] = [];
|
|
|
+ if (activeLeafNode && activeLeafReqIds.size > 0) {
|
|
|
+ activeLeafReqIds.forEach(reqId => startNodes.push(`req:${reqId}`));
|
|
|
+ } else if (activeId) {
|
|
|
+ startNodes.push(activeId);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (startNodes.length === 0) return new Set<string>();
|
|
|
+
|
|
|
+ const visited = new Set<string>(startNodes);
|
|
|
+ const queue: [string, number][] = startNodes.map(n => [n, 0]);
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const [u, dir] = queue.shift()!;
|
|
|
+ const uOrder = colOrder[getColType(u)] ?? -1;
|
|
|
+ const neighbors = adjacencyMap.get(u) || new Set();
|
|
|
+ neighbors.forEach(v => {
|
|
|
+ if (visited.has(v)) return;
|
|
|
+ const vOrder = colOrder[getColType(v)] ?? -1;
|
|
|
+ const goRight = vOrder > uOrder;
|
|
|
+ const goLeft = vOrder < uOrder;
|
|
|
+ const allowed = dir === 0 || (dir === 1 && goRight) || (dir === -1 && goLeft);
|
|
|
+ if (allowed) { visited.add(v); queue.push([v, goRight ? 1 : -1]); }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return visited;
|
|
|
+ }, [activeId, activeLeafNode, activeLeafReqIds, adjacencyMap]);
|
|
|
+
|
|
|
+ const sortedItems = (items: any[], type: string) => {
|
|
|
+ if (!activeId && !activeLeafNode) return items;
|
|
|
+ const activeType = activeId ? activeId.split(':')[0] : 'req';
|
|
|
+ if (type === activeType) return items;
|
|
|
+ return [...items].sort((a, b) => {
|
|
|
+ const aRel = relatedIds.has(`${type}:${a.id}`) ? 0 : 1;
|
|
|
+ const bRel = relatedIds.has(`${type}:${b.id}`) ? 0 : 1;
|
|
|
+ return aRel - bRel;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSingleClick = (nodeId: string, _item: any) => {
|
|
|
+ setActiveId(prev => prev === nodeId ? null : nodeId);
|
|
|
+ setActiveLeafNode(null);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDoubleClick = (type: string, data: any) => {
|
|
|
+ if (type === 'req') {
|
|
|
+ // 业务需求双击 → 打开帖子聚合抽屉
|
|
|
+ openRequirementPostsDrawer(data);
|
|
|
+ } else {
|
|
|
+ setDrawerItem({ type, data });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const openRequirementPostsDrawer = (req: any) => {
|
|
|
+ // 对每个来源节点,只展示“需求输入帖子”与“该节点真实帖子”的交集。
|
|
|
+ const nodePostsMap: Record<string, string[]> = {};
|
|
|
+ const requirementInputPosts = [
|
|
|
+ ...(req.source_posts || []),
|
|
|
+ ...(req.post_ids || []),
|
|
|
+ ].map((item: any) => typeof item === 'string' ? item : item?.post_id).filter(Boolean);
|
|
|
+ const fallbackInputPosts: string[] = [];
|
|
|
+
|
|
|
+ (req.source_nodes || []).forEach((sn: any) => {
|
|
|
+ const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name || '') : sn;
|
|
|
+ if (!nodeName || nodeName === '__abstract__') return;
|
|
|
+ const node = nameToNodeMap[nodeName];
|
|
|
+ if (!node) return;
|
|
|
+
|
|
|
+ const nodePostIds = collectNodePostIds(node);
|
|
|
+ const nodePostIdSet = new Set(nodePostIds);
|
|
|
+
|
|
|
+ const explicitPosts = typeof sn === 'object'
|
|
|
+ ? [
|
|
|
+ ...(sn.posts || []),
|
|
|
+ ...(sn.post_ids || []),
|
|
|
+ ...(sn.source_posts || []),
|
|
|
+ ]
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
|
|
|
+ .map((item: any) => typeof item === 'string' ? item : item?.post_id)
|
|
|
+ .filter(Boolean);
|
|
|
+ candidatePosts.forEach((pid: string) => {
|
|
|
+ if (pid && !fallbackInputPosts.includes(pid)) fallbackInputPosts.push(pid);
|
|
|
+ });
|
|
|
+
|
|
|
+ const postIds: string[] = [];
|
|
|
+ candidatePosts.forEach((pid: string) => {
|
|
|
+ if (nodePostIdSet.has(pid) && !postIds.includes(pid)) postIds.push(pid);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (postIds.length > 0) nodePostsMap[nodeName] = postIds;
|
|
|
+ });
|
|
|
+
|
|
|
+ const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
|
|
|
+ const matchedPosts = new Set(Object.values(nodePostsMap).flat());
|
|
|
+ const unmatchedPosts = allInputPosts.filter((pid: string) => pid && !matchedPosts.has(pid));
|
|
|
+
|
|
|
+ if (unmatchedPosts.length > 0) {
|
|
|
+ nodePostsMap.__unmatched__ = unmatchedPosts;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (Object.keys(nodePostsMap).length === 0 && allInputPosts.length > 0) {
|
|
|
+ nodePostsMap.__unmatched__ = allInputPosts;
|
|
|
+ }
|
|
|
+
|
|
|
+ setRequirementPostsData({ requirement: req, nodePostsMap });
|
|
|
+ };
|
|
|
+
|
|
|
+ const columns = [
|
|
|
+ { t: 'req', l: '业务需求', i: Target, d: filteredData.reqs, headerColor: 'border-t-2 border-t-indigo-400' },
|
|
|
+ { t: 'proc', l: '生产工序', i: ListTree, d: [], headerColor: 'border-t-2 border-t-purple-400' },
|
|
|
+ { t: 'cap', l: '原子能力', i: Cpu, d: filteredData.caps, headerColor: 'border-t-2 border-t-teal-400' },
|
|
|
+ { t: 'tool', l: '执行工具', i: Wrench, d: filteredData.tools, headerColor: 'border-t-2 border-t-green-400' },
|
|
|
+ ];
|
|
|
+
|
|
|
return (
|
|
|
- <div className="space-y-8 animate-in fade-in duration-500">
|
|
|
- <div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-6 overflow-hidden">
|
|
|
- <div className="flex justify-between items-center mb-8">
|
|
|
- <div className="flex bg-slate-100 p-1 rounded-lg">
|
|
|
- <button
|
|
|
- className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'unweighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
|
|
|
- onClick={() => setWeightTab('unweighted')}
|
|
|
- >
|
|
|
- 无权重 (节点数)
|
|
|
- </button>
|
|
|
- <button
|
|
|
- className={cn("px-4 py-1.5 text-sm font-bold rounded-md transition-all", weightTab === 'weighted' ? "bg-white text-indigo-700 shadow-sm" : "text-slate-500 hover:text-slate-700")}
|
|
|
- onClick={() => setWeightTab('weighted')}
|
|
|
- >
|
|
|
- 带权重 (帖子数)
|
|
|
- </button>
|
|
|
- </div>
|
|
|
+ <div className="flex flex-col h-[calc(100vh-64px)] gap-4 animate-in fade-in duration-500 overflow-hidden relative">
|
|
|
+ {/* 顶部抽屉:叶子节点帖子详情 或 业务需求帖子聚合(全局 fixed) */}
|
|
|
+ <div className={cn(
|
|
|
+ "fixed left-0 right-0 top-0 z-[200] bg-white border-b border-slate-200 shadow-xl transition-transform duration-300 ease-in-out flex flex-col",
|
|
|
+ (leafDetailNode || requirementPostsData) ? "translate-y-0" : "-translate-y-full"
|
|
|
+ )} style={{ maxHeight: '60vh' }}>
|
|
|
+ <div className="flex items-center justify-between px-6 py-3 border-b border-slate-100 shrink-0">
|
|
|
+ <span className="font-bold text-slate-800">
|
|
|
+ {leafDetailNode ? leafDetailNode.name : requirementPostsData ? `${requirementPostsData.requirement.name || requirementPostsData.requirement.description || '业务需求'} - 输入帖子` : ''}
|
|
|
+ </span>
|
|
|
+ <button onClick={() => { setLeafDetailNode(null); setRequirementPostsData(null); }} className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors">
|
|
|
+ <X size={16} />
|
|
|
+ </button>
|
|
|
</div>
|
|
|
-
|
|
|
- <div className="flex justify-center items-center h-48 py-4">
|
|
|
- {(() => {
|
|
|
- const data = weightTab === 'unweighted' ? [
|
|
|
- { label: '全局节点', value: coverageStats.totalLeaves, percent: '100%', color: 'bg-blue-400' },
|
|
|
- { label: '需求覆盖节点', value: coverageStats.reqCoveredNodes, percent: coverageStats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
|
|
|
- { label: '工具覆盖节点', value: coverageStats.toolCoveredNodes, percent: (coverageStats.totalLeaves ? (coverageStats.toolCoveredNodes/coverageStats.totalLeaves*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
|
|
|
- { label: '已验证节点', value: coverageStats.verifiedNodes, percent: coverageStats.verifiedCoveragePerc + '%', color: 'bg-cyan-400' },
|
|
|
- ] : [
|
|
|
- { label: '全局节点 (帖子)', value: coverageStats.totalPostsCnt, percent: '100%', color: 'bg-blue-400' },
|
|
|
- { label: '需求覆盖 (帖子)', value: coverageStats.coveredPostsCnt, percent: coverageStats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
|
|
|
- { label: '工具覆盖 (帖子)', value: coverageStats.toolCoveredPostsCnt, percent: (coverageStats.totalPostsCnt ? (coverageStats.toolCoveredPostsCnt/coverageStats.totalPostsCnt*100).toFixed(1) : 0) + '%', color: 'bg-emerald-400' },
|
|
|
- { label: '已验证 (帖子)', value: coverageStats.verifiedPostsCnt, percent: coverageStats.verifiedWeightedCoveragePerc + '%', color: 'bg-cyan-400' },
|
|
|
- ];
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="flex w-full max-w-4xl h-full relative" style={{ filter: "drop-shadow(0 4px 6px rgba(0,0,0,0.05))" }}>
|
|
|
- {data.map((item, idx) => {
|
|
|
- const prevWidth = idx === 0 ? 100 : (data[idx-1].value / Math.max(data[0].value, 1) * 100);
|
|
|
- const currWidth = (item.value / Math.max(data[0].value, 1) * 100);
|
|
|
- // 取消最小高度限制,严格按比例渲染梯形
|
|
|
- const leftHeight = prevWidth;
|
|
|
- const rightHeight = currWidth;
|
|
|
- const clipPath = `polygon(0 ${50 - leftHeight/2}%, 100% ${50 - rightHeight/2}%, 100% ${50 + rightHeight/2}%, 0 ${50 + leftHeight/2}%)`;
|
|
|
-
|
|
|
- return (
|
|
|
- <div key={idx} className="flex-1 flex flex-col items-center justify-center relative border-r-2 border-white last:border-r-0">
|
|
|
- <div className={cn("absolute inset-0 transition-all duration-700 opacity-90", item.color)} style={{ clipPath }}></div>
|
|
|
- <div className="z-10 flex flex-col items-center text-slate-900">
|
|
|
- <span className="text-2xl font-black tracking-tight">{item.value}</span>
|
|
|
- <span className="text-xs font-bold mt-0.5 opacity-90">{item.label}</span>
|
|
|
- </div>
|
|
|
- {idx > 0 && (
|
|
|
- <div className="absolute top-0 left-0 -translate-x-1/2 -mt-4 text-[11px] font-bold text-slate-500 bg-white px-2 py-0.5 rounded shadow-sm border border-slate-100 z-20">
|
|
|
- 转化率 {item.percent}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- );
|
|
|
- })()}
|
|
|
+ <div className="overflow-hidden flex-1 min-w-0">
|
|
|
+ {leafDetailNode && <LeafNodeDrawer node={leafDetailNode} onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })} />}
|
|
|
+ {requirementPostsData && (
|
|
|
+ <RequirementPostsDrawer
|
|
|
+ requirement={requirementPostsData.requirement}
|
|
|
+ nodePostsMap={requirementPostsData.nodePostsMap}
|
|
|
+ onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
|
|
|
+ />
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <div className="flex flex-col lg:flex-row gap-6">
|
|
|
- {/* Left Side: Tree Viewer */}
|
|
|
- <div className={cn("transition-all duration-300 ease-in-out", selectedNode ? "w-full xl:w-2/3" : "w-full")}>
|
|
|
- <CategoryTree data={treeData} onSelect={setSelectedNode} selectedId={selectedNode?.id} />
|
|
|
+ {selectedPostDetail && (
|
|
|
+ <PostDetailModal
|
|
|
+ postId={selectedPostDetail.postId}
|
|
|
+ post={selectedPostDetail.post}
|
|
|
+ onClose={() => setSelectedPostDetail(null)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 覆盖率统计 */}
|
|
|
+ <CoverageStats stats={coverageStats} weightTab={weightTab} setWeightTab={setWeightTab} />
|
|
|
+
|
|
|
+ {/* 工具栏 */}
|
|
|
+ <div className="flex items-center gap-2 shrink-0">
|
|
|
+ <button
|
|
|
+ onClick={() => setOnlyCoveredFilter(v => !v)}
|
|
|
+ className={cn(
|
|
|
+ "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
|
|
|
+ onlyCoveredFilter
|
|
|
+ ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
|
|
|
+ : "bg-slate-200 text-slate-500 hover:bg-slate-300"
|
|
|
+ )}
|
|
|
+ >
|
|
|
+ <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
|
|
|
+ 只看覆盖需求的数据
|
|
|
+ </button>
|
|
|
+ {(selectedNode || activeLeafNode || activeId) && (
|
|
|
+ <button
|
|
|
+ onClick={() => { setSelectedNode(null); setActiveId(null); setActiveLeafNode(null); }}
|
|
|
+ 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"
|
|
|
+ >
|
|
|
+ <X size={12} /> 清除筛选
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 主区域:整行横向滚动,每列固定宽度 */}
|
|
|
+ <div className="flex flex-row gap-4 flex-1 min-h-0 overflow-x-auto">
|
|
|
+ {/* 内容树 */}
|
|
|
+ <div className={cn("shrink-0 min-h-0", treeWideMode ? "w-[900px]" : "w-[420px]")}>
|
|
|
+ <CategoryTree
|
|
|
+ data={treeData}
|
|
|
+ onSelect={(node) => {
|
|
|
+ const isLeaf = !node.children || node.children.length === 0;
|
|
|
+ const isSame = selectedNode?.id === node.id;
|
|
|
+ setSelectedNode(isSame ? null : node);
|
|
|
+ setActiveId(null);
|
|
|
+ setActiveLeafNode(isLeaf && !isSame ? node : null);
|
|
|
+ }}
|
|
|
+ onDoubleClick={(node) => {
|
|
|
+ setLeafDetailNode(node);
|
|
|
+ }}
|
|
|
+ selectedId={activeLeafNode?.id ?? selectedNode?.id}
|
|
|
+ highlightLeafNames={
|
|
|
+ activeId
|
|
|
+ ? highlightLeafNames
|
|
|
+ : activeLeafNode
|
|
|
+ ? (highlightLeafNamesFromActiveLeaf.size > 0 ? highlightLeafNamesFromActiveLeaf : null)
|
|
|
+ : onlyCoveredFilter
|
|
|
+ ? coveredLeafNames
|
|
|
+ : null
|
|
|
+ }
|
|
|
+ totalNodeCount={totalNodeCount}
|
|
|
+ wideMode={treeWideMode}
|
|
|
+ onToggleWideMode={() => setTreeWideMode(m => !m)}
|
|
|
+ />
|
|
|
</div>
|
|
|
|
|
|
- {/* Right Side: Drill-down Details Panel */}
|
|
|
- {navStack.length > 0 && (
|
|
|
- <div className="w-full xl:w-1/3 bg-white p-6 rounded-3xl border border-slate-100 shadow-sm sticky top-24 max-h-[calc(100vh-100px)] overflow-y-auto custom-scrollbar">
|
|
|
- {/* Breadcrumbs */}
|
|
|
- <div className="flex flex-wrap items-center gap-1.5 font-bold text-[13px] text-slate-500 border-b pb-4 mb-4">
|
|
|
- {navStack.map((item, idx) => {
|
|
|
- let Icon: any = FolderTree;
|
|
|
- let crumbTitle = "Node";
|
|
|
- if (item.type === 'node') { Icon = FolderTree; crumbTitle = item.data.name || "Root"; }
|
|
|
- if (item.type === 'req') { Icon = Target; crumbTitle = item.data.id.substring(0, 8); }
|
|
|
- if (item.type === 'cap') { Icon = Brain; crumbTitle = item.data.name || item.data.id.substring(0,8); }
|
|
|
- if (item.type === 'tool') { Icon = Wrench; crumbTitle = item.data.name || item.data.id.substring(0,8); }
|
|
|
- if (item.type === 'know') { Icon = FileText; crumbTitle = item.data.task ? item.data.task.substring(0, 10)+"..." : item.data.id.substring(0,8); }
|
|
|
-
|
|
|
- const isLast = idx === navStack.length - 1;
|
|
|
-
|
|
|
- return (
|
|
|
- <div key={idx} className="flex items-center gap-1.5">
|
|
|
- <div
|
|
|
- className={cn("flex items-center gap-1 transition-colors bg-slate-50 px-2 py-1 rounded-md border border-slate-100",
|
|
|
- isLast ? "text-indigo-700 bg-indigo-50 border-indigo-100 shadow-sm" : "hover:text-indigo-600 cursor-pointer")}
|
|
|
- onClick={() => !isLast && handleBreadcrumbClick(idx)}
|
|
|
- >
|
|
|
- <Icon size={14} />
|
|
|
- <span className="max-w-[120px] truncate">{crumbTitle}</span>
|
|
|
- </div>
|
|
|
- {!isLast && <ChevronRight size={14} className="text-slate-300" />}
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
+ {/* 关系列:每列固定宽度 */}
|
|
|
+ {columns.map(col => (
|
|
|
+ <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
|
|
|
+ <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 flex justify-between shrink-0">
|
|
|
+ {col.l}
|
|
|
+ <span className="text-slate-400">{col.d.length}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
|
|
|
+ {col.t === 'proc' ? (
|
|
|
+ <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">暂无数据</div>
|
|
|
+ ) : (
|
|
|
+ sortedItems(col.d, col.t).map(item => (
|
|
|
+ <RelationCard
|
|
|
+ key={item.id}
|
|
|
+ type={col.t}
|
|
|
+ item={item}
|
|
|
+ activeId={activeLeafNode ? null : activeId}
|
|
|
+ isLeafActive={!!activeLeafNode}
|
|
|
+ relatedIds={relatedIds}
|
|
|
+ selectedLeafNames={activeLeafNode ? highlightLeafNamesFromActiveLeaf : new Set()}
|
|
|
+ onSingleClick={(nodeId) => handleSingleClick(nodeId, item)}
|
|
|
+ onDoubleClick={handleDoubleClick}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ {col.t !== 'proc' && col.d.length === 0 && (
|
|
|
+ <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
|
|
|
+ {selectedNode ? '该节点无关联数据' : '无数据'}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
-
|
|
|
- {/* Content Area */}
|
|
|
- {(() => {
|
|
|
- const currentItem = navStack[navStack.length - 1];
|
|
|
- const d = currentItem.data;
|
|
|
-
|
|
|
- let relReqs: any[] = [];
|
|
|
- let relCaps: any[] = [];
|
|
|
- let relTools: any[] = [];
|
|
|
- let relKnow: any[] = [];
|
|
|
-
|
|
|
- if (currentItem.type === 'node') {
|
|
|
- const getLeafNames = (nodes: any[]): any[] => {
|
|
|
- let leaves: any[] = [];
|
|
|
- nodes.forEach(n => {
|
|
|
- if (!n.children || n.children.length === 0) leaves.push(n);
|
|
|
- else leaves = leaves.concat(getLeafNames(n.children));
|
|
|
- });
|
|
|
- return leaves;
|
|
|
- };
|
|
|
- const leafNames = getLeafNames([d]).map(l => l.name);
|
|
|
-
|
|
|
- relReqs = dbData.reqs.filter((r: any) =>
|
|
|
- (r.source_nodes || []).some((sn: any) => leafNames.includes(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
|
|
|
- );
|
|
|
- const relReqIds = new Set(relReqs.map(r => r.id));
|
|
|
- relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).some((rid: string) => relReqIds.has(rid)));
|
|
|
- const relCapIds = new Set(relCaps.map(c => c.id));
|
|
|
- relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).some((cid: string) => relCapIds.has(cid)));
|
|
|
- const relToolIds = new Set(relTools.map(t => t.id));
|
|
|
- relKnow = dbData.know.filter((k: any) => {
|
|
|
- const hasCap = (k.capability_ids || []).some((cid: string) => relCapIds.has(cid));
|
|
|
- const hasTool = (k.tool_ids || []).some((tid: string) => relToolIds.has(tid));
|
|
|
- return hasCap || hasTool;
|
|
|
- });
|
|
|
- }
|
|
|
- else if (currentItem.type === 'req') {
|
|
|
- relCaps = dbData.caps.filter((c: any) => (c.requirement_ids || []).includes(d.id));
|
|
|
- }
|
|
|
- else if (currentItem.type === 'cap') {
|
|
|
- relReqs = dbData.reqs.filter((r: any) => (d.requirement_ids || []).includes(r.id));
|
|
|
- relTools = dbData.tools.filter((t: any) => (t.capability_ids || []).includes(d.id));
|
|
|
- relKnow = dbData.know.filter((k: any) => (k.capability_ids || []).includes(d.id));
|
|
|
- }
|
|
|
- else if (currentItem.type === 'tool') {
|
|
|
- relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
|
|
|
- relKnow = dbData.know.filter((k: any) => (k.tool_ids || []).includes(d.id));
|
|
|
- }
|
|
|
- else if (currentItem.type === 'know') {
|
|
|
- relCaps = dbData.caps.filter((c: any) => (d.capability_ids || []).includes(c.id));
|
|
|
- relTools = dbData.tools.filter((t: any) => (d.tool_ids || []).includes(t.id));
|
|
|
- }
|
|
|
-
|
|
|
- return (
|
|
|
- <div className="space-y-6 pb-8 animate-in fade-in slide-in-from-right-4 duration-300">
|
|
|
- {/* Dedicated Detail Views */}
|
|
|
- <div>
|
|
|
- {currentItem.type === 'node' && (
|
|
|
- <>
|
|
|
- <h2 className="text-2xl font-black text-slate-800">{d.name || "Root"}</h2>
|
|
|
- <div className="text-xs text-slate-400 font-mono mt-1 break-all bg-slate-50 p-2 rounded">{d.path || "/"}</div>
|
|
|
- {d.description && (
|
|
|
- <div className="bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50 mt-4">
|
|
|
- <h3 className="font-bold text-indigo-900 mb-2 text-sm">定义与描述</h3>
|
|
|
- <p className="text-indigo-700 text-sm leading-relaxed">{d.description}</p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </>
|
|
|
- )}
|
|
|
- {currentItem.type === 'req' && (
|
|
|
- <>
|
|
|
- <h2 className="text-xl font-black text-slate-800 leading-snug">需求定义</h2>
|
|
|
- <p className="mt-4 text-indigo-800 text-sm leading-relaxed whitespace-pre-wrap bg-indigo-50/50 p-4 rounded-xl border border-indigo-100/50">{d.description}</p>
|
|
|
- <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">追踪 ID</div>
|
|
|
- <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- {currentItem.type === 'cap' && (
|
|
|
- <>
|
|
|
- <h2 className="text-xl font-black text-amber-600">{d.name}</h2>
|
|
|
- <div className="bg-amber-50/50 p-4 rounded-xl mt-4 border border-amber-100/50">
|
|
|
- <h3 className="font-bold text-amber-900 mb-2 text-sm">能力标准定义</h3>
|
|
|
- <p className="text-amber-800 text-sm leading-relaxed">{d.description || "暂无描述"}</p>
|
|
|
- </div>
|
|
|
- <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">能力标识 ID</div>
|
|
|
- <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- {currentItem.type === 'tool' && (
|
|
|
- <>
|
|
|
- <h2 className="text-xl font-black text-emerald-600">{d.name}</h2>
|
|
|
- <div className="bg-emerald-50/50 p-4 rounded-xl mt-4 border border-emerald-100/50">
|
|
|
- <h3 className="font-bold text-emerald-900 mb-2 text-sm">工具介绍</h3>
|
|
|
- <p className="text-emerald-800 text-sm leading-relaxed">{d.introduction || "暂无介绍"}</p>
|
|
|
- </div>
|
|
|
- <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">执行端 ID</div>
|
|
|
- <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- {currentItem.type === 'know' && (
|
|
|
- <>
|
|
|
- <h2 className="text-xl font-black text-violet-700 leading-snug">{d.task}</h2>
|
|
|
- <div className="bg-violet-50/50 p-4 rounded-xl mt-4 border border-violet-100/50">
|
|
|
- <h3 className="font-bold text-violet-900 mb-2 text-sm">知识正文</h3>
|
|
|
- <p className="text-violet-800 text-sm whitespace-pre-wrap leading-relaxed">{d.content}</p>
|
|
|
- </div>
|
|
|
- <div className="mt-4 bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">知识库 ID</div>
|
|
|
- <div className="font-mono text-slate-700 text-[11px] break-all">{d.id}</div>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {currentItem.type === 'node' && (
|
|
|
- <div className="grid grid-cols-2 gap-3 mt-6">
|
|
|
- <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">层级分支</div>
|
|
|
- <div className="font-bold text-slate-800 text-sm">{d.children?.length || 0} 个</div>
|
|
|
- </div>
|
|
|
- <div className="bg-slate-50 p-3 rounded-xl border border-slate-100">
|
|
|
- <div className="text-[10px] text-slate-500 mb-1">小红书热度</div>
|
|
|
- <div className="font-bold text-indigo-600 text-sm">{d.total_element_count || 0} 篇</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Relations Map */}
|
|
|
- <div className="pt-2 mt-6 border-t border-slate-100">
|
|
|
- {relReqs.length > 0 && currentItem.type !== 'req' && (
|
|
|
- <RelationGroup title="基于此的需求" count={relReqs.length} colorClass="text-indigo-600" borderClass="bg-indigo-600">
|
|
|
- <div className="space-y-1">
|
|
|
- {relReqs.map((r: any) => <CompactListCard key={r.id} data={r} type="req" onDrillDown={handleDrillDown}/>)}
|
|
|
- </div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
- {relReqs.length === 0 && currentItem.type === 'node' && (
|
|
|
- <RelationGroup title="关联需求" count={0} colorClass="text-indigo-600" borderClass="bg-indigo-600">
|
|
|
- <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未检索到任何需求</div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
-
|
|
|
- {relCaps.length > 0 && currentItem.type !== 'cap' && (
|
|
|
- <RelationGroup title="下属原子能力" count={relCaps.length} colorClass="text-amber-700" borderClass="bg-amber-700">
|
|
|
- <div className="space-y-1">
|
|
|
- {relCaps.map((c: any) => <CompactListCard key={c.id} data={c} type="cap" onDrillDown={handleDrillDown}/>)}
|
|
|
- </div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
- {relCaps.length === 0 && currentItem.type === 'node' && (
|
|
|
- <RelationGroup title="下属原子能力" count={0} colorClass="text-amber-700" borderClass="bg-amber-700">
|
|
|
- <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">能力库为空</div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
-
|
|
|
- {relTools.length > 0 && currentItem.type !== 'tool' && (
|
|
|
- <RelationGroup title="相关实现工具" count={relTools.length} colorClass="text-emerald-700" borderClass="bg-emerald-700">
|
|
|
- <div className="space-y-1">
|
|
|
- {relTools.map((t: any) => <CompactListCard key={t.id} data={t} type="tool" onDrillDown={handleDrillDown}/>)}
|
|
|
- </div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
- {relTools.length === 0 && currentItem.type === 'node' && (
|
|
|
- <RelationGroup title="相关实现工具" count={0} colorClass="text-emerald-700" borderClass="bg-emerald-700">
|
|
|
- <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未发现关联的执行工具</div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
-
|
|
|
- {relKnow.length > 0 && currentItem.type !== 'know' && (
|
|
|
- <RelationGroup title="相关支撑知识" count={relKnow.length} colorClass="text-violet-700" borderClass="bg-violet-700">
|
|
|
- <div className="space-y-1">
|
|
|
- {relKnow.map((k: any) => <CompactListCard key={k.id} data={k} type="know" onDrillDown={handleDrillDown}/>)}
|
|
|
- </div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
- {relKnow.length === 0 && currentItem.type === 'node' && (
|
|
|
- <RelationGroup title="相关支撑知识" count={0} colorClass="text-violet-700" borderClass="bg-violet-700">
|
|
|
- <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">无相关文档资料</div>
|
|
|
- </RelationGroup>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })()}
|
|
|
</div>
|
|
|
- )}
|
|
|
+ ))}
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* 详情抽屉 */}
|
|
|
+ <SideDrawer
|
|
|
+ isOpen={!!drawerItem}
|
|
|
+ onClose={() => setDrawerItem(null)}
|
|
|
+ title={drawerItem ? (drawerItem.data.name || drawerItem.data.description?.slice(0, 20) || drawerItem.data.task?.slice(0, 20) || drawerItem.data.id?.slice(0, 12)) : ''}
|
|
|
+ width="w-[560px]"
|
|
|
+ >
|
|
|
+ {drawerItem && (
|
|
|
+ <DrawerContent type={drawerItem.type} data={drawerItem.data} dbData={dbData} />
|
|
|
+ )}
|
|
|
+ </SideDrawer>
|
|
|
+
|
|
|
</div>
|
|
|
);
|
|
|
}
|