DataFlowRenderer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. "use client";
  2. import { useMemo, useState } from "react";
  3. import { AlertTriangle, ArrowDown, Check, ChevronRight, CircleDashed, Database, FileOutput, GitBranch, Link2, ScrollText } from "lucide-react";
  4. import { RichText } from "@/components/ui/RichText";
  5. import { ValueView } from "@/components/ui/ValueView";
  6. import type { CardBusinessData, CardDataCompleteness, CardDataField, CardDataSourceKind, RuntimeUnit } from "@/lib/types";
  7. interface Props {
  8. data?: CardBusinessData;
  9. loading?: boolean;
  10. error?: string;
  11. missingRef?: boolean;
  12. }
  13. const sourceLabels: Record<CardDataSourceKind, string> = {
  14. database: "数据库",
  15. "runtime-event": "运行事件",
  16. artifact: "产物",
  17. calculation: "计算生成",
  18. "log-anchor": "日志锚点",
  19. };
  20. const relationLabels: Record<CardBusinessData["relationKind"], string> = {
  21. "single-event": "单次运行事件",
  22. "event-sequence": "事件序列",
  23. "business-record": "业务记录",
  24. aggregate: "多条记录聚合",
  25. calculated: "计算生成",
  26. missing: "运行关系缺失",
  27. };
  28. const completenessLabels: Record<CardDataCompleteness, string> = {
  29. complete: "记录完整",
  30. partial: "部分记录",
  31. missing: "记录缺失",
  32. };
  33. export function DataFlowRenderer({ data, loading, error, missingRef }: Props) {
  34. if (loading) return <DataFlowSkeleton />;
  35. if (error) return <DataFlowState title="数据流暂时不可用" description={error} />;
  36. if (missingRef) return <DataFlowState title="没有稳定的数据定位" description="这张摘要卡没有独立业务详情,不会推断或拼接它与运行记录的关系。" />;
  37. if (!data) return <DataFlowState title="还没有数据流记录" description="打开其他卡片,查看其业务输入、运行过程和业务输出。" />;
  38. const sourceIndex = new Map<string, number>();
  39. [...data.businessInputs, ...data.businessOutputs].forEach((field) => {
  40. if (!sourceIndex.has(field.id)) sourceIndex.set(field.id, sourceIndex.size + 1);
  41. });
  42. data.runtime.units.forEach((unit) => {
  43. if (!sourceIndex.has(unit.id)) sourceIndex.set(unit.id, sourceIndex.size + 1);
  44. });
  45. return <div className="dataFlow" aria-label="卡片数据流">
  46. <header className="dataFlowOverview">
  47. <div className="dataFlowOverviewIcon" aria-hidden="true"><GitBranch size={18} /></div>
  48. <div>
  49. <span>{data.cardKind || "业务卡片"}</span>
  50. <h3>数据关系与完整性</h3>
  51. </div>
  52. <div className="dataFlowOverviewBadges">
  53. <span>{relationLabels[data.relationKind] || data.relationKind}</span>
  54. <span className={`is-${data.completeness}`}>{completenessLabels[data.completeness]}</span>
  55. </div>
  56. </header>
  57. {data.notices.length ? <div className="dataFlowNotices" aria-label="数据说明">
  58. {data.notices.map((notice, index) => <p key={`${notice.message}-${index}`} className={`is-${notice.level}`}><AlertTriangle size={14} aria-hidden="true" />{notice.message}</p>)}
  59. </div> : null}
  60. <FlowSection stage="input" title="业务输入" description="这一步开始前,可确认获得的信息。">
  61. <FieldList fields={data.businessInputs} sourceIndex={sourceIndex} empty="没有记录到可确认的业务输入。" />
  62. </FlowSection>
  63. <FlowConnector />
  64. <FlowSection stage="runtime" title="运行过程 / I/O" description={data.runtime.summary || "本次运行没有留下过程概要。"}>
  65. {data.runtime.units.length ? <div className="runtimeUnitList">
  66. {data.runtime.units.map((unit, index) => <RuntimeUnitDisclosure key={unit.id || index} unit={unit} index={index + 1} sourceNumber={sourceIndex.get(unit.id)} />)}
  67. </div> : <EmptyLine>没有保存可下钻的运行单元。</EmptyLine>}
  68. {data.runtime.calculation ? <div className="dataFlowCalculation"><strong>聚合方式</strong><LongValue value={data.runtime.calculation} /></div> : null}
  69. </FlowSection>
  70. <FlowConnector />
  71. <FlowSection stage="output" title="业务输出" description="运行完成后实际形成或保存的结果。">
  72. <FieldList fields={data.businessOutputs} sourceIndex={sourceIndex} empty="没有记录到可确认的业务输出。" />
  73. </FlowSection>
  74. <FlowConnector muted />
  75. <DisplayUse data={data} sourceIndex={sourceIndex} />
  76. </div>;
  77. }
  78. function FlowSection({ stage, title, description, children }: { stage: "input" | "runtime" | "output"; title: string; description: string; children: React.ReactNode }) {
  79. return <section className="dataFlowStage" data-stage={stage} aria-labelledby={`data-flow-${stage}`}>
  80. <header>
  81. <span className="dataFlowStageIcon" aria-hidden="true">{stage === "input" ? <Database size={16} /> : stage === "runtime" ? <CircleDashed size={16} /> : <FileOutput size={16} />}</span>
  82. <div><h3 id={`data-flow-${stage}`}>{title}</h3><p>{description}</p></div>
  83. </header>
  84. {children}
  85. </section>;
  86. }
  87. function FlowConnector({ muted = false }: { muted?: boolean }) {
  88. return <div className={`dataFlowConnector ${muted ? "is-muted" : ""}`} aria-hidden="true"><ArrowDown size={14} /></div>;
  89. }
  90. function FieldList({ fields, sourceIndex, empty }: { fields: CardDataField[]; sourceIndex: Map<string, number>; empty: string }) {
  91. if (!fields.length) return <EmptyLine>{empty}</EmptyLine>;
  92. return <div className="dataFieldList">
  93. {fields.map((field, index) => <article className="dataField" key={`${field.id}-${index}`}>
  94. <header>
  95. <SourceMarker number={sourceIndex.get(field.id)} />
  96. <h4>{field.label}</h4>
  97. <span className={`dataCompleteness is-${field.completeness}`}>{completenessLabels[field.completeness]}</span>
  98. </header>
  99. <LongValue value={field.value} />
  100. <footer>
  101. <SourceBadge kind={field.source.kind} />
  102. <span>{field.source.label}</span>
  103. {field.source.ref ? <code>{field.source.ref}</code> : null}
  104. {field.source.fieldPath ? <code>{field.source.fieldPath}</code> : null}
  105. <span className="dataRelation">{fieldRelationLabel(field)}</span>
  106. </footer>
  107. </article>)}
  108. </div>;
  109. }
  110. function RuntimeUnitDisclosure({ unit, index, sourceNumber }: { unit: RuntimeUnit; index: number; sourceNumber?: number }) {
  111. const [open, setOpen] = useState(false);
  112. const title = unit.label || `运行单元 ${index}`;
  113. return <details className="runtimeUnit" open={open}>
  114. <summary aria-expanded={open} onClick={(event) => { event.preventDefault(); setOpen((current) => !current); }}>
  115. <ChevronRight className="dataDisclosureChevron" size={15} aria-hidden="true" />
  116. <SourceMarker number={sourceNumber} />
  117. <strong>{title}</strong>
  118. {unit.status ? <span className={`runtimeStatus is-${statusClass(unit.status)}`}>{unit.status}</span> : null}
  119. {typeof unit.durationMs === "number" ? <small>{formatDuration(unit.durationMs)}</small> : null}
  120. </summary>
  121. <div className="runtimeUnitBody">
  122. {unit.summary ? <LongValue value={unit.summary} /> : null}
  123. {unit.input !== undefined ? <RuntimeValue label="输入" value={unit.input} /> : null}
  124. {unit.output !== undefined ? <RuntimeValue label="输出" value={unit.output} /> : null}
  125. {unit.eventRef ? <p className="runtimeEventRef"><span>运行事件</span><code>{unit.eventRef}</code></p> : null}
  126. {unit.completeness ? <p className="runtimeCompleteness"><Check size={13} aria-hidden="true" />{completenessLabels[unit.completeness]}</p> : null}
  127. </div>
  128. </details>;
  129. }
  130. function RuntimeValue({ label, value }: { label: string; value: unknown }) {
  131. return <section className="runtimeValue"><h5>{label}</h5><LongValue value={value} /></section>;
  132. }
  133. function DisplayUse({ data, sourceIndex }: { data: CardBusinessData; sourceIndex: Map<string, number> }) {
  134. const { businessModules, cardFields, unusedSourceIds } = data.displayUse;
  135. return <section className="displayUse" aria-labelledby="data-flow-display-use">
  136. <header><span className="dataFlowStageIcon" aria-hidden="true"><Link2 size={16} /></span><div><h3 id="data-flow-display-use">页面如何使用这些数据</h3><p>同一编号把原始字段、业务详情模块与卡片摘要对应起来。</p></div></header>
  137. <div className="displayUseGroups">
  138. <MappingGroup title="业务详情" items={businessModules.map((module) => ({ key: module.id, title: module.title, sourceIds: module.sourceIds }))} sourceIndex={sourceIndex} />
  139. <MappingGroup title="卡片摘要" items={cardFields.map((field) => ({ key: field.key, title: field.label, sourceIds: field.sourceIds, note: field.transform }))} sourceIndex={sourceIndex} />
  140. </div>
  141. {unusedSourceIds.length ? <div className="unusedSources"><strong>未用于页面展示</strong><SourceReferences ids={unusedSourceIds} sourceIndex={sourceIndex} /></div> : null}
  142. </section>;
  143. }
  144. function MappingGroup({ title, items, sourceIndex }: { title: string; items: Array<{ key: string; title: string; sourceIds: string[]; note?: string }>; sourceIndex: Map<string, number> }) {
  145. return <section className="mappingGroup"><h4>{title}</h4>{items.length ? <ul>{items.map((item) => <li key={item.key}><div><strong>{item.title}</strong>{item.note ? <small>{item.note}</small> : null}</div><SourceReferences ids={item.sourceIds} sourceIndex={sourceIndex} /></li>)}</ul> : <EmptyLine>没有配置展示映射。</EmptyLine>}</section>;
  146. }
  147. function SourceReferences({ ids, sourceIndex }: { ids: string[]; sourceIndex: Map<string, number> }) {
  148. if (!ids.length) return <span className="sourceReferenceEmpty">无直接来源</span>;
  149. return <span className="sourceReferences">{ids.map((id) => <span key={id} title={id}>F{sourceIndex.get(id) || "?"}</span>)}</span>;
  150. }
  151. function SourceMarker({ number }: { number?: number }) {
  152. return <span className="sourceMarker" title={number ? `数据字段 F${number}` : "运行单元"}>{number ? `F${number}` : "I/O"}</span>;
  153. }
  154. function SourceBadge({ kind }: { kind: CardDataSourceKind }) {
  155. const Icon = kind === "database" ? Database : kind === "artifact" ? FileOutput : kind === "log-anchor" ? ScrollText : kind === "runtime-event" ? CircleDashed : GitBranch;
  156. return <span className={`dataSourceBadge is-${kind}`}><Icon size={12} aria-hidden="true" />{sourceLabels[kind]}</span>;
  157. }
  158. function LongValue({ value }: { value: unknown }) {
  159. const { text, structured } = useMemo(() => displayValue(value), [value]);
  160. const [open, setOpen] = useState(false);
  161. if (text.length <= 800) return structured ? <ValueView value={value} /> : <RichText className="dataFlowText" value={text || "(空)"} />;
  162. const preview = `${text.slice(0, 240).trimEnd()}…`;
  163. return <details className="longDataValue" open={open}>
  164. <summary aria-expanded={open} onClick={(event) => { event.preventDefault(); setOpen((current) => !current); }}>
  165. <span className="longDataSummaryLine"><ChevronRight className="dataDisclosureChevron" size={15} aria-hidden="true" /><span>{open ? "收起完整内容" : "展开完整内容"}</span><small>{text.length.toLocaleString("zh-CN")} 字符</small></span>
  166. {!open ? <span className="longDataPreview">{preview}</span> : null}
  167. </summary>
  168. {open ? structured ? <ValueView value={value} /> : <RichText className="dataFlowText" value={text} /> : null}
  169. </details>;
  170. }
  171. function EmptyLine({ children }: { children: React.ReactNode }) { return <p className="dataFlowEmpty">{children}</p>; }
  172. function DataFlowState({ title, description }: { title: string; description: string }) {
  173. return <div className="dataFlowState" role="status"><GitBranch size={20} aria-hidden="true" /><h3>{title}</h3><p>{description}</p></div>;
  174. }
  175. function DataFlowSkeleton() {
  176. return <div className="dataFlowSkeleton" aria-label="正在加载数据流"><div /><span /><span /><div /><span /><span /><div /><span /></div>;
  177. }
  178. function displayValue(value: unknown) {
  179. if (typeof value === "string") return { text: value, structured: false };
  180. if (value === undefined) return { text: "(未记录)", structured: false };
  181. if (value === null) return { text: "(空)", structured: false };
  182. try { return { text: JSON.stringify(value, null, 2), structured: true }; }
  183. catch { return { text: String(value), structured: false }; }
  184. }
  185. function fieldRelationLabel(field: CardDataField) {
  186. if (field.relation === "available-upstream") return "可确认是上游信息,但无法确认是否被本次决策采用";
  187. return ({
  188. "direct-input": "本次运行的直接输入",
  189. "previous-result": "上一步形成的结果",
  190. "standing-constraint": "持续生效的约束",
  191. "explicit-basis": "记录明确采用的依据",
  192. "persisted-output": "已保存的业务输出",
  193. } as Record<string, string>)[field.relation] || field.relation;
  194. }
  195. function statusClass(value: string) {
  196. const normalized = value.toLowerCase();
  197. if (["success", "completed", "hit", "passed"].includes(normalized)) return "success";
  198. if (["failed", "failure", "error"].includes(normalized)) return "failure";
  199. if (["running", "processing"].includes(normalized)) return "running";
  200. return "neutral";
  201. }
  202. function formatDuration(value: number) {
  203. if (value < 1000) return `${value}ms`;
  204. if (value < 60_000) return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}s`;
  205. return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
  206. }