|
|
@@ -1,14 +1,92 @@
|
|
|
import { useState, useEffect } from 'react';
|
|
|
import { StatCard } from '../components/common/StatCard';
|
|
|
-import { Layers, FolderTree, Hammer, Target, Wrench } from 'lucide-react';
|
|
|
+import { Layers, FolderTree, Hammer, Target, Wrench, ChevronRight, ChevronDown, Brain, FileText } from 'lucide-react';
|
|
|
import { CategoryTree } from '../components/dashboard/CategoryTree';
|
|
|
import { cn } from '../lib/utils';
|
|
|
import { EntityTag } from '../components/common/EntityTag';
|
|
|
-import { getRequirements, getCapabilities, getTools } from '../services/api';
|
|
|
+import { getRequirements, getCapabilities, getTools, getKnowledge } from '../services/api';
|
|
|
+
|
|
|
+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>
|
|
|
+ {open && <div className="pl-1 mb-8">{children}</div>}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function ExpandableDetailsItem({ data, type }: { data: any, type: 'req' | 'cap' | 'tool' | 'know' }) {
|
|
|
+ const [open, setOpen] = useState(false);
|
|
|
+
|
|
|
+ let Icon: any = Target;
|
|
|
+ let iconColor = "text-indigo-500";
|
|
|
+ let title = "";
|
|
|
+ let content = "";
|
|
|
+ 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;
|
|
|
+ content = data.description || "暂无原子能力详细描述";
|
|
|
+ } else if (type === 'tool') {
|
|
|
+ Icon = Wrench; iconColor = "text-emerald-500";
|
|
|
+ title = data.name || data.id;
|
|
|
+ content = data.introduction || "暂无工具的详细介绍。";
|
|
|
+ status = data.status;
|
|
|
+ } else if (type === 'know') {
|
|
|
+ Icon = FileText; iconColor = "text-violet-500";
|
|
|
+ title = data.task || data.content?.substring(0, 40) || data.id;
|
|
|
+ content = data.content || "暂无正文内容源文件";
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm hover:border-slate-300 transition-colors mb-2 w-full text-left">
|
|
|
+ <div
|
|
|
+ className="flex justify-between items-center p-3 cursor-pointer hover:bg-slate-50 transition-colors"
|
|
|
+ onClick={() => setOpen(!open)}
|
|
|
+ >
|
|
|
+ <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>
|
|
|
+ <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>
|
|
|
+ )}
|
|
|
+ {open ? <ChevronDown size={14} className="text-slate-400"/> : <ChevronRight size={14} className="text-slate-400"/>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {open && (
|
|
|
+ <div className="p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
|
|
|
+ <div className="text-[10px] text-slate-400 font-mono">ID: {data.id}</div>
|
|
|
+ {content && <div className="text-xs text-slate-600 leading-relaxed whitespace-pre-wrap max-h-[400px] overflow-y-auto scrollbar-thin">{content}</div>}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
|
|
|
export function Dashboard() {
|
|
|
const [treeData, setTreeData] = useState<any>(null);
|
|
|
const [selectedNode, setSelectedNode] = useState<any>(null);
|
|
|
+ const [dbData, setDbData] = useState<{ reqs: any[], caps: any[], tools: any[], know: any[] }>({
|
|
|
+ reqs: [], caps: [], tools: [], know: []
|
|
|
+ });
|
|
|
|
|
|
const [coverageStats, setCoverageStats] = useState({
|
|
|
totalLeaves: 0,
|
|
|
@@ -41,16 +119,26 @@ export function Dashboard() {
|
|
|
const leaves = getLeafNodes([data]);
|
|
|
const totalLeaves = leaves.length;
|
|
|
|
|
|
- // 2. Fetch associations (use limit 1000 to respect backend constraints)
|
|
|
+ // 2. Fetch associations
|
|
|
const [reqRes, capRes, toolRes] = await Promise.all([
|
|
|
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);
|
|
|
+ }
|
|
|
+
|
|
|
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[]> = {};
|
|
|
@@ -158,7 +246,7 @@ export function Dashboard() {
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
- <div className="flex flex-col xl:flex-row gap-6 items-start">
|
|
|
+ <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} />
|
|
|
@@ -203,31 +291,71 @@ export function Dashboard() {
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {selectedNode.elements && selectedNode.elements.length > 0 && (
|
|
|
- <div>
|
|
|
- <h3 className="font-bold text-slate-800 mb-3 text-sm flex items-center justify-between">
|
|
|
- 本节点直接关联的需求项 <span className="bg-slate-100 text-slate-500 px-2 py-0.5 rounded-full text-xs">{selectedNode.element_count || selectedNode.elements.length}</span>
|
|
|
- </h3>
|
|
|
- <div className="space-y-2 max-h-[300px] overflow-y-auto pr-2">
|
|
|
- {selectedNode.elements.map((el: any, idx: number) => (
|
|
|
- <div key={idx} className="bg-white p-3 rounded-xl border border-slate-200 shadow-sm hover:border-indigo-300 transition-colors">
|
|
|
- <div className="flex justify-between items-start mb-2">
|
|
|
- <span className="font-bold text-sm text-slate-800">{el.name}</span>
|
|
|
- <span className="text-xs font-bold text-indigo-500 bg-indigo-50 px-2 py-1 rounded-full">{el.count} 热度</span>
|
|
|
- </div>
|
|
|
- {el.post_ids && el.post_ids.length > 0 && (
|
|
|
- <div className="flex flex-wrap gap-1 mt-2">
|
|
|
- {el.post_ids.slice(0, 5).map((pid: string) => (
|
|
|
- <EntityTag key={pid} type="requirement" label={pid.substring(0,6)} />
|
|
|
- ))}
|
|
|
- {el.post_ids.length > 5 && <span className="text-[10px] text-slate-400 self-center">+{el.post_ids.length - 5}</span>}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {/* Dynamic Relations Panel */}
|
|
|
+ {(() => {
|
|
|
+ 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([selectedNode]).map(l => l.name);
|
|
|
+
|
|
|
+ const relatedReqs = dbData.reqs.filter((r: any) =>
|
|
|
+ (r.source_nodes || []).some((sn: any) => leafNames.includes(typeof sn === 'object' ? (sn.node_name || sn.name) : sn))
|
|
|
+ );
|
|
|
+
|
|
|
+ const relatedReqIds = new Set(relatedReqs.map((r: any) => r.id));
|
|
|
+ const relatedCaps = dbData.caps.filter((c: any) =>
|
|
|
+ (c.requirements || []).some((rid: string) => relatedReqIds.has(rid))
|
|
|
+ );
|
|
|
+
|
|
|
+ const relatedCapIds = new Set(relatedCaps.map((c: any) => c.id));
|
|
|
+ const relatedTools = dbData.tools.filter((t: any) =>
|
|
|
+ (t.capabilities || []).some((cid: string) => relatedCapIds.has(cid))
|
|
|
+ );
|
|
|
+
|
|
|
+ const relatedToolIds = new Set(relatedTools.map((t: any) => t.id));
|
|
|
+ const relatedKnow = dbData.know.filter((k: any) => {
|
|
|
+ const hasCap = (k.support_capability || []).some((cid: string) => relatedCapIds.has(cid));
|
|
|
+ const hasTool = (k.tools || []).some((tid: string) => relatedToolIds.has(tid));
|
|
|
+ return hasCap || hasTool;
|
|
|
+ });
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="pt-2">
|
|
|
+ <RelationGroup title="关联需求" count={relatedReqs.length} colorClass="text-indigo-600" borderClass="bg-indigo-600">
|
|
|
+ <div className="space-y-1">
|
|
|
+ {relatedReqs.map((r: any) => <ExpandableDetailsItem key={r.id} data={r} type="req" />)}
|
|
|
+ {relatedReqs.length === 0 && <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未检索到任何需求</div>}
|
|
|
</div>
|
|
|
- ))}
|
|
|
+ </RelationGroup>
|
|
|
+
|
|
|
+ <RelationGroup title="原子能力" count={relatedCaps.length} colorClass="text-amber-700" borderClass="bg-amber-700">
|
|
|
+ <div className="space-y-1">
|
|
|
+ {relatedCaps.map((c: any) => <ExpandableDetailsItem key={c.id} data={c} type="cap" />)}
|
|
|
+ {relatedCaps.length === 0 && <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">能力库为空</div>}
|
|
|
+ </div>
|
|
|
+ </RelationGroup>
|
|
|
+
|
|
|
+ <RelationGroup title="实现工具" count={relatedTools.length} colorClass="text-emerald-700" borderClass="bg-emerald-700">
|
|
|
+ <div className="space-y-1">
|
|
|
+ {relatedTools.map((t: any) => <ExpandableDetailsItem key={t.id} data={t} type="tool" />)}
|
|
|
+ {relatedTools.length === 0 && <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">未发现支持本能力的执行工具</div>}
|
|
|
+ </div>
|
|
|
+ </RelationGroup>
|
|
|
+
|
|
|
+ <RelationGroup title="支撑知识" count={relatedKnow.length} colorClass="text-violet-700" borderClass="bg-violet-700">
|
|
|
+ <div className="space-y-1">
|
|
|
+ {relatedKnow.map((k: any) => <ExpandableDetailsItem key={k.id} data={k} type="know" />)}
|
|
|
+ {relatedKnow.length === 0 && <div className="text-xs text-slate-400 pl-4 border-l-2 border-slate-100">无相关文档资料</div>}
|
|
|
+ </div>
|
|
|
+ </RelationGroup>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ );
|
|
|
+ })()}
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|