Inspector.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. "use client";
  2. import { useState } from "react";
  3. import { Braces, ChevronRight, CircleOff, Copy, GitFork, X } from "lucide-react";
  4. import { FieldTree } from "@/components/FieldTree";
  5. import type { FlowEdgeRecord, FlowNodeRecord, RunGraph } from "@/lib/types";
  6. type Tab = "fields" | "lineage" | "contract";
  7. function EdgeList({ title, edges, graph, direction }: {
  8. title: string;
  9. edges: FlowEdgeRecord[];
  10. graph: RunGraph;
  11. direction: "incoming" | "outgoing";
  12. }) {
  13. return <section className="inspectorSection">
  14. <h3>{title}<span>{edges.length}</span></h3>
  15. {edges.length ? <div className="edgeList">{edges.map((edge) => {
  16. const otherId = direction === "incoming" ? edge.source : edge.target;
  17. const other = graph.nodes.find((item) => item.id === otherId);
  18. return <div key={edge.id} className={`edgeRow edge-${edge.kind}`}>
  19. <GitFork size={14} />
  20. <div><strong>{other?.title ?? otherId}</strong><small>{edge.label ?? edge.kind}</small></div>
  21. <ChevronRight size={14} />
  22. </div>;
  23. })}</div> : <p className="emptyState"><CircleOff size={15} /> 无连接</p>}
  24. </section>;
  25. }
  26. export function Inspector({ item, graph, onClose }: {
  27. item: FlowNodeRecord;
  28. graph: RunGraph;
  29. onClose: () => void;
  30. }) {
  31. const [tab, setTab] = useState<Tab>("fields");
  32. const incoming = graph.edges.filter((edge) => edge.target === item.id);
  33. const outgoing = graph.edges.filter((edge) => edge.source === item.id);
  34. const expectedFields = graph.schema_catalog[item.record.model_name] ?? [];
  35. const copyJson = () => navigator.clipboard?.writeText(JSON.stringify(item.record.payload, null, 2));
  36. return <aside className="inspector">
  37. <header className="inspectorHeader">
  38. <div className={`inspectorIcon type-${item.node_type}`}><Braces size={19} /></div>
  39. <div><span>{item.phase} / {item.lane}</span><h2>{item.title}</h2></div>
  40. <button type="button" className="iconButton" onClick={onClose} aria-label="关闭详情"><X size={18} /></button>
  41. </header>
  42. <div className="inspectorMeta">
  43. <span className={`pill status-${item.status}`}>{item.status}</span>
  44. <code>{item.id}</code>
  45. </div>
  46. <nav className="inspectorTabs" aria-label="节点详情页签">
  47. <button className={tab === "fields" ? "active" : ""} onClick={() => setTab("fields")}>全部字段</button>
  48. <button className={tab === "lineage" ? "active" : ""} onClick={() => setTab("lineage")}>上下游</button>
  49. <button className={tab === "contract" ? "active" : ""} onClick={() => setTab("contract")}>字段合同</button>
  50. </nav>
  51. <div className="inspectorScroll">
  52. {tab === "fields" ? <>
  53. <section className="modelBanner">
  54. <div><small>PROJECT MODEL</small><strong>{item.record.model_name}</strong></div>
  55. <button type="button" onClick={copyJson}><Copy size={14} /> 复制 JSON</button>
  56. </section>
  57. <FieldTree value={item.record.payload} />
  58. </> : null}
  59. {tab === "lineage" ? <>
  60. <EdgeList title="输入" edges={incoming} graph={graph} direction="incoming" />
  61. <EdgeList title="输出" edges={outgoing} graph={graph} direction="outgoing" />
  62. </> : null}
  63. {tab === "contract" ? <section className="inspectorSection contractFields">
  64. <h3>当前源码字段<span>{expectedFields.length}</span></h3>
  65. <p>fake payload 必须与此清单完全同键;后端测试会拒绝遗漏和额外字段。</p>
  66. <ol>{expectedFields.map((field) => <li key={field}><code>{field}</code><span>{field in item.record.payload ? "present" : "missing"}</span></li>)}</ol>
  67. </section> : null}
  68. </div>
  69. </aside>;
  70. }