InspectorComparison.tsx 6.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. "use client";
  2. import { useEffect, useMemo, useState } from "react";
  3. import { AlertTriangle, ChevronDown, ChevronRight, Columns3, UnfoldVertical } from "lucide-react";
  4. import type { InspectorModule, InspectorWorkbenchProjection, RetrievalActivity, SourceBinding } from "@/lib/types";
  5. import { ComparisonRowCells } from "@/components/inspector/ComparisonRowCells";
  6. interface Props {
  7. data?: InspectorWorkbenchProjection;
  8. loading?: boolean;
  9. error?: string;
  10. onNavigate?: (activity: RetrievalActivity) => void;
  11. onActiveBindingChange?: (bindingId?: string) => void;
  12. clearActiveSignal?: number;
  13. }
  14. export function InspectorComparison({ data, loading, error, onActiveBindingChange, clearActiveSignal }: Props) {
  15. const [openModules, setOpenModules] = useState<Set<string>>(new Set());
  16. const [activeBindingId, setActiveBindingId] = useState<string>();
  17. const markers = useMemo(() => bindingMarkers(data), [data]);
  18. const moduleSignature = (data?.modules || []).map((module) => `${module.id}:${module.defaultOpen}`).join("|");
  19. useEffect(() => {
  20. setOpenModules(new Set((data?.modules || []).filter((module) => module.defaultOpen).map((module) => module.id)));
  21. setActiveBindingId(undefined);
  22. onActiveBindingChange?.(undefined);
  23. }, [data?.detailRef, moduleSignature, onActiveBindingChange]);
  24. useEffect(() => {
  25. if (clearActiveSignal === undefined) return;
  26. setActiveBindingId(undefined);
  27. }, [clearActiveSignal]);
  28. if (loading) return <ComparisonSkeleton />;
  29. if (error) return <ComparisonState title="数据来源读取失败" message={error} />;
  30. if (!data) return <ComparisonState title="没有可对照的数据" message="当前卡片没有统一来源记录。" />;
  31. const allOpen = data.modules.length > 0 && data.modules.every((module) => openModules.has(module.id));
  32. const activate = (bindingId: string, toggle = true) => {
  33. const next = toggle && activeBindingId === bindingId ? undefined : bindingId;
  34. setActiveBindingId(next);
  35. onActiveBindingChange?.(next);
  36. if (!next) return;
  37. requestAnimationFrame(() => document.querySelector<HTMLElement>(`.inspectorComparison [data-binding-id="${cssEscape(next)}"]`)?.scrollIntoView({ block: "nearest", behavior: "smooth" }));
  38. };
  39. const clearActive = () => { setActiveBindingId(undefined); onActiveBindingChange?.(undefined); };
  40. return <div className="inspectorComparison" data-completeness={data.completeness} onKeyDown={(event) => { if (event.key === "Escape" && activeBindingId) { event.preventDefault(); event.stopPropagation(); clearActive(); } }}>
  41. <div className="comparisonToolbar">
  42. <div><Columns3 aria-hidden="true" size={17} /><span>{data.modules.length} 个业务模块</span><b className={`comparisonCompleteness is-${data.completeness}`}>{completenessLabel(data.completeness)}</b></div>
  43. <div>{activeBindingId ? <button type="button" className="comparisonTextAction" onClick={clearActive}>清除突出</button> : null}<button type="button" className="comparisonTextAction" onClick={() => setOpenModules(allOpen ? new Set() : new Set(data.modules.map((module) => module.id)))}><UnfoldVertical aria-hidden="true" size={14} />{allOpen ? "折叠全部" : "展开全部"}</button></div>
  44. </div>
  45. {data.notices.length ? <div className="comparisonNotices">{data.notices.map((notice, index) => <p key={`${notice.code || "notice"}-${index}`} className={`is-${notice.level || "info"}`}><AlertTriangle aria-hidden="true" size={14} />{notice.message}</p>)}</div> : null}
  46. <div className="comparisonColumnHeaders" role="row"><span role="columnheader">业务详情中的内容</span><span role="columnheader">这一项的数据依据</span><span role="columnheader">对应的原始字段或原文</span></div>
  47. <div className="comparisonModules">{data.modules.map((module, index) => {
  48. const open = openModules.has(module.id);
  49. return <section key={module.id} className={`comparisonModule ${open ? "is-open" : ""}`} data-module-id={module.id}>
  50. <button type="button" className="comparisonModuleHeader" aria-expanded={open} onClick={() => setOpenModules((current) => { const next = new Set(current); next.has(module.id) ? next.delete(module.id) : next.add(module.id); return next; })}>
  51. {open ? <ChevronDown aria-hidden="true" size={16} /> : <ChevronRight aria-hidden="true" size={16} />}
  52. <span className="moduleMarker">M{index + 1}</span><strong>{module.title}</strong><small>{module.rows?.length || 0} 项业务内容</small>
  53. </button>
  54. {open ? <div className="comparisonItemRows">{(module.rows || []).map((row) => <ComparisonRowCells
  55. key={row.id}
  56. row={row}
  57. module={module}
  58. businessProjection={data.businessProjection}
  59. sources={data.sources}
  60. markers={markers}
  61. activeBindingId={activeBindingId}
  62. onActivate={activate}
  63. />)}</div> : null}
  64. </section>;
  65. })}</div>
  66. </div>;
  67. }
  68. export function allBindings(module: InspectorModule): SourceBinding[] {
  69. const seen = new Set<string>();
  70. return (module.bindings || []).filter((binding) => {
  71. if (seen.has(binding.id)) return false;
  72. seen.add(binding.id);
  73. return true;
  74. });
  75. }
  76. function bindingMarkers(data?: InspectorWorkbenchProjection) {
  77. const result = new Map<string, string>();
  78. let index = 1;
  79. for (const module of data?.modules || []) for (const binding of allBindings(module)) if (!result.has(binding.id)) result.set(binding.id, `S${index++}`);
  80. return result;
  81. }
  82. function completenessLabel(value: InspectorWorkbenchProjection["completeness"]) { return value === "complete" ? "来源完整" : value === "partial" ? "部分来源缺失" : "来源记录缺失"; }
  83. function cssEscape(value: string) { return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); }
  84. function ComparisonState({ title, message }: { title: string; message: string }) { return <div className="comparisonState"><AlertTriangle size={22} /><h3>{title}</h3><p>{message}</p></div>; }
  85. function ComparisonSkeleton() { return <div className="comparisonSkeleton" aria-label="正在加载业务详情、数据依据和原始记录"><div /><span /><span /><span /><div /><span /><span /><span /></div>; }