app.js 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805
  1. (() => {
  2. const runId = document.body.dataset.runId;
  3. const root = document.getElementById("audit-root");
  4. const runSummary = document.getElementById("run-summary");
  5. const moduleJump = document.getElementById("module-jump");
  6. const onlyUsedFields = document.getElementById("only-used-fields");
  7. const sourceFilter = document.getElementById("source-filter");
  8. const readColumnTitle = document.getElementById("read-column-title");
  9. const contextColumnTitle = document.getElementById("context-column-title");
  10. const detailCache = new Map();
  11. const renderedModules = new Set();
  12. const renderQueue = [];
  13. let renderScheduled = false;
  14. let runMeta = null;
  15. let moduleTotal = 0;
  16. let streamComplete = false;
  17. const jsonText = (value) => {
  18. if (value === null || value === undefined) return "";
  19. if (typeof value === "string") return value;
  20. try { return JSON.stringify(value, null, 2); } catch { return String(value); }
  21. };
  22. const chars = (value) => Array.from(jsonText(value)).length;
  23. const escapeHtml = (value) => String(value ?? "")
  24. .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
  25. .replaceAll('"', "&quot;").replaceAll("'", "&#039;");
  26. const truncate = (value, length = 240) => {
  27. const text = jsonText(value).replace(/\s+/g, " ").trim();
  28. return Array.from(text).length > length ? `${Array.from(text).slice(0, length).join("")}…` : text;
  29. };
  30. const excerptAround = (value, needle, length = 300) => {
  31. const text = jsonText(value).replace(/\s+/g, " ").trim();
  32. if (!needle || !text.includes(needle)) return truncate(text, length);
  33. const charsBefore = 80;
  34. const index = text.indexOf(needle);
  35. const start = Math.max(0, index - charsBefore);
  36. const end = Math.min(text.length, start + Math.max(length, needle.length + charsBefore));
  37. return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`;
  38. };
  39. const correlationText = (value) => {
  40. if (value === null || value === undefined) return "";
  41. if (typeof value === "string") return value.trim();
  42. if (typeof value === "number" || typeof value === "boolean") return String(value);
  43. return jsonText(value).trim();
  44. };
  45. const makeSegments = (scope, items) => items
  46. .filter((item) => item && correlationText(item.value))
  47. .map((item, index) => ({ ...item, id: `${scope}-${index + 1}`, number: index + 1, color: index % 8, text: correlationText(item.value) }));
  48. const matchingSegments = (value, segments) => {
  49. const text = correlationText(value);
  50. if (!text) return [];
  51. return segments.filter((segment) => segment.text.length >= 2 && text.includes(segment.text));
  52. };
  53. const labelForDisposition = (value) => ({ selected: "已采用", fallback: "备用来源", "calculation-input": "参与计算", unconfirmed: "来源未确认", missing: "记录缺失" }[value] || value || "已读取");
  54. const sourceKind = (source = {}) => source.sourceKind || "unknown";
  55. const sourceKindLabel = (kind) => ({ database: "数据库", function: "函数", log: "日志", unknown: "未确认" }[kind] || "未确认");
  56. const relationLabel = (value) => ({ exact: "直接摘取", "exact-record": "完整记录", structured: "结构化字段", projected: "展示处理", combined: "多来源重组", "calculation-input": "参与计算", anchored: "日志定位", missing: "记录缺失" }[value] || value || "未标记");
  57. const completenessLabel = (value) => ({ complete: "完整", "body-missing": "Event Body 缺失", missing: "记录缺失", partial: "部分可用" }[value] || value || "未知");
  58. function sourceName(source = {}) {
  59. const kind = sourceKind(source);
  60. if (kind === "database") return `数据库:${source.table || "表名未记录"}`;
  61. if (kind === "function") return `函数:${source.functionName || "Inspector 详情组装"}`;
  62. if (kind === "log") return `日志:${source.table || "script_build_log"}`;
  63. return "来源未确认";
  64. }
  65. function sourceDetail(source = {}) {
  66. return [
  67. source.recordId !== null && source.recordId !== undefined ? `记录 #${source.recordId}` : null,
  68. source.eventId !== null && source.eventId !== undefined ? `Event ${source.eventId}` : null,
  69. source.eventName || null,
  70. source.technicalGroup ? `处理组 ${source.technicalGroup}` : null,
  71. ].filter(Boolean).join(" · ");
  72. }
  73. function buildSourceRegistry(reads, contexts) {
  74. const registry = new Map();
  75. const counters = { database: 0, function: 0, log: 0, unknown: 0 };
  76. [...reads, ...contexts].forEach((item) => {
  77. const key = item.sourceKey || `unknown:${registry.size + 1}`;
  78. if (registry.has(key)) return;
  79. const kind = sourceKind(item.source || {});
  80. counters[kind] = (counters[kind] || 0) + 1;
  81. registry.set(key, { key, kind, label: `${sourceKindLabel(kind)} ${counters[kind]}`, source: item.source || {} });
  82. });
  83. return registry;
  84. }
  85. function groupReads(reads) {
  86. const groups = new Map();
  87. reads.forEach((read, index) => {
  88. const key = read.sourceKey || `unknown:${index}`;
  89. if (!groups.has(key)) groups.set(key, { key, source: read.source || {}, items: [], usageAreas: new Set() });
  90. const group = groups.get(key);
  91. group.items.push(read);
  92. (Array.isArray(read.usageAreas) ? read.usageAreas : []).forEach((area) => group.usageAreas.add(area));
  93. });
  94. return [...groups.values()];
  95. }
  96. function groupsForAreas(groups, areas) {
  97. const wanted = new Set(areas);
  98. return groups.filter((group) => [...group.usageAreas].some((area) => wanted.has(area)));
  99. }
  100. function referencesForGroups(groups, registry) {
  101. return groups.map((group) => registry.get(group.key)).filter(Boolean);
  102. }
  103. function contextsForGroups(contexts, groups) {
  104. const keys = new Set(groups.map((group) => group.key));
  105. return contexts.filter((context) => keys.has(context.sourceKey) || (context.kind === "log-module" && context.relatedSourceKey && keys.has(context.relatedSourceKey)));
  106. }
  107. function activeSourceKind() {
  108. return sourceFilter?.value || "all";
  109. }
  110. function filterGroups(groups) {
  111. const wanted = activeSourceKind();
  112. return wanted === "all" ? groups : groups.filter((group) => sourceKind(group.source) === wanted);
  113. }
  114. function filterContexts(contexts) {
  115. const wanted = activeSourceKind();
  116. return wanted === "all" ? contexts : contexts.filter((context) => sourceKind(context.source) === wanted);
  117. }
  118. function parseStructuredText(value) {
  119. if (typeof value !== "string") return value;
  120. const text = value.trim();
  121. if (!text || !["{", "["].includes(text[0])) return value;
  122. try { return JSON.parse(text); } catch { return value; }
  123. }
  124. function sourceLinks(links = [], usageAreas = []) {
  125. if (!links.length && !usageAreas.length) return "";
  126. const refs = links.map((item) => `<span class="sourceTag source-${escapeHtml(item.kind)}" role="button" tabindex="0" aria-pressed="false" data-source-key="${escapeHtml(item.key)}" title="突出显示三列中的对应内容">${escapeHtml(item.label)}</span>`).join("");
  127. const usages = usageAreas.map((area) => `<span class="usageTag">${escapeHtml(area)}</span>`).join("");
  128. return `<span class="sourceLinks">${refs}${usages}</span>`;
  129. }
  130. function correlationLinks(segments = []) {
  131. if (!segments.length) return "";
  132. return `<span class="correlationLinks" aria-label="字段对应关系">${segments.map((segment) => `<span class="correlationChip correlation-${segment.color}" role="button" tabindex="0" aria-pressed="false" data-correlation-id="${escapeHtml(segment.id)}" title="突出显示三列中的对应字段"><b>${String(segment.number).padStart(2, "0")}</b>${escapeHtml(segment.label)}</span>`).join("")}</span>`;
  133. }
  134. function contentBox(title, value, options = {}) {
  135. const count = options.characters ?? chars(value);
  136. const long = count > 800;
  137. const body = options.html ?? `<pre>${escapeHtml(jsonText(value))}</pre>`;
  138. const meta = [options.meta, `${count.toLocaleString("zh-CN")} 字`].filter(Boolean).join(" · ");
  139. const sourceKeys = [...new Set([...(options.links || []).map((item) => item.key), ...(options.sourceKeys || [])].filter(Boolean))].join(" ");
  140. return `<details class="contentBox ${options.tone ? `tone-${options.tone}` : ""}" data-source-keys="${escapeHtml(sourceKeys)}" ${long ? "" : "open"}>
  141. <summary><span class="summaryLine"><b>${escapeHtml(title)}</b><small>${escapeHtml(meta)}</small></span>${sourceLinks(options.links, options.usageAreas)}${correlationLinks(options.correlationSegments)}${long && options.showPreview ? `<em>${escapeHtml(truncate(value))}</em>` : ""}</summary>
  142. <div class="contentBody">${body}</div>
  143. </details>`;
  144. }
  145. function cardSegments(card) {
  146. return makeSegments("card", (card?.fields || []).map((field) => ({ label: field?.label || "内容", value: field?.value })));
  147. }
  148. function businessSegments(inspector) {
  149. const items = [];
  150. (inspector?.businessSections || []).forEach((section) => items.push({ label: section.title || "内容", value: section.content ?? section.items ?? section }));
  151. (inspector?.blocks || []).forEach((block) => items.push({ label: block.title || block.type || "决策内容", value: block.value ?? block.items ?? block }));
  152. if (inspector?.changes) items.push({ label: "产出与改动", value: inspector.changes });
  153. return makeSegments("business", items);
  154. }
  155. function technicalSegments(inspector) {
  156. const technical = inspector?.technical;
  157. if (!technical || typeof technical !== "object") return [];
  158. return makeSegments("technical", Object.entries(technical).map(([label, value]) => ({ label, value })));
  159. }
  160. function targetAttrs(segment) {
  161. if (!segment) return "";
  162. return ` role="button" tabindex="0" data-correlation-id="${escapeHtml(segment.id)}"`;
  163. }
  164. function renderCard(card, links, segments) {
  165. if (!card) return contentBox("卡片", "该模块没有卡片展示数据。", { tone: "muted" });
  166. const fields = (card.fields || []).filter((field) => field && field.value !== null && field.value !== undefined && field.value !== "");
  167. const html = `<div class="displayCard"><h4>${escapeHtml(card.title || "卡片")}</h4>${fields.map((field, index) => {
  168. const segment = segments[index];
  169. return `<div class="displayField${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><b>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(field.label || "内容")}</b><div>${renderPlainValue(field.value)}</div></div>`;
  170. }).join("") || `<p class="empty">卡片没有可显示字段。</p>`}</div>`;
  171. return contentBox("卡片", card, { html, characters: chars(card), meta: "最终显示内容", tone: "used", links, correlationSegments: segments });
  172. }
  173. function businessDetailPayload(inspector) {
  174. return {
  175. businessSections: inspector?.businessSections || [],
  176. blocks: inspector?.blocks || [],
  177. changes: inspector?.changes ?? null,
  178. };
  179. }
  180. function renderBusinessDetail(inspector, notice, links, segments) {
  181. if (!inspector) return contentBox("业务详情", notice || "该模块没有业务详情。", { tone: "muted", links });
  182. const parts = [];
  183. let segmentIndex = 0;
  184. (inspector.businessSections || []).forEach((section) => {
  185. const segment = segments[segmentIndex++];
  186. parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(section.title || "内容")}</h4>${renderPlainValue(section.content ?? section.items ?? section)}</section>`);
  187. });
  188. (inspector.blocks || []).forEach((block) => {
  189. const segment = segments[segmentIndex++];
  190. parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(block.title || block.type || "决策内容")}</h4>${renderPlainValue(block.value ?? block.items ?? block)}</section>`);
  191. });
  192. if (inspector.changes) {
  193. const segment = segments[segmentIndex];
  194. parts.push(`<section class="businessPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}产出与改动</h4>${renderPlainValue(inspector.changes)}</section>`);
  195. }
  196. const payload = businessDetailPayload(inspector);
  197. return contentBox("业务详情", payload, {
  198. html: parts.length ? `<div class="businessDetailBody">${parts.join("")}</div>` : `<p class="empty">本次运行没有记录业务详情。</p>`,
  199. characters: chars(payload),
  200. meta: "Inspector 最终显示内容",
  201. tone: "used",
  202. links,
  203. correlationSegments: segments,
  204. });
  205. }
  206. function renderTechnicalDetail(inspector, notice, links, segments) {
  207. if (!inspector || !Object.prototype.hasOwnProperty.call(inspector, "technical")) {
  208. return contentBox("技术详情", notice || "该模块没有技术详情。", { tone: "muted", links });
  209. }
  210. const technical = inspector.technical;
  211. const html = technical && typeof technical === "object"
  212. ? `<div class="technicalDetailBody">${Object.entries(technical).map(([label, value], index) => {
  213. const segment = segments[index];
  214. return `<section class="technicalPart${segment ? ` correspondenceTarget correlation-${segment.color}` : ""}"${targetAttrs(segment)}><h4>${segment ? `<span class="correlationNumber">${String(segment.number).padStart(2, "0")}</span>` : ""}${escapeHtml(label)}</h4>${renderPlainValue(value)}</section>`;
  215. }).join("")}</div>`
  216. : renderPlainValue(technical);
  217. return contentBox("技术详情", technical, { html, meta: "Inspector 最终显示内容", tone: "read", links, correlationSegments: segments });
  218. }
  219. function renderReadGroup(group, registry, segments) {
  220. const entry = registry.get(group.key);
  221. const usageAreas = [...group.usageAreas];
  222. const count = group.items.reduce((sum, item) => sum + Number(item.characters || 0), 0);
  223. const correlated = segments.filter((segment) => group.items.some((item) => matchingSegments(item.value, [segment]).length));
  224. const html = group.items.map((read) => {
  225. const status = `<span class="readStatus status-${escapeHtml(read.disposition)}">${escapeHtml(labelForDisposition(read.disposition))}</span>`;
  226. const ratio = read.usageRatio?.label || "不可直接计算";
  227. const field = read.fieldPath && read.fieldPath !== "$" ? `读取字段:${read.fieldPath}` : "读取完整记录";
  228. const exactRef = (read.contextRefs || []).find((ref) => ref.exact);
  229. const locateLabel = exactRef ? "定位原始字段" : "定位原始记录";
  230. return `<div class="readItem" data-read-id="${escapeHtml(read.readId || "")}"><div class="readItemHeader"><b>${escapeHtml(read.label || "数据")}</b><code>${escapeHtml(field)}</code></div><div class="readMeta">${status}<span>${escapeHtml(read.transformation || relationLabel(read.relation))}</span><span>使用比例 ${escapeHtml(ratio)}</span><button type="button" class="locateContextButton" data-locate-read-id="${escapeHtml(read.readId || "")}">${locateLabel}</button></div><div class="contextTree readValueTree">${renderContextValue(read.value, "", [], [], correlated)}</div></div>`;
  231. }).join("");
  232. const detail = sourceDetail(group.source);
  233. return contentBox(sourceName(group.source), group.items.map((item) => item.value), { html, characters: count, meta: [detail, `${group.items.length} 项读取`].filter(Boolean).join(" · "), tone: sourceKind(group.source), links: entry ? [entry] : [], usageAreas: usageAreas.length ? usageAreas : ["未进入界面"], correlationSegments: correlated });
  234. }
  235. function refsForContext(groups, context) {
  236. return groups.flatMap((group) => group.items).flatMap((read) =>
  237. (read.contextRefs || [])
  238. .filter((ref) => ref.contextId === context.id)
  239. .map((ref) => ({ read, ref }))
  240. );
  241. }
  242. function renderContext(context, groups, registry, segments) {
  243. const source = context.source || {};
  244. const entry = registry.get(context.sourceKey);
  245. const meta = [context.title, sourceDetail(source), relationLabel(context.match), completenessLabel(context.completeness)].filter(Boolean).join(" · ");
  246. const readPaths = Array.isArray(context.readPaths) ? context.readPaths : [];
  247. const usedFragments = Array.isArray(context.usedFragments) ? context.usedFragments : [];
  248. const correlated = matchingSegments(context.content, segments);
  249. const exactFields = refsForContext(groups, context)
  250. .filter(({ ref }) => ref.contextFieldPath)
  251. .map(({ read, ref }) => ({ read, ref, value: resolveFieldPath(context.content, ref.contextFieldPath) }))
  252. .filter(({ value }) => value !== undefined);
  253. const raw = exactFields.length
  254. ? `<section class="exactFieldMap"><h4>第二列对应的原始字段</h4>${exactFields.map(({ read, ref, value }) => renderRawField(read, value, segments, [], ref)).join("")}</section>`
  255. : "";
  256. const html = renderContextValue(context.content, "", readPaths, usedFragments, correlated);
  257. return contentBox(sourceName(source), context.content, { html: `${raw}<div class="contextTree">${html}</div>`, characters: context.characters, meta, tone: context.completeness === "complete" ? sourceKind(source) : "warning", links: entry ? [entry] : [], sourceKeys: [context.relatedSourceKey], correlationSegments: correlated });
  258. }
  259. function renderReads(groups, registry, segments) {
  260. return groups.map((group) => renderReadGroup(group, registry, segments)).join("") || contentBox("读取来源", "这一部分没有可确认的读取数据。", { tone: "muted" });
  261. }
  262. function renderContexts(contexts, groups, registry, segments) {
  263. const matched = filterContexts(contextsForGroups(contexts, groups));
  264. return matched.map((context) => renderContext(context, groups, registry, segments)).join("") || contentBox("原始记录与上下文", "这一部分没有可定位的原始记录或日志上下文。", { tone: "muted" });
  265. }
  266. function compactSourceLabel(group) {
  267. const source = group.source || {};
  268. const event = group.items.map((item) => item.value).find((value) => value && typeof value === "object" && value.event_name);
  269. const eventName = source.eventName || event?.event_name;
  270. const record = source.eventId !== null && source.eventId !== undefined ? `Event ${source.eventId}` : source.recordId !== null && source.recordId !== undefined ? `记录 ${source.recordId}` : "";
  271. return [sourceKindLabel(sourceKind(source)), eventName || source.functionName || source.table, record].filter(Boolean).join(" · ");
  272. }
  273. function previewWithMatch(value, needle) {
  274. const preview = excerptAround(value, needle, 300);
  275. if (!needle || !preview.includes(needle)) return escapeHtml(preview);
  276. const index = preview.indexOf(needle);
  277. return `${escapeHtml(preview.slice(0, index))}<mark class="valueMatch">${escapeHtml(needle)}</mark>${escapeHtml(preview.slice(index + needle.length))}`;
  278. }
  279. function renderValueDisclosure(value, label = "实际读取值", needle = "") {
  280. const count = chars(value);
  281. if (count <= 260 && typeof parseStructuredText(value) !== "object") {
  282. return `<div class="fieldActualValue"><b>${escapeHtml(label)}</b><p>${previewWithMatch(value, needle)}</p></div>`;
  283. }
  284. return `<details class="valueDisclosure"><summary><span>${escapeHtml(label)}</span><small>${count.toLocaleString("zh-CN")} 字 · 展开全文</small><em>${previewWithMatch(value, needle)}</em></summary><div class="valueDisclosureBody">${renderPlainValue(value)}</div></details>`;
  285. }
  286. function renderCompactSource(group, segments) {
  287. const unique = new Map();
  288. group.items.forEach((read) => {
  289. if (read.value === null || read.value === undefined || read.value === "") return;
  290. const key = read.readId || `${read.fieldPath || "$"}::${jsonText(read.value)}`;
  291. if (!unique.has(key)) unique.set(key, read);
  292. });
  293. const items = [...unique.values()];
  294. const visible = items.slice(0, 8);
  295. const hidden = items.length - visible.length;
  296. return `<section class="lineageSource" data-source-key="${escapeHtml(group.key)}">
  297. <header class="lineageSourceHeader"><span class="sourceKindPill source-${escapeHtml(sourceKind(group.source))}">${escapeHtml(compactSourceLabel(group))}</span></header>
  298. ${visible.map((read) => {
  299. const related = matchingSegments(read.value, segments);
  300. const ids = related.map((segment) => segment.id).join(" ");
  301. const tone = related[0] ? ` correspondenceTarget correlation-${related[0].color}` : "";
  302. const exactRef = (read.contextRefs || []).find((ref) => ref.exact);
  303. return `<div class="readValueEntry${tone}" data-read-id="${escapeHtml(read.readId || "")}"${ids ? ` data-correlation-ids="${escapeHtml(ids)}"` : ""}><div class="readValueMeta"><span>${escapeHtml(read.label || "读取字段")}</span><code>${escapeHtml(read.fieldPath || "$" )}</code>${correlationLinks(related)}<button type="button" class="locateContextButton" data-locate-read-id="${escapeHtml(read.readId || "")}" data-locate-correlation="${escapeHtml(related[0]?.id || "")}">${exactRef ? "定位原始字段" : "定位原始记录"}</button></div>${renderValueDisclosure(read.value, "实际读取值", related[0]?.text || "")}${related.length ? "" : `<small class="lineageUnproven">参与展示处理,但无法逐字定位到左侧单一字段</small>`}</div>`;
  304. }).join("")}
  305. ${hidden > 0 ? `<small class="moreFieldsNotice">另有 ${hidden} 个读取字段,可在完整记录模式查看。</small>` : ""}
  306. </section>`;
  307. }
  308. function renderCompactReads(groups, contexts, segments) {
  309. if (!groups.length) return `<div class="compactEmpty">当前来源筛选下没有业务读取字段。</div>`;
  310. const items = groups.map((group) => renderCompactSource(group, segments)).join("");
  311. return `<section class="compactPanel"><header><h4>字段来源</h4><span>${groups.reduce((sum, group) => sum + group.items.length, 0)} 个实际读取字段</span></header><div class="lineageSources">${items}</div></section>`;
  312. }
  313. function contextDigest(context) {
  314. const content = context.content && typeof context.content === "object" ? context.content : {};
  315. const eventName = content.event_name || context.source?.eventName || context.source?.functionName || context.title;
  316. const eventId = context.source?.eventId;
  317. return { context, eventName, eventId };
  318. }
  319. function resolveFieldPath(value, fieldPath) {
  320. if (!fieldPath || fieldPath === "$" || fieldPath === "event") return value;
  321. const parts = String(fieldPath).replace(/^\$\.?/, "").replaceAll("[]", "").split(".").filter(Boolean);
  322. let current = value;
  323. for (const part of parts) {
  324. current = parseStructuredText(current);
  325. if (!current || typeof current !== "object" || !(part in current)) return undefined;
  326. current = current[part];
  327. }
  328. return parseStructuredText(current);
  329. }
  330. function renderRawField(read, value, segments, derivedSegments, ref = {}) {
  331. const exact = matchingSegments(value, segments);
  332. const related = exact.length ? exact : derivedSegments;
  333. const ids = related.map((segment) => segment.id).join(" ");
  334. const tone = related[0] ? ` correlation-${related[0].color}` : "";
  335. const relation = ref.relation === "record-only" ? "只能定位到记录" : ref.relation === "transformed" ? "函数处理结果" : "精确字段对应";
  336. return `<div class="rawField${tone}" tabindex="-1" data-read-ids="${escapeHtml(read.readId || "")}"${ids ? ` data-correlation-ids="${escapeHtml(ids)}"` : ""}><header><div><span>${escapeHtml(read.label || "读取字段")}</span><code>${escapeHtml(ref.contextFieldPath || read.fieldPath || "完整记录")}</code></div><em class="lineageRelation">${escapeHtml(relation)}</em>${correlationLinks(related)}</header>${renderValueDisclosure(value, "原始字段值", exact[0]?.text || "")}</div>`;
  337. }
  338. function renderCompactContexts(contexts, groups, segments) {
  339. const matched = filterContexts(contextsForGroups(contexts, groups));
  340. if (!matched.length) return `<div class="compactEmpty">当前来源筛选下没有相关原始记录。</div>`;
  341. const grouped = new Map();
  342. matched.forEach((context) => {
  343. const kind = sourceKind(context.source);
  344. const key = `${kind}::${context.sourceKey || context.id}`;
  345. if (!grouped.has(key)) grouped.set(key, { context, contexts: [], eventIds: [] });
  346. const bucket = grouped.get(key);
  347. bucket.contexts.push(context);
  348. if (context.source?.eventId !== null && context.source?.eventId !== undefined) bucket.eventIds.push(context.source.eventId);
  349. });
  350. const html = [...grouped.values()].map((bucket) => {
  351. const context = bucket.context;
  352. const kind = sourceKind(context.source);
  353. const digest = contextDigest(context);
  354. const records = bucket.eventIds.length ? `Event ${[...new Set(bucket.eventIds)].join("、")}` : sourceDetail(context.source);
  355. const sourceKey = context.sourceKey || "";
  356. const relatedGroups = groups.filter((group) => group.key === sourceKey || (context.relatedSourceKey && group.key === context.relatedSourceKey));
  357. const reads = relatedGroups.flatMap((group) => group.items);
  358. const uniqueReads = new Map();
  359. reads.forEach((read) => {
  360. (read.contextRefs || []).forEach((ref) => {
  361. const targetContext = bucket.contexts.find((candidate) => candidate.id === ref.contextId);
  362. if (!targetContext) return;
  363. const value = resolveFieldPath(targetContext.content, ref.contextFieldPath);
  364. if (value === undefined) return;
  365. const key = `${read.readId || "read"}::${ref.contextId}::${ref.contextFieldPath}`;
  366. if (!uniqueReads.has(key)) uniqueReads.set(key, { read, value, ref });
  367. });
  368. });
  369. if (kind === "log") return `<article class="sourceDigest tone-log" tabindex="-1" data-source-key="${escapeHtml(context.relatedSourceKey || sourceKey)}"><header><span class="sourceKindPill source-log">日志</span><strong>${escapeHtml(digest.eventName)}</strong><em>${escapeHtml(records || `${context.characters || 0} 字`)}</em></header>${renderValueDisclosure(context.content, "锚定日志上下文")}</article>`;
  370. const fields = [...uniqueReads.values()].slice(0, 6);
  371. const hidden = uniqueReads.size - fields.length;
  372. return `<article class="sourceDigest tone-${escapeHtml(kind)}" tabindex="-1" data-source-key="${escapeHtml(sourceKey)}"><header><span class="sourceKindPill source-${escapeHtml(kind)}">${escapeHtml(sourceKindLabel(kind))}</span><strong>${escapeHtml(digest.eventName)}</strong><em>${escapeHtml(records)}</em></header><div class="recordLocation"><span>原始记录位置</span><code>${escapeHtml(context.source?.table || sourceName(context.source))}</code>${context.source?.recordId !== null && context.source?.recordId !== undefined ? `<code>#${escapeHtml(context.source.recordId)}</code>` : ""}${context.source?.eventId !== null && context.source?.eventId !== undefined ? `<code>Event ${escapeHtml(context.source.eventId)}</code>` : ""}</div>${fields.length ? `<div class="rawFields">${fields.map(({ read, value, ref }) => renderRawField(read, value, segments, [], ref)).join("")}</div>` : `<p>这条记录参与了处理,但没有可确认的独立读取字段。</p>`}${hidden > 0 ? `<small class="moreFieldsNotice">另有 ${hidden} 个字段,可在完整记录模式查看。</small>` : ""}</article>`;
  373. }).join("");
  374. return `<section class="compactPanel"><header><h4>原始字段与记录</h4><span>${grouped.size} 组</span></header>${html}</section>`;
  375. }
  376. function correspondenceRow(kind, actualUse, groups, contextGroups, contexts, registry, segments, compact) {
  377. return `<section class="correspondenceRow" data-area="${escapeHtml(kind)}">
  378. <div class="correspondenceCell" data-column="use"><div class="columnLabel">模块实际使用</div>${actualUse}</div>
  379. <div class="correspondenceCell" data-column="read"><div class="columnLabel">${compact ? "字段来源与读取" : "模块实际读取"}</div>${compact ? renderCompactReads(groups, contexts, segments) : renderReads(groups, registry, segments)}</div>
  380. <div class="correspondenceCell" data-column="context"><div class="columnLabel">${compact ? "原始字段与记录" : "原始记录与上下文"}</div>${compact ? renderCompactContexts(contexts, contextGroups, segments) : renderContexts(contexts, contextGroups, registry, segments)}</div>
  381. </section>`;
  382. }
  383. function renderPlainValue(value) {
  384. if (value === null || value === undefined || value === "") return `<span class="empty">空</span>`;
  385. if (typeof value === "object") return `<pre>${escapeHtml(jsonText(value))}</pre>`;
  386. return `<p>${escapeHtml(String(value)).replaceAll("\n", "<br>")}</p>`;
  387. }
  388. function renderContextValue(value, path, readPaths, snippets, segments = []) {
  389. if (Array.isArray(value)) return `<ol>${value.map((item, index) => `<li>${renderContextValue(item, `${path}[${index}]`, readPaths, snippets, segments)}</li>`).join("")}</ol>`;
  390. if (value && typeof value === "object") return `<dl>${Object.entries(value).map(([key, nested]) => {
  391. const childPath = path ? `${path}.${key}` : key;
  392. const read = readPaths.some((item) => item === "$" || childPath === item || childPath.endsWith(`.${item}`) || item.startsWith(`${childPath}.`));
  393. const correlated = matchingSegments(nested, segments).length > 0;
  394. return `<div class="contextEntry ${read ? "isRead" : "isDim"}${correlated ? " hasCorrelationData" : ""}"><dt>${escapeHtml(key)}</dt><dd>${renderContextValue(nested, childPath, readPaths, snippets, segments)}</dd></div>`;
  395. }).join("")}</dl>`;
  396. const text = String(value ?? "");
  397. return `<span>${highlightCorrelations(text, segments) || highlightUsed(text, snippets)}</span>`;
  398. }
  399. function highlightCorrelations(text, segments) {
  400. const matches = [];
  401. segments.forEach((segment) => {
  402. if (!segment.text || segment.text.length < 2) return;
  403. let from = 0;
  404. let found = 0;
  405. while (from <= text.length && found < 24) {
  406. const index = text.indexOf(segment.text, from);
  407. if (index < 0) break;
  408. matches.push({ start: index, end: index + segment.text.length, segment });
  409. from = index + Math.max(segment.text.length, 1);
  410. found += 1;
  411. }
  412. });
  413. if (!matches.length) return "";
  414. matches.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
  415. const accepted = [];
  416. let cursor = -1;
  417. matches.forEach((match) => {
  418. if (match.start < cursor) return;
  419. accepted.push(match);
  420. cursor = match.end;
  421. });
  422. let output = "";
  423. cursor = 0;
  424. accepted.forEach((match) => {
  425. output += escapeHtml(text.slice(cursor, match.start));
  426. output += `<mark class="correlationMark correlation-${match.segment.color}" role="button" tabindex="0" data-correlation-id="${escapeHtml(match.segment.id)}" title="对应 ${escapeHtml(match.segment.label)}">${escapeHtml(text.slice(match.start, match.end))}</mark>`;
  427. cursor = match.end;
  428. });
  429. output += escapeHtml(text.slice(cursor));
  430. return output.replaceAll("\n", "<br>");
  431. }
  432. function highlightUsed(text, snippets) {
  433. const candidates = (Array.isArray(snippets) ? snippets : []).filter((snippet) => snippet && snippet.length >= 8 && text.includes(snippet)).sort((a, b) => b.length - a.length);
  434. if (!candidates.length) return escapeHtml(text).replaceAll("\n", "<br>");
  435. const snippet = candidates[0];
  436. const index = text.indexOf(snippet);
  437. return `${escapeHtml(text.slice(0, index))}<mark>${escapeHtml(snippet)}</mark>${escapeHtml(text.slice(index + snippet.length))}`.replaceAll("\n", "<br>");
  438. }
  439. function moduleRow(module) {
  440. const path = (module.path || []).join(" / ");
  441. const variant = module.displayVariant === "collapsed-summary" ? `<span class="variant">折叠态摘要</span>` : "";
  442. return `<article class="moduleRow" data-module-id="${escapeHtml(module.id)}" tabindex="-1">
  443. <header><div><span>${escapeHtml(path)}</span><h3>${escapeHtml(module.title)}</h3></div><div class="moduleMeta">${variant}<code>${escapeHtml(module.id)}</code><span>${Number(module.cardCharacters || 0).toLocaleString("zh-CN")} 字卡片内容</span><span class="moduleStreamState">正在自动加载</span></div></header>
  444. <div class="moduleDeferred"><div class="cellLoading"><i></i><i></i></div></div>
  445. </article>`;
  446. }
  447. function populateModuleJump(modules) {
  448. if (!moduleJump) return;
  449. const groups = new Map();
  450. modules.forEach((module) => {
  451. const group = module.group || "其他";
  452. if (!groups.has(group)) groups.set(group, []);
  453. groups.get(group).push(module);
  454. });
  455. const options = [...groups.entries()].map(([group, items]) => `<optgroup label="${escapeHtml(group)}">${items.map((module) => {
  456. const path = (module.path || []).filter(Boolean).join(" / ");
  457. const label = path ? `${module.title} · ${path}` : module.title;
  458. return `<option value="${escapeHtml(module.id)}">${escapeHtml(label)}</option>`;
  459. }).join("")}</optgroup>`).join("");
  460. moduleJump.innerHTML = `<option value="">选择一张卡片…</option>${options}`;
  461. moduleJump.disabled = modules.length === 0;
  462. }
  463. function jumpToModule(moduleId, options = {}) {
  464. if (!moduleId) return;
  465. const row = [...document.querySelectorAll(".moduleRow")].find((item) => item.dataset.moduleId === moduleId);
  466. if (!row) return;
  467. const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
  468. row.scrollIntoView({ behavior: reduceMotion || options.instant ? "auto" : "smooth", block: "start" });
  469. row.focus({ preventScroll: true });
  470. row.classList.remove("isJumpTarget");
  471. void row.offsetWidth;
  472. row.classList.add("isJumpTarget");
  473. window.setTimeout(() => row.classList.remove("isJumpTarget"), 1600);
  474. if (options.updateHash !== false) history.replaceState(null, "", `#module=${encodeURIComponent(moduleId)}`);
  475. }
  476. function renderModuleDetail(row, detail) {
  477. const reads = detail.actualReads || [];
  478. const contexts = detail.contexts || [];
  479. const registry = buildSourceRegistry(reads, contexts);
  480. const sourceGroups = groupReads(reads);
  481. const cardContextGroups = groupsForAreas(sourceGroups, ["卡片"]);
  482. const businessContextGroups = groupsForAreas(sourceGroups, ["Inspector 业务详情", "Inspector 产出与改动"]);
  483. const assigned = new Set([...cardContextGroups, ...businessContextGroups].map((group) => group.key));
  484. const technicalContextGroups = [
  485. ...groupsForAreas(sourceGroups, ["Inspector 技术详情", "模块处理"]),
  486. ...sourceGroups.filter((group) => !assigned.has(group.key) && group.usageAreas.size === 0),
  487. ].filter((group, index, all) => all.findIndex((candidate) => candidate.key === group.key) === index);
  488. const cardGroups = filterGroups(cardContextGroups);
  489. const businessGroups = filterGroups(businessContextGroups);
  490. const technicalGroups = filterGroups(technicalContextGroups);
  491. const cardLinks = referencesForGroups(cardGroups, registry);
  492. const businessLinks = referencesForGroups(businessGroups, registry);
  493. const technicalLinks = referencesForGroups(technicalGroups, registry);
  494. const cardFieldSegments = cardSegments(detail.actualUse?.card);
  495. const businessFieldSegments = businessSegments(detail.actualUse?.inspector);
  496. const technicalFieldSegments = technicalSegments(detail.actualUse?.inspector);
  497. const compact = onlyUsedFields?.checked !== false;
  498. const rows = [
  499. correspondenceRow("卡片", renderCard(detail.actualUse?.card, cardLinks, cardFieldSegments), cardGroups, cardContextGroups, contexts, registry, cardFieldSegments, compact),
  500. correspondenceRow("业务详情", renderBusinessDetail(detail.actualUse?.inspector, detail.actualUse?.inspectorNotice, businessLinks, businessFieldSegments), businessGroups, businessContextGroups, contexts, registry, businessFieldSegments, compact),
  501. correspondenceRow("技术详情", renderTechnicalDetail(detail.actualUse?.inspector, detail.actualUse?.inspectorNotice, technicalLinks, technicalFieldSegments), technicalGroups, technicalContextGroups, contexts, registry, technicalFieldSegments, compact),
  502. ];
  503. const target = row.querySelector(".moduleDeferred, .moduleColumns, .correspondenceRows");
  504. if (target) target.outerHTML = `<div class="correspondenceRows ${compact ? "isCompact" : "isComplete"}">${rows.join("")}</div>`;
  505. row.classList.remove("hasSourceFocus", "hasCorrelationFocus");
  506. delete row.dataset.activeSource;
  507. delete row.dataset.activeCorrelation;
  508. row.classList.add("isLoaded");
  509. const state = row.querySelector(".moduleStreamState");
  510. if (state) state.textContent = "已加载";
  511. }
  512. function updateLoadProgress() {
  513. if (!runMeta) return;
  514. const loadedCount = detailCache.size;
  515. const renderedCount = renderedModules.size;
  516. const progress = moduleTotal ? `${loadedCount}/${moduleTotal} 数据` : "正在建立模块目录";
  517. const dataDone = streamComplete && loadedCount >= moduleTotal;
  518. const renderDone = renderedCount >= moduleTotal;
  519. const state = dataDone && renderDone ? "全部加载完成" : dataDone ? `正在展示 ${renderedCount}/${moduleTotal}` : "正在加载全部数据";
  520. runSummary.innerHTML = `<strong>${progress}</strong><span>${Number(runMeta.summary?.groupCount || 0)} 个流程分组</span><span>卡片内容 ${Number(runMeta.summary?.cardCharacters || 0).toLocaleString("zh-CN")} 字</span><em>${state}</em>`;
  521. }
  522. function drainRenderQueue() {
  523. renderScheduled = false;
  524. const started = performance.now();
  525. while (renderQueue.length && performance.now() - started < 10) {
  526. const { moduleId, detail } = renderQueue.shift();
  527. const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(moduleId)}"]`);
  528. if (row) {
  529. renderModuleDetail(row, detail);
  530. renderedModules.add(moduleId);
  531. }
  532. }
  533. updateLoadProgress();
  534. if (renderQueue.length) scheduleRenderQueue();
  535. }
  536. function scheduleRenderQueue() {
  537. if (renderScheduled) return;
  538. renderScheduled = true;
  539. requestAnimationFrame(drainRenderQueue);
  540. }
  541. function enqueueDetail(moduleId, detail) {
  542. detailCache.set(moduleId, detail);
  543. renderQueue.push({ moduleId, detail });
  544. updateLoadProgress();
  545. scheduleRenderQueue();
  546. }
  547. function refreshLoadedModules() {
  548. const compact = onlyUsedFields?.checked !== false;
  549. if (readColumnTitle) readColumnTitle.textContent = compact ? "字段来源与读取" : "模块实际读取";
  550. if (contextColumnTitle) contextColumnTitle.textContent = compact ? "原始字段与记录" : "原始记录与上下文";
  551. const entries = [...detailCache.entries()];
  552. let cursor = 0;
  553. const renderBatch = () => {
  554. const limit = Math.min(cursor + 3, entries.length);
  555. for (; cursor < limit; cursor += 1) {
  556. const [moduleId, detail] = entries[cursor];
  557. const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(moduleId)}"]`);
  558. if (row) renderModuleDetail(row, detail);
  559. }
  560. if (cursor < entries.length) requestAnimationFrame(renderBatch);
  561. };
  562. requestAnimationFrame(renderBatch);
  563. }
  564. function locateOriginalField(target) {
  565. const row = target.closest(".moduleRow");
  566. if (!row) return;
  567. const readId = target.dataset.locateReadId;
  568. const match = readId
  569. ? row.querySelector(`[data-column="context"] [data-read-ids~="${CSS.escape(readId)}"]`)
  570. : null;
  571. if (!match) return;
  572. const correlationId = target.dataset.locateCorrelation;
  573. if (correlationId) activateCorrelation(row, correlationId, target);
  574. match.classList.remove("isLocated");
  575. void match.offsetWidth;
  576. match.classList.add("isLocated");
  577. match.scrollIntoView({ behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center", inline: "nearest" });
  578. match.focus({ preventScroll: true });
  579. window.setTimeout(() => match.classList.remove("isLocated"), 1800);
  580. }
  581. function setSourceFocus(target) {
  582. const row = target.closest(".moduleRow");
  583. if (!row) return;
  584. const key = target.dataset.sourceKey;
  585. const alreadyActive = row.dataset.activeSource === key;
  586. row.querySelectorAll(".sourceTag").forEach((tag) => tag.setAttribute("aria-pressed", "false"));
  587. row.querySelectorAll(".contentBox").forEach((box) => box.classList.remove("isSourceMatch"));
  588. row.classList.remove("hasSourceFocus");
  589. delete row.dataset.activeSource;
  590. if (alreadyActive || !key) return;
  591. row.dataset.activeSource = key;
  592. row.classList.add("hasSourceFocus");
  593. row.querySelectorAll(".sourceTag").forEach((tag) => {
  594. if (tag.dataset.sourceKey === key) tag.setAttribute("aria-pressed", "true");
  595. });
  596. row.querySelectorAll(".contentBox").forEach((box) => {
  597. const keys = (box.dataset.sourceKeys || "").split(" ");
  598. if (keys.includes(key)) box.classList.add("isSourceMatch");
  599. });
  600. }
  601. function activateCorrelation(row, id, colorTarget) {
  602. const alreadyActive = row.dataset.activeCorrelation === id;
  603. row.querySelectorAll("[data-correlation-id], [data-correlation-ids]").forEach((item) => {
  604. item.classList.remove("isCorrelationMatch");
  605. item.setAttribute("aria-pressed", "false");
  606. });
  607. row.classList.remove("hasCorrelationFocus");
  608. delete row.dataset.activeCorrelation;
  609. row.style.removeProperty("--active-correlation-color");
  610. if (alreadyActive || !id) return;
  611. row.dataset.activeCorrelation = id;
  612. row.classList.add("hasCorrelationFocus");
  613. const activeColor = getComputedStyle(colorTarget).getPropertyValue("--correlation-color").trim();
  614. if (activeColor) row.style.setProperty("--active-correlation-color", activeColor);
  615. row.querySelectorAll(`[data-correlation-id="${CSS.escape(id)}"], [data-correlation-ids~="${CSS.escape(id)}"]`).forEach((item) => {
  616. item.classList.add("isCorrelationMatch");
  617. item.setAttribute("aria-pressed", "true");
  618. const details = item.closest("details");
  619. if (details) details.open = true;
  620. });
  621. }
  622. function setCorrelationFocus(target) {
  623. const row = target.closest(".moduleRow");
  624. if (!row) return;
  625. activateCorrelation(row, target.dataset.correlationId, target);
  626. }
  627. root.addEventListener("click", (event) => {
  628. const locateTarget = event.target.closest("[data-locate-read-id]");
  629. if (locateTarget) {
  630. event.preventDefault();
  631. event.stopPropagation();
  632. locateOriginalField(locateTarget);
  633. return;
  634. }
  635. const correlationTarget = event.target.closest("[data-correlation-id]");
  636. if (correlationTarget) {
  637. event.preventDefault();
  638. event.stopPropagation();
  639. setCorrelationFocus(correlationTarget);
  640. return;
  641. }
  642. const target = event.target.closest(".sourceTag");
  643. if (!target) return;
  644. event.preventDefault();
  645. event.stopPropagation();
  646. setSourceFocus(target);
  647. });
  648. root.addEventListener("keydown", (event) => {
  649. if (event.key !== "Enter" && event.key !== " ") return;
  650. const locateTarget = event.target.closest("[data-locate-read-id]");
  651. if (locateTarget) {
  652. event.preventDefault();
  653. event.stopPropagation();
  654. locateOriginalField(locateTarget);
  655. return;
  656. }
  657. const correlationTarget = event.target.closest("[data-correlation-id]");
  658. if (correlationTarget) {
  659. event.preventDefault();
  660. event.stopPropagation();
  661. setCorrelationFocus(correlationTarget);
  662. return;
  663. }
  664. const target = event.target.closest(".sourceTag");
  665. if (!target) return;
  666. event.preventDefault();
  667. event.stopPropagation();
  668. setSourceFocus(target);
  669. });
  670. moduleJump?.addEventListener("change", () => jumpToModule(moduleJump.value));
  671. onlyUsedFields?.addEventListener("change", refreshLoadedModules);
  672. sourceFilter?.addEventListener("change", refreshLoadedModules);
  673. function initializeIndex(data) {
  674. runMeta = data;
  675. const modules = data.modules || [];
  676. moduleTotal = modules.length;
  677. updateLoadProgress();
  678. const groups = new Map();
  679. modules.forEach((module) => { if (!groups.has(module.group)) groups.set(module.group, []); groups.get(module.group).push(module); });
  680. root.innerHTML = [...groups.entries()].map(([group, items]) => `<section class="auditGroup"><header class="groupHeader"><h2>${escapeHtml(group)}</h2><span>${items.length} 个模块</span></header>${items.map(moduleRow).join("")}</section>`).join("") || `<p class="fatal">这个 Run 没有可展示的模块。</p>`;
  681. populateModuleJump(modules);
  682. const initialModule = new URLSearchParams(location.hash.slice(1)).get("module");
  683. if (initialModule) {
  684. moduleJump.value = initialModule;
  685. jumpToModule(initialModule, { instant: true, updateHash: false });
  686. }
  687. }
  688. function handleStreamMessage(message) {
  689. if (message.type === "index") {
  690. initializeIndex(message.data || {});
  691. return;
  692. }
  693. if (message.type === "detail") {
  694. enqueueDetail(String(message.moduleId || message.data?.id || ""), message.data || {});
  695. return;
  696. }
  697. if (message.type === "error") {
  698. const row = document.querySelector(`.moduleRow[data-module-id="${CSS.escape(String(message.moduleId || ""))}"]`);
  699. const target = row?.querySelector(".moduleDeferred");
  700. if (target) target.innerHTML = `<p class="loadError">读取失败:${escapeHtml(message.message || "未知错误")}</p>`;
  701. const state = row?.querySelector(".moduleStreamState");
  702. if (state) state.textContent = "读取失败";
  703. return;
  704. }
  705. if (message.type === "complete") {
  706. streamComplete = true;
  707. updateLoadProgress();
  708. }
  709. }
  710. function consumeLines(text, carry = "") {
  711. const lines = `${carry}${text}`.split("\n");
  712. const nextCarry = lines.pop() || "";
  713. lines.forEach((line) => {
  714. if (!line.trim()) return;
  715. handleStreamMessage(JSON.parse(line));
  716. });
  717. return nextCarry;
  718. }
  719. async function start() {
  720. try {
  721. const response = await fetch(`/api/script-builds/${encodeURIComponent(runId)}/module-audit-stream`, { headers: { Accept: "application/x-ndjson" }, cache: "no-store" });
  722. if (!response.ok) throw new Error(`API ${response.status}`);
  723. if (!response.body) {
  724. consumeLines(await response.text());
  725. return;
  726. }
  727. const reader = response.body.getReader();
  728. const decoder = new TextDecoder();
  729. let carry = "";
  730. while (true) {
  731. const { value, done } = await reader.read();
  732. if (done) break;
  733. carry = consumeLines(decoder.decode(value, { stream: true }), carry);
  734. }
  735. carry = consumeLines(decoder.decode(), carry);
  736. if (carry.trim()) handleStreamMessage(JSON.parse(carry));
  737. } catch (error) {
  738. runSummary.textContent = "读取失败";
  739. root.innerHTML = `<div class="fatal"><h2>无法读取模块数据对照</h2><p>${escapeHtml(error instanceof Error ? error.message : String(error))}</p></div>`;
  740. }
  741. }
  742. start();
  743. })();