ComparisonRowCells.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. "use client";
  2. import { useMemo, useState } from "react";
  3. import { Activity, Calculator, Database, FileText, History, ScrollText } from "lucide-react";
  4. import type { InspectorComparisonRow, InspectorModule, SourceBinding, SourceRecord, SourceSelector } from "@/lib/types";
  5. import { BindingBadge, selectorDescription } from "@/components/inspector/BindingBadge";
  6. import { BusinessValueRenderer } from "@/components/inspector/BusinessValueRenderer";
  7. import { LongValueDisclosure } from "@/components/inspector/LongValueDisclosure";
  8. interface RowProps {
  9. row: InspectorComparisonRow;
  10. module: InspectorModule;
  11. businessProjection: unknown;
  12. sources: Record<string, SourceRecord>;
  13. markers: Map<string, string>;
  14. activeBindingId?: string;
  15. onActivate: (bindingId: string, toggle?: boolean) => void;
  16. }
  17. export function ComparisonRowCells(props: RowProps) {
  18. const bindings = props.row.bindingIds
  19. .map((id) => props.module.bindings.find((binding) => binding.id === id))
  20. .filter((binding): binding is SourceBinding => Boolean(binding));
  21. const businessResult = pointerValue(props.businessProjection, props.row.businessSelector);
  22. const active = bindings.some((binding) => binding.id === props.activeBindingId);
  23. return <article className={`comparisonItemRow ${active ? "is-active" : ""}`} data-row-id={props.row.id}>
  24. <section className="comparisonItemBusiness" aria-label="业务详情中的这一项">
  25. {props.row.label ? <h4>{props.row.label}</h4> : null}
  26. {businessResult.found
  27. ? <BusinessValueRenderer module={props.module} value={businessResult.value} label={props.row.label || props.module.title} />
  28. : <p className="comparisonEmptyValue">业务详情中的这一项无法定位。</p>}
  29. </section>
  30. <section className="comparisonItemEvidence" aria-label="这一项的数据依据">
  31. {bindings.length ? bindings.map((binding) => <EvidenceSummary
  32. key={binding.id}
  33. binding={binding}
  34. source={props.sources[binding.sourceId]}
  35. marker={props.markers.get(binding.id) || "S?"}
  36. active={props.activeBindingId === binding.id}
  37. onActivate={props.onActivate}
  38. />) : <p className="comparisonEmptyValue">没有返回来源绑定。</p>}
  39. </section>
  40. <section className="comparisonItemSource" aria-label="对应的原始字段或原文">
  41. {bindings.length ? bindings.map((binding) => <SourceExcerpt
  42. key={binding.id}
  43. binding={binding}
  44. source={props.sources[binding.sourceId]}
  45. sources={props.sources}
  46. marker={props.markers.get(binding.id) || "S?"}
  47. />) : <p className="comparisonEmptyValue">没有可展示的原始记录。</p>}
  48. </section>
  49. </article>;
  50. }
  51. function EvidenceSummary({ binding, source, marker, active, onActivate }: { binding: SourceBinding; source?: SourceRecord; marker: string; active: boolean; onActivate: RowProps["onActivate"] }) {
  52. return <div className={`comparisonEvidenceSummary ${active ? "is-active" : ""}`} data-binding-id={binding.id}>
  53. <BindingBadge binding={binding} marker={marker} active={active} onActivate={onActivate} />
  54. <div className="comparisonEvidenceCopy">
  55. <strong>{source?.label || (binding.selector.kind === "unresolved" ? "无法定位来源" : binding.sourceId)}</strong>
  56. <small>{roleLabel(binding.role)} · {transformLabel(binding)}</small>
  57. <details className="comparisonLocatorDetails">
  58. <summary>定位详情</summary>
  59. <code>{selectorDescription(binding)}</code>
  60. <small>{confidenceLabel(binding.evidence.confidence)}{binding.resolution !== "resolved" ? ` · ${resolutionLabel(binding.resolution)}` : ""}</small>
  61. </details>
  62. </div>
  63. </div>;
  64. }
  65. function SourceExcerpt({ binding, source, sources, marker }: { binding: SourceBinding; source?: SourceRecord; sources: Record<string, SourceRecord>; marker: string }) {
  66. const [rawOpen, setRawOpen] = useState(false);
  67. const excerpt = useMemo(() => resolveExcerpt(binding.selector, source, sources), [binding.selector, source, sources]);
  68. const Icon = sourceIcon(source?.kind);
  69. return <div className={`comparisonSourceExcerpt is-${source?.resultState || "present"}`} data-binding-id={binding.id}>
  70. <header>
  71. <Icon aria-hidden="true" size={15} />
  72. <div><strong>{source?.label || "原始记录不可用"}</strong><small>{sourceKindLabel(source?.kind)}</small></div>
  73. <span className="sourceExcerptMarker">{marker}</span>
  74. </header>
  75. {source ? <SourceMeta source={source} /> : null}
  76. {source?.resultState === "empty" ? <div className="sourceEmptyResult"><strong>查询完成,返回 0 条</strong><span>这不是记录缺失。</span></div> : null}
  77. {excerpt.kind === "span" ? <TextSpanExcerpt before={excerpt.before} exact={excerpt.exact} after={excerpt.after} />
  78. : excerpt.kind === "members" ? <div className="sourceMemberList">{excerpt.items.map((item, index) => <section key={`${item.label}-${index}`}><strong>{item.label}</strong><LongValueDisclosure value={item.value} label="完整成员值" /></section>)}</div>
  79. : excerpt.kind === "missing" ? <p className="sourceMissingReason">{excerpt.reason}</p>
  80. : <LongValueDisclosure value={excerpt.value} label="完整字段值" />}
  81. {source?.rawRecord !== undefined && source?.rawRecord !== null ? <details className="rawRecordDisclosure" onToggle={(event) => setRawOpen(event.currentTarget.open)}>
  82. <summary><span>完整原始记录</span><small>{source.truncated ? `已省略 ${source.omittedCharacters || 0} 字符` : "按需展开"}</small></summary>
  83. {rawOpen ? <div><LongValueDisclosure value={source.rawRecord} label="完整原始记录" threshold={Number.MAX_SAFE_INTEGER} /></div> : null}
  84. </details> : null}
  85. </div>;
  86. }
  87. type Excerpt =
  88. | { kind: "value"; value: unknown }
  89. | { kind: "span"; before: string; exact: string; after: string }
  90. | { kind: "members"; items: Array<{ label: string; value: unknown }> }
  91. | { kind: "missing"; reason: string };
  92. function resolveExcerpt(selector: SourceSelector, source: SourceRecord | undefined, sources: Record<string, SourceRecord>): Excerpt {
  93. if (selector.kind === "unresolved") return { kind: "missing", reason: selector.reason };
  94. if (!source) return { kind: "missing", reason: "来源记录没有随接口响应返回。" };
  95. if (selector.kind === "whole-record") return { kind: "value", value: source.rawRecord };
  96. if (selector.kind === "json-pointer") {
  97. const selected = pointerValue(source.rawRecord, selector.path);
  98. return selected.found ? { kind: "value", value: selected.value } : { kind: "missing", reason: `原始记录中不存在字段 ${selector.path}` };
  99. }
  100. if (selector.kind === "text-span") {
  101. const selected = pointerValue(source.rawRecord, selector.path);
  102. if (!selected.found || typeof selected.value !== "string") return { kind: "missing", reason: `原始记录中不存在文本字段 ${selector.path}` };
  103. const characters = Array.from(selected.value);
  104. const start = selector.startCodePoint;
  105. const end = selector.endCodePoint;
  106. return {
  107. kind: "span",
  108. before: characters.slice(Math.max(0, start - 80), start).join(""),
  109. exact: characters.slice(start, end).join("") || selector.exactText,
  110. after: characters.slice(end, end + 80).join(""),
  111. };
  112. }
  113. const items = selector.members.map((member, index) => {
  114. const memberSource = sources[member.sourceId];
  115. const resolved = resolveExcerpt(member.selector, memberSource, sources);
  116. return {
  117. label: memberSource?.label || `参与记录 ${index + 1}`,
  118. value: resolved.kind === "value" ? resolved.value
  119. : resolved.kind === "span" ? `${resolved.before}${resolved.exact}${resolved.after}`
  120. : resolved.kind === "missing" ? resolved.reason
  121. : resolved.items.map((item) => item.value),
  122. };
  123. });
  124. return { kind: "members", items };
  125. }
  126. function TextSpanExcerpt({ before, exact, after }: { before: string; exact: string; after: string }) {
  127. return <p className="comparisonTextExcerpt">{before ? <span>{before}</span> : null}<mark>{exact}</mark>{after ? <span>{after}</span> : null}</p>;
  128. }
  129. function pointerValue(value: unknown, pointer: string): { found: boolean; value: unknown } {
  130. if (!pointer) return { found: true, value };
  131. let current = value;
  132. for (const rawPart of pointer.replace(/^\//, "").split("/")) {
  133. const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
  134. if (Array.isArray(current)) {
  135. const index = Number(part);
  136. if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false, value: undefined };
  137. current = current[index];
  138. } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, part)) {
  139. current = (current as Record<string, unknown>)[part];
  140. } else return { found: false, value: undefined };
  141. }
  142. return { found: true, value: current };
  143. }
  144. function SourceMeta({ source }: { source: SourceRecord }) {
  145. const locator = typeof source.locator === "string" ? undefined : source.locator;
  146. const fields = locator ? Object.entries(locator).filter(([, value]) => value !== undefined && value !== null && value !== "") : [];
  147. return fields.length || source.status || typeof source.durationMs === "number" ? <dl className="sourceMeta">
  148. {fields.map(([key, value]) => <div key={key}><dt>{locatorLabel(key)}</dt><dd><code>{printMeta(value)}</code></dd></div>)}
  149. {source.status ? <div><dt>状态</dt><dd>{source.status}</dd></div> : null}
  150. {typeof source.durationMs === "number" ? <div><dt>耗时</dt><dd>{source.durationMs >= 1000 ? `${(source.durationMs / 1000).toFixed(1)} 秒` : `${source.durationMs} ms`}</dd></div> : null}
  151. </dl> : null;
  152. }
  153. function sourceIcon(kind?: string) {
  154. if (kind === "database") return Database;
  155. if (kind === "runtime-event") return Activity;
  156. if (kind === "artifact") return FileText;
  157. if (kind === "log-anchor") return ScrollText;
  158. if (kind === "calculation") return Calculator;
  159. return History;
  160. }
  161. function sourceKindLabel(kind?: string) { return ({ database: "数据库记录", "runtime-event": "运行事件 / Event Body", artifact: "脚本或候选产物", "log-anchor": "日志锚点", calculation: "可视化确定性计算", "historical-fallback": "历史回退", missing: "原始记录缺失" } as Record<string, string>)[kind || "missing"] || kind || "原始记录缺失"; }
  162. function roleLabel(role: SourceBinding["role"]) { return ({ input: "输入", basis: "判断依据", constraint: "约束", process: "运行过程", output: "产出", status: "状态" } as const)[role]; }
  163. function transformLabel(binding: SourceBinding) { const value = binding.transform; return value.kind === "direct" ? "直接展示" : value.kind === "parsed" ? "确定性解析" : value.kind === "combined" ? "多来源组合" : value.kind === "calculated" ? "确定性计算" : "历史回退"; }
  164. function confidenceLabel(value: SourceBinding["evidence"]["confidence"]) { return ({ exact: "精确对应", "safe-association": "可安全关联", deterministic: "确定性生成", unconfirmed: "关联未确认" } as const)[value]; }
  165. function resolutionLabel(value: SourceBinding["resolution"]) { return ({ partial: "部分定位", missing: "记录缺失", unsafe: "无法安全关联", resolved: "已定位" } as const)[value]; }
  166. function locatorLabel(key: string) { return ({ table: "数据库表", recordId: "记录 ID", eventId: "Event ID", eventName: "Event 名称", eventType: "Event 类型", artifactRef: "产物", logAnchor: "日志锚点", fieldPath: "字段路径", filters: "查询条件", resultCount: "结果数量", reason: "缺失原因" } as Record<string, string>)[key] || key; }
  167. function printMeta(value: unknown) { if (typeof value === "string") return value; try { return JSON.stringify(value); } catch { return String(value); } }