report.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. /* views/report.js —— 报告层:一份分析报告在这个看板里的"读法"。
  2. *
  3. * 三件事:
  4. * 1. 目录(index) —— 哪些成果带报告、每条报告的证据厚度(结论数 / 引用成果数 / 带原文条数)
  5. * 2. 阅读(view) —— 封面 → 结构总览 → 章节(章首导语 + KPI + 交叉表 + 结论块 + 证据锚点)→ 附录
  6. * 3. 证据链(drawer)—— 点结论旁的锚点,抽屉里列出支撑它的每条成果(可跳到成果/报告),
  7. * 每条证据都标出「库里有没有这条成果」,回溯不到就如实说。
  8. *
  9. * 报告正文的数据形状见 README「报告数据结构」。这里的所有正文都经过 esc(),
  10. * report 字段是数据不是 HTML;未知的 section.kind 会原样列出 key/value,不会静默吞掉。
  11. */
  12. (function () {
  13. "use strict";
  14. const T = window.KF_TAXONOMY;
  15. const U = window.KF_UTIL;
  16. const Q = window.KF_QUERY;
  17. const UI = window.KF_UI;
  18. const esc = U.esc;
  19. /* ---------------- 小工具 ---------------- */
  20. const fmt = (n) => (typeof n === "number" ? n.toLocaleString("zh-CN") : esc(n));
  21. const plain = (s) => String(s == null ? "" : s).replace(/<[^>]*>/g, "").trim();
  22. /* 报告里引用成果 objectId 的地方,统一得到"这条成果在库里长什么样" */
  23. function cite(db, id) {
  24. if (!id) return null;
  25. const a = db.artifactById[id];
  26. if (!a) return { missing: true, id };
  27. return { a, missing: false, id };
  28. }
  29. function citeLabel(db, id) {
  30. const c = cite(db, id);
  31. if (!c) return `<span class="r-cite-missing">未登记成果</span>`;
  32. if (c.missing) return `<span class="r-cite-missing" title="报告引用了 ${esc(id)},但库里已经没有这条成果">${esc(id)}(库里已不存在)</span>`;
  33. const a = c.a;
  34. return `<a class="r-cite" href="#/artifacts?focus=${encodeURIComponent(id)}" title="在成果台账里定位这条成果">${esc(a.shortTitle)}<span class="who">${esc(a.ownerName || "未知")} · ${esc(a.stageObj ? a.stageObj.code : a.stage)}</span></a>`;
  35. }
  36. /* 证据锚点:报告里出现结论的地方都放一个,点了打开 ?ev=抽屉 */
  37. function anchor(evId, label, count) {
  38. if (!evId) return "";
  39. return `<button class="r-anchor" data-act="open-evidence" data-id="${esc(evId)}" aria-label="查看证据链">
  40. ${UI.icons.link}<span>${esc(label || "证据链")}</span>${count != null ? `<b>${count}</b>` : ""}</button>`;
  41. }
  42. function evidenceOf(report, evId) {
  43. const e = report && report.evidence && evId ? report.evidence[evId] : null;
  44. if (!e) return null;
  45. const quotes = Array.isArray(e.quotes) ? e.quotes : [];
  46. const derived = Array.isArray(e.derivedFrom) ? e.derivedFrom.filter(Boolean) : [];
  47. return { ...e, quotes, derived };
  48. }
  49. /* ---------------- 目录页 ---------------- */
  50. function reportStats(db, a) {
  51. const r = a.report;
  52. const evidence = r.evidence || {};
  53. const ids = new Set();
  54. Object.values(evidence).forEach((e) => (e.derivedFrom || []).forEach((id) => id && ids.add(id)));
  55. const inLib = [...ids].filter((id) => db.artifactById[id]);
  56. const quotes = Object.values(evidence).reduce((n, e) => n + (Array.isArray(e.quotes) ? e.quotes.length : 0), 0);
  57. const verified = Object.values(evidence).filter((e) => (e.quotes || []).length > 0).length;
  58. return {
  59. chapters: (r.chapters || []).length,
  60. conclusions: Object.keys(evidence).length,
  61. refs: ids.size,
  62. refsInLib: inLib.length,
  63. quotes,
  64. verified,
  65. stages: [...new Set((r.chapters || []).map((c) => c.stage).filter(Boolean))]
  66. };
  67. }
  68. function indexCard(db, a) {
  69. const s = reportStats(db, a);
  70. const p = db.projectById[a.projectId];
  71. return `<a class="r-index-card" href="#/report/${encodeURIComponent(a.id)}" data-report-link="${esc(a.id)}">
  72. <div class="r-ic-top">
  73. <span class="r-ic-kicker">${esc(s.stages.length ? s.stages[0] + " → " + s.stages[s.stages.length - 1] : "报告")} · ${s.chapters} 章</span>
  74. ${UI.layerBadge(a.layer)}
  75. </div>
  76. <h3>${esc(a.report.title || a.shortTitle)}</h3>
  77. <p class="r-ic-sub">${esc(a.report.subtitle || a.summaryText || "")}</p>
  78. <div class="r-ic-stats">
  79. <span><b>${s.conclusions}</b>条结论</span>
  80. <span><b>${s.refsInLib}</b>条支撑成果</span>
  81. <span><b>${s.quotes}</b>条原文</span>
  82. </div>
  83. <div class="r-ic-foot">
  84. ${UI.person(a.ownerName)}${p ? `<span class="r-ic-proj">${esc(p.category || "")} · ${esc(p.title)}</span>` : ""}
  85. <span class="r-ic-go">${UI.icons.arrow}</span>
  86. </div>
  87. </a>`;
  88. }
  89. function index(db, c) {
  90. let reports = db.artifacts.filter((a) => a.report && typeof a.report === "object");
  91. if (c.q) {
  92. const q = c.q;
  93. reports = reports.filter((a) => {
  94. const r = a.report;
  95. const hay = [r.title, r.subtitle, a.ownerName, ...(r.executive || []),
  96. ...(r.chapters || []).flatMap((ch) => [ch.title, ch.lead, ...(ch.sections || []).map((s) => s.title)])]
  97. .filter(Boolean).join(" ").toLowerCase();
  98. return hay.includes(q);
  99. });
  100. }
  101. reports.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
  102. const body = !reports.length
  103. ? UI.empty({
  104. title: c.q ? "没有匹配的报告" : "还没有报告",
  105. body: c.q
  106. ? `现有没有报告命中「${esc(c.q)}」。报告会搜标题、章节目录和结论原文。`
  107. : "学员把一份完整的行业分析写进成果的 report 字段后,会在这里出现:可读的封面、章节、交叉表和结论,以及能一路追到原始成果的证据链。没有报告的成果仍可在「成果」页按台账查看。",
  108. actions: [{ label: "去成果台账", href: "#/artifacts" }]
  109. })
  110. : `<div class="r-index">${reports.map((a) => indexCard(db, a)).join("")}</div>`;
  111. const withReport = db.artifacts.filter((a) => a.report).length;
  112. return `<div class="view-head"><div>
  113. <h1>报告</h1>
  114. <p class="lede">不是一行标题,而是能翻开读的分析:每份报告有章节、有交叉表、有敢下判断的结论;每条结论旁都有证据锚点,能一路追到支撑它的原始成果。</p>
  115. </div>
  116. <div class="aside"><span class="r-count"><b>${withReport}</b> 份报告 / 库中 ${db.artifacts.length} 条成果</span></div>
  117. </div>
  118. <div data-list>${body}</div>`;
  119. }
  120. /* ---------------- 阅读页 ---------------- */
  121. /* 封面:项目名 / 方法论 / 数据源 / 数据管线 / 生成日期 / 出身 */
  122. function cover(db, a) {
  123. const r = a.report;
  124. const p = db.projectById[a.projectId];
  125. const s = reportStats(db, a);
  126. const pipeline = Array.isArray(r.pipeline) ? r.pipeline : [];
  127. const flow = pipeline.length
  128. ? `<div class="r-pipe" aria-label="数据管线">${pipeline.map((x, i) =>
  129. `${i ? `<span class="r-pipe-arrow" aria-hidden="true">→</span>` : ""}<span class="r-pipe-step"><b class="num">${fmt(x.value)}</b><span>${esc(x.label)}</span></span>`).join("")}
  130. ${r.filterRate ? `<span class="r-pipe-rate">过滤率 ${esc(r.filterRate)}</span>` : ""}</div>`
  131. : "";
  132. const meta = [
  133. ["方法论", r.method], ["数据源", r.sources], ["报告结构", r.structure],
  134. ["生成日期", r.generatedAt ? `${U.fmtDate(r.generatedAt, true)}` : ""],
  135. ].filter(([, v]) => v);
  136. return `<header class="r-cover" id="cover">
  137. <div class="r-cover-main">
  138. <div class="r-kicker">
  139. ${UI.layerBadge(a.layer)}<span>${esc(a.stage)}</span><span>${esc(a.typeLabel)}</span>
  140. <span class="r-owner">${esc(a.ownerName || "未知成员")}${p ? ` · ${esc(p.title)}` : ""}</span>
  141. </div>
  142. <h1>${esc(r.title || a.shortTitle)}</h1>
  143. ${r.subtitle ? `<p class="r-sub">${esc(r.subtitle)}</p>` : ""}
  144. ${flow}
  145. ${meta.length ? `<dl class="r-meta">${meta.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join("")}</dl>` : ""}
  146. </div>
  147. <aside class="r-cover-side" aria-label="报告完整性">
  148. <div class="r-side-t">此报告的证据链</div>
  149. <div class="r-side-row"><span>结论</span><b class="num">${s.conclusions}</b></div>
  150. <div class="r-side-row"><span>引用成果</span><b class="num">${s.refs}</b></div>
  151. <div class="r-side-row ${s.refsInLib < s.refs ? "warn" : ""}"><span>其中库里可追</span><b class="num">${s.refsInLib}</b></div>
  152. <div class="r-side-row ${s.verified < s.conclusions ? "warn" : ""}"><span>带原文的结论</span><b class="num">${s.verified} / ${s.conclusions}</b></div>
  153. <div class="r-side-row"><span>原文片段</span><b class="num">${s.quotes}</b></div>
  154. <a class="r-side-link" href="#/artifacts?focus=${encodeURIComponent(a.id)}">${UI.icons.doc}在台账中查看这条成果</a>
  155. </aside>
  156. </header>`;
  157. }
  158. function structure(report) {
  159. const chs = report.chapters || [];
  160. if (!chs.length) return "";
  161. return `<section class="r-block r-structure" aria-label="报告结构">
  162. <h2>报告结构</h2>
  163. <div class="r-structure-grid">${chs.map((ch) => `
  164. <a class="r-ch-card" href="#" data-scroll-to="${esc(ch.id)}" title="跳到第 ${esc(ch.no)} 章">
  165. <span class="r-ch-no">${esc(ch.stage || "")} · 第 ${esc(ch.no)} 章${ch.en ? " · " + esc(ch.en) : ""}</span>
  166. <b>${esc(ch.title)}</b>
  167. <span>${esc(plain(ch.lead).slice(0, 46))}${plain(ch.lead).length > 46 ? "…" : ""}</span>
  168. </a>`).join("")}</div>
  169. </section>`;
  170. }
  171. function executive(report) {
  172. const ex = report.executive || [];
  173. if (!ex.length) return "";
  174. return `<section class="r-block r-exec" aria-label="执行摘要">
  175. <h2>执行摘要 <span>先看这四句</span></h2>
  176. <ol>${ex.map((x, i) => `<li><span class="r-exec-no num">${String(i + 1).padStart(2, "0")}</span><p>${esc(x)}</p></li>`).join("")}</ol>
  177. </section>`;
  178. }
  179. function kpis(report) {
  180. const ks = report.kpis || [];
  181. if (!ks.length) return "";
  182. return `<div class="r-kpis">${ks.map((k) => `
  183. <div class="r-kpi">
  184. <div class="r-kpi-v"><span class="num">${fmt(k.value)}</span><small>${esc(k.unit || "")}</small></div>
  185. <div class="r-kpi-l">${esc(k.label || "")}</div>
  186. ${k.note ? `<div class="r-kpi-n">${esc(k.note)}</div>` : ""}
  187. </div>`).join("")}</div>`;
  188. }
  189. /* ---------------- section 渲染器 ---------------- */
  190. const sections = {
  191. para(s) {
  192. return `<p class="r-para">${esc(s.text)}</p>`;
  193. },
  194. /* 长论证:按空行切成多段,保留长文的可读节奏(每段限宽 74ch) */
  195. longpara(s) {
  196. const paras = String(s.text || "").split(/\n{2,}/).map((x) => x.trim()).filter(Boolean);
  197. const one = paras.length ? paras : [String(s.text || "")];
  198. return one.map((x) => `<p class="r-para">${esc(x)}</p>`).join("");
  199. },
  200. /* 漏斗:逐级收窄,每级一行,宽度按数值对数尺度示意(不伪造比例) */
  201. funnel(s) {
  202. const rows = s.data || [];
  203. const max = Math.max(...rows.map((x) => Number(x.value) || 0), 1);
  204. return `<ol class="r-funnel">${rows.map((x) => {
  205. const w = Math.max(18, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
  206. return `<li>
  207. <span class="r-f-stage">${esc(x.stage)}</span>
  208. <div class="r-f-body">
  209. <div class="r-f-head"><b>${esc(x.name)}</b><span class="r-f-scope">${esc(x.scope)}</span></div>
  210. <div class="r-f-bar"><i style="width:${w}%"></i></div>
  211. ${x.note ? `<div class="r-f-note">${esc(x.note)}</div>` : ""}
  212. </div>
  213. <span class="r-f-v"><b class="num">${fmt(x.value)}</b><small>${esc(x.unit || "")}</small></span>
  214. </li>`;
  215. }).join("")}</ol>`;
  216. },
  217. /* 嵌套交叉表:多列 × 多维单元格;带 id 的列渲染成证据锚点 */
  218. matrix(s, ctx) {
  219. const d = s.data || {};
  220. return `<div class="r-tbl-wrap"><table class="r-tbl r-matrix"><thead><tr>${
  221. (d.cols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${
  222. (d.rows || []).map((row) => `<tr>${row.map((cell, i) => {
  223. if (i === 0) return `<td class="r-td-k">${esc(cell)}</td>`;
  224. /* 多行文字(如"未满足需求"):一行一条,绝不用字符串切片 */
  225. if (Array.isArray(cell) && cell.length && cell.every((x) => typeof x === "string")) {
  226. return `<td>${cell.map((x) => `<div class="r-cell-line">${esc(x)}</div>`).join("")}</td>`;
  227. }
  228. /* 产品选择:细条形 + 百分比,比纯数字更快看出"谁占多数" */
  229. if (Array.isArray(cell)) {
  230. return `<td class="r-td-pick">${cell.map(([name, pct]) => `
  231. <div class="r-pick"><span class="r-pick-n">${esc(name)}</span><span class="r-mini"><i style="width:${Math.max(2, Math.min(100, Number(pct) || 0))}%"></i></span><b class="num">${fmt(pct)}%</b></div>`).join("")}</td>`;
  232. }
  233. if (typeof cell === "string" && ctx.evidence[cell]) {
  234. const e = ctx.evidence[cell];
  235. return `<td>${esc(e.claim)} ${anchor(cell, "看原文", (e.quotes || []).length)}</td>`;
  236. }
  237. if (typeof cell === "number") return `<td class="r-td-n num">${fmt(cell)}</td>`;
  238. return `<td>${esc(cell)}</td>`;
  239. }).join("")}</tr>`).join("")}</tbody></table></div>`;
  240. },
  241. /* 结论块带态度:标题是判断,正文是态度,右侧是证据锚点(人数 + 原文数) */
  242. findings(s, ctx) {
  243. return `<div class="r-findings">${(s.data || []).map((f) => {
  244. const e = ctx.evidence[f.evidence] || {};
  245. const quotes = (e.quotes || []).length;
  246. return `<article class="r-finding" data-finding="${esc(f.id || "")}">
  247. <div class="r-f-mark" aria-hidden="true"></div>
  248. <h4>${esc(f.conclusion)}</h4>
  249. <p>${esc(f.attitude)}</p>
  250. <div class="r-finding-ev">
  251. ${e.ownerCount != null ? `<span><b class="num">${e.ownerCount}</b>人独立提到</span>` : ""}
  252. <span class="${quotes ? "" : "warn"}"><b class="num">${quotes}</b>条原文</span>
  253. ${e.derived ? `<span><b class="num">${(e.derived || []).length}</b>条支撑成果</span>` : ""}
  254. ${anchor(f.evidence, "看证据链", null)}
  255. </div>
  256. </article>`;
  257. }).join("")}</div>`;
  258. },
  259. table(s, ctx) {
  260. const d = s.data || {};
  261. return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.cols || []).map((c, i) =>
  262. `<th class="${i && typeof (d.rows || [])[0]?.[i] === "number" ? "r" : ""}">${esc(c)}</th>`).join("")}</tr></thead><tbody>${
  263. (d.rows || []).map((row) => `<tr>${row.map((cell, i) =>
  264. `<td class="${typeof cell === "number" ? "r num" : (i === 0 ? "r-td-k" : "")}">${typeof cell === "number" ? fmt(cell) : esc(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
  265. },
  266. /* 能做到 / 做不到:两栏对照,红色一栏是红线 */
  267. two(s) {
  268. const d = s.data || {};
  269. return `<div class="r-two">
  270. <div class="r-two-col yes"><h5>✓ 能做到的</h5><ul>${(d.can || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
  271. <div class="r-two-col no"><h5>✗ 做不到的(红线)</h5><ul>${(d.cannot || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
  272. </div>`;
  273. },
  274. fit(s) {
  275. const d = s.data || {};
  276. return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.transferCols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead>
  277. <tbody>${(d.transfer || []).map((r) => `<tr>${r.map((c, i) => `<td class="${i === 2 ? "r-fit-flag" : ""}">${esc(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>
  278. <div class="r-tbl-wrap" style="margin-top:14px"><table class="r-tbl"><thead><tr>${(d.redlineCols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead>
  279. <tbody>${(d.redline || []).map((r) => `<tr>${r.map((c, i) => `<td class="${i === 0 ? "r-td-k" : (i === 1 ? "r-fit-risk" : "")}">${esc(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
  280. },
  281. score(s) {
  282. return `<div class="r-tbl-wrap"><table class="r-tbl r-score"><thead><tr>
  283. <th>机会</th><th class="r">需求强度</th><th class="r">竞争烈度</th><th class="r">我方能力</th><th>总分</th><th>结论</th></tr></thead><tbody>${
  284. (s.data || []).map(([name, need, comp, cap, total, verdict]) => `<tr>
  285. <td class="r-td-k">${esc(name)}</td>
  286. <td class="r num">${fmt(need)}</td><td class="r num">${fmt(comp)}</td><td class="r num">${fmt(cap)}</td>
  287. <td><span class="r-score-bar"><i style="width:${Math.max(0, Math.min(100, Number(total) || 0))}%"></i><b class="num">${fmt(total)}</b></span></td>
  288. <td>${esc(verdict)}</td></tr>`).join("")}</tbody></table></div>`;
  289. },
  290. /* 心智阶梯:从高到低的决策序列,最后一格是空位 */
  291. ladder(s) {
  292. const d = s.data || {};
  293. const alt = d.alt || [];
  294. const st = d.statement || "";
  295. return `<ol class="r-ladder">${(d.ladder || []).map(([no, name, note]) => `
  296. <li class="${/空/.test(name) ? "gap" : ""}"><span class="r-l-no num">${esc(no)}</span><span class="r-l-name">${esc(name)}</span><span class="r-l-note">${esc(note)}</span></li>`).join("")}</ol>
  297. ${st ? `<div class="r-stmt">
  298. <div class="r-stmt-k">一句话定位</div>
  299. <p class="r-stmt-v">「${esc(st)}」</p>
  300. ${d.for_who ? `<div class="r-stmt-k" style="margin-top:10px">写给谁:${esc(d.for_who)}</div>` : ""}
  301. </div>` : ""}`;
  302. },
  303. statement(s, ctx, sec) {
  304. /* 5.3 只渲染替代方案与自检;一句话定位已在 ladder 里给大字号 */
  305. const d = s.data || {};
  306. return `${(d.alt || []).length ? `<div class="r-alt"><div class="r-alt-t">备选方案(未采用,供讨论)</div>${d.alt.map((x) => `<div class="r-alt-i">${esc(x)}</div>`).join("")}</div>` : ""}`;
  307. },
  308. milestones(s) {
  309. return `<div class="r-miles">${(s.data || []).map(([m, name, checks, rule]) => `
  310. <div class="r-mile"><div class="r-mile-m">${esc(m)}</div><h5>${esc(name)}</h5>
  311. <ul class="r-mile-checks">${(checks || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>
  312. <div class="r-mile-rule"><span>决策规则</span>${esc(rule)}</div></div>`).join("")}</div>`;
  313. },
  314. /* 未知结构不丢:原样列出键值,提醒作者补渲染器 */
  315. unknown(s) {
  316. const flat = (v) => (v && typeof v === "object" ? JSON.stringify(v) : String(v));
  317. return `<div class="r-unknown"><div class="r-muted tiny">未知的结构类型「${esc(s.kind)}」,以下为原始数据:</div>
  318. <dl>${Object.entries(s.data || s).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(flat(v))}</dd></div>`).join("")}</dl></div>`;
  319. }
  320. };
  321. function renderSection(s, ctx, sec) {
  322. const fn = sections[s.kind] || sections.unknown;
  323. return `<div class="r-sec" id="${esc(s.id)}" data-anchor="${esc(s.id)}">
  324. <h3 class="r-sec-h"><span class="num">${esc(s.no || "")}</span>${esc(s.title || "")}</h3>
  325. ${fn(s, ctx, sec)}
  326. ${s.note ? `<p class="r-sec-note">${esc(s.note)}</p>` : ""}
  327. </div>`;
  328. }
  329. function chapter(ch, ctx) {
  330. return `<section class="r-chapter" id="${esc(ch.id)}" data-chapter-id="${esc(ch.id)}" aria-labelledby="h-${esc(ch.id)}">
  331. <div class="r-ch-head">
  332. <div class="r-ch-kicker"><span>${esc(ch.stage || "")} · CHAPTER ${esc(ch.no)}</span><span class="r-ch-en">${esc(ch.en || "")}</span></div>
  333. <h2 id="h-${esc(ch.id)}">${esc(ch.title)}</h2>
  334. ${ch.subtitle ? `<p class="r-ch-sub">${esc(ch.subtitle)}</p>` : ""}
  335. </div>
  336. <p class="r-ch-lead">${esc(ch.lead || "")}</p>
  337. ${(ch.sections || []).map((s) => renderSection(s, ctx, ch)).join("")}
  338. </section>`;
  339. }
  340. /* 附录:本报告所有结论 → 支撑成果 → 原文,一张总表,方便一次性核对证据 */
  341. function appendix(db, a, report) {
  342. const ev = report.evidence || {};
  343. const ids = Object.keys(ev);
  344. if (!ids.length) return "";
  345. return `<section class="r-block" aria-label="证据附录">
  346. <h2>附录 · 结论与证据对照</h2>
  347. <p class="r-muted r-para">每条结论 → 由谁提到 → 支撑它的成果在库里是否可追 → 带几条原文。点结论可打开证据链,点成果回到台账。</p>
  348. <div class="r-tbl-wrap"><table class="r-tbl"><thead><tr><th>结论</th><th class="r">提到人数</th><th class="r">原文</th><th>支撑成果(可追 / 引用)</th><th></th></tr></thead><tbody>${
  349. ids.map((id) => {
  350. const e = ev[id];
  351. const quotes = Array.isArray(e.quotes) ? e.quotes.length : 0;
  352. const derived = (e.derivedFrom || []).filter(Boolean);
  353. const inLib = derived.filter((x) => db.artifactById[x]).length;
  354. return `<tr>
  355. <td class="r-td-k"><button class="r-linkbtn" data-act="open-evidence" data-id="${esc(id)}">${esc(e.claim || id)}</button></td>
  356. <td class="r num">${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</td>
  357. <td class="r num ${quotes ? "" : "warn"}">${quotes}</td>
  358. <td>${inLib} / ${derived.length}${inLib < derived.length ? `<span class="r-cite-missing">(${derived.length - inLib} 条未接入)</span>` : ""}</td>
  359. <td class="r">${anchor(id, "证据链")}</td></tr>`;
  360. }).join("")}</tbody></table></div>
  361. </section>`;
  362. }
  363. function toc(report) {
  364. const chs = report.chapters || [];
  365. return `<nav class="r-toc" aria-label="章节目录">
  366. <a href="#" data-scroll-to="cover" class="r-toc-cover">封面与摘要</a>
  367. <ol>${chs.map((ch) => `
  368. <li><a href="#" data-scroll-to="${esc(ch.id)}" data-chapter="${esc(ch.id)}">
  369. <span class="r-toc-no num">${esc(ch.no)}</span><span class="r-toc-t">${esc(ch.title)}</span>
  370. ${ch.stage ? `<span class="r-toc-s">${esc(ch.stage)}</span>` : ""}
  371. </a></li>`).join("")}</ol>
  372. </nav>`;
  373. }
  374. function view(db, id, params) {
  375. const a = db.artifactById[id];
  376. if (!a || !a.report) return null;
  377. const report = a.report;
  378. const ctx = { db, evidence: this_evidence(report) };
  379. const p = db.projectById[a.projectId];
  380. return `<div class="report" data-report="${esc(a.id)}">
  381. <div class="r-topbar">
  382. <a class="btn btn-ghost" href="#/artifacts">${UI.icons.back} 成果台账</a>
  383. ${p ? `<a class="btn btn-ghost" href="#/project/${encodeURIComponent(p.id)}">项目:${esc(p.title)}</a>` : ""}
  384. <span class="r-topbar-note">${esc(a.ownerName || "")} · ${esc(a.stage)}</span>
  385. </div>
  386. ${toc(report)}
  387. <article class="r-paper">
  388. ${cover(db, a)}
  389. ${structure(report)}
  390. ${executive(report)}
  391. ${kpis(report)}
  392. ${(report.chapters || []).map((ch) => chapter(ch, ctx)).join("")}
  393. ${appendix(db, a, report)}
  394. <footer class="r-foot">
  395. <span>报告由 ${esc(a.ownerName || "学员")} 在 ${esc(a.stage)} 产出 · 数据管线 ${esc((report.pipeline || []).map((x) => fmt(x.value)).join(" → ") || "—")}</span>
  396. <a href="#/artifacts?focus=${encodeURIComponent(a.id)}">回到台账查看这条成果与它的出身</a>
  397. </footer>
  398. </article>
  399. </div>`;
  400. }
  401. /* ---------------- 报告页的滚动高亮 ----------------
  402. * 不依赖 IntersectionObserver:在 scroll(rAF 节流)里实时算"当前章"——
  403. * 规则:章节顶部越过顶部偏下的一条基准线(视口 30% 处)的最后一个,就是当前章;
  404. * 章节比整屏还高时,只要它占住这条基准线就算当前章。
  405. * 点击目录后 650ms 内锁住高亮,让平滑滚动途中不来回跳。 */
  406. let segmentLockUntil = 0;
  407. function currentChapter(root, ids) {
  408. let cur = ids[0];
  409. const line = Math.max(120, window.innerHeight * 0.3);
  410. for (const id of ids) {
  411. const el = root.querySelector(`[data-chapter-id="${id}"]`);
  412. if (!el) continue;
  413. const r = el.getBoundingClientRect();
  414. if (r.top - line <= 0.5) cur = id;
  415. if (r.top <= line && r.bottom > line) return id;
  416. }
  417. return cur;
  418. }
  419. function paintToc(root, id) {
  420. const links = root.querySelectorAll(".r-toc a[data-chapter]");
  421. links.forEach((a) => {
  422. const on = a.dataset.chapter === id;
  423. a.classList.toggle("on", on);
  424. if (on) a.setAttribute("aria-current", "true"); else a.removeAttribute("aria-current");
  425. });
  426. }
  427. function bind(root, reportId) {
  428. const ids = [...root.querySelectorAll("[data-chapter-id]")].map((n) => n.dataset.chapterId);
  429. if (!ids.length) return;
  430. let lastPaint = "";
  431. const onScroll = () => {
  432. if (Date.now() < segmentLockUntil) return;
  433. const id = currentChapter(root, ids);
  434. if (id === lastPaint) return;
  435. lastPaint = id;
  436. paintToc(root, id);
  437. };
  438. window.addEventListener("scroll", onScroll, { passive: true });
  439. window.addEventListener("resize", onScroll, { passive: true });
  440. const jump = (id) => {
  441. const target = root.querySelector(`[data-chapter-id="${id}"]`) || root.querySelector("#cover");
  442. if (!target) return;
  443. segmentLockUntil = Date.now() + 700;
  444. paintToc(root, id);
  445. const top = target.getBoundingClientRect().top + window.scrollY - 96;
  446. window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
  447. window.__KF_ROUTER && window.KF_ROUTER.write({ ...window.KF_ROUTER.parseHash().params, ch: id }, { silent: true });
  448. };
  449. root.querySelectorAll("[data-scroll-to]").forEach((a) => {
  450. a.addEventListener("click", (ev) => {
  451. ev.preventDefault();
  452. const id = a.dataset.scrollTo;
  453. if (id === "cover") { segmentLockUntil = Date.now() + 700; window.scrollTo({ top: 0, behavior: "smooth" }); return; }
  454. jump(id);
  455. });
  456. });
  457. /* 直接带 ?ch= 打开:还原到那一章,并先画出高亮 */
  458. const want = (window.KF_ROUTER.parseHash().params || {}).ch;
  459. if (want && ids.includes(want)) {
  460. const t = root.querySelector(`[data-chapter-id="${want}"]`);
  461. segmentLockUntil = Date.now() + 250;
  462. paintToc(root, want);
  463. requestAnimationFrame(() => window.scrollTo(0, t.getBoundingClientRect().top + window.scrollY - 96));
  464. } else {
  465. paintToc(root, currentChapter(root, ids));
  466. }
  467. if (history.scrollRestoration) history.scrollRestoration = "manual";
  468. }
  469. function this_evidence(report) {
  470. const ev = report && report.evidence ? report.evidence : {};
  471. const out = {};
  472. Object.keys(ev).forEach((k) => { out[k] = ev[k]; });
  473. return out;
  474. }
  475. /* ---------------- 证据链抽屉 ---------------- */
  476. /* 找到所有"在报告里引用这条成果"的结论(跨库内全部报告) */
  477. function conclusionsCiting(db, artId) {
  478. const out = [];
  479. db.artifacts.forEach((a) => {
  480. const ev = a.report && a.report.evidence;
  481. if (!ev) return;
  482. Object.keys(ev).forEach((k) => {
  483. if ((ev[k].derivedFrom || []).includes(artId)) out.push({ report: a, evId: k, claim: ev[k].claim });
  484. });
  485. });
  486. return out;
  487. }
  488. function evidenceDrawer(db, evId, back) {
  489. /* 一份报告里的结论 ID 可能撞车(不同报告都有自己的 c-xxx),所以先把所有带该结论的报告找出来 */
  490. const owners = db.artifacts.filter((a) => a.report && a.report.evidence && a.report.evidence[evId]);
  491. if (!owners.length) {
  492. return { title: "证据链", html: UI.empty({ sm: true, title: "找不到这条结论", body: "链接可能来自旧版本报告,结论已经更新。" }) };
  493. }
  494. const report = owners[0];
  495. const e = report.report.evidence[evId];
  496. const quotes = Array.isArray(e.quotes) ? e.quotes : [];
  497. const derived = (e.derivedFrom || []).filter(Boolean);
  498. const inLib = derived.filter((x) => db.artifactById[x]);
  499. const rows = derived.map((artId, i) => {
  500. const a = db.artifactById[artId];
  501. const qs = quotes.filter((qq) => qq.artId === artId);
  502. if (!a) {
  503. return `<div class="trace-item"><span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
  504. <div><div class="t">${esc(artId)} <span class="r-cite-missing">库里已不存在</span></div>
  505. <div class="m">这条结论引用的成果在库中找不到,证据链在此断掉。</div></div><span></span></div>`;
  506. }
  507. const cs = conclusionsCiting(db, artId);
  508. return `<div class="trace-item">
  509. <span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
  510. <div>
  511. <a class="t" href="#/artifacts?focus=${encodeURIComponent(a.id)}" data-act="nav" data-href="#/artifacts?focus=${encodeURIComponent(a.id)}">${esc(a.shortTitle)}</a>
  512. <div class="m"><span>${esc(a.ownerName || "未知成员")}</span>${a.ownerKey ? "" : `<span class="una">缺少出身</span>`}${UI.layerBadge(a.layer)}${UI.evidenceBadge(a)}<span>${esc(a.stageObj ? a.stageObj.code : a.stage)}</span>${a.report ? "<span>带完整报告</span>" : ""}</div>
  513. <div class="m">被 ${cs.length} 条结论引用${cs.length > 1 ? `(含本链:${esc(cs.map((x) => x.report.shortTitle).slice(0, 2).join("、"))})` : ""}</div>
  514. ${qs.length ? qs.map((qq) => `<div class="evi">「${esc(qq.text)}」<span class="tiny muted">— ${esc(qq.author || "")}${qq.ref ? " · " + esc(qq.ref) : ""}${qq.likes ? " · ♥" + fmt(qq.likes) : ""}</span></div>`).join("")
  515. : `<div class="evi r-muted">这条成果本身没有直接原文;结论里的原文来自其他成果。</div>`}
  516. </div>
  517. <a class="trace-btn" href="#/report/${encodeURIComponent(report.id)}" data-act="nav" data-href="#/report/${encodeURIComponent(report.id)}" title="打开所在报告">所在报告</a>
  518. </div>`;
  519. }).join("");
  520. const orphanQuotes = quotes.filter((qq) => !qq.artId || !derived.includes(qq.artId));
  521. const body = `
  522. <div class="block"><div class="bl">这条结论的判断依据</div>
  523. <div class="kpi-inline" style="margin-top:2px;justify-content:flex-start">
  524. <span><b>${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</b>人独立提到</span>
  525. <span class="${quotes.length ? "" : "warn"}"><b>${quotes.length}</b>条原文</span>
  526. <span><b>${derived.length}</b>条支撑成果</span>
  527. </div>
  528. <p class="tiny muted" style="margin-top:8px">${inLib.length < derived.length
  529. ? `<b class="warn-txt">${derived.length - inLib.length} 条引用的成果在库里已不存在</b>,证据链不完整——这本身就是一个需要补数据的信号。`
  530. : "证据链完整:每条引用的成果都能在台账里找到。"}</p>
  531. </div>
  532. <div class="block"><div class="bl">支撑成果 <span class="n">${derived.length}</span></div>
  533. ${derived.length ? rows : UI.empty({ sm: true, title: "没有支撑成果", body: "这条结论还没有指向任何过程层成果——按规则不能回灌。" })}
  534. </div>
  535. ${orphanQuotes.length ? `<div class="block"><div class="bl">未挂到成果上的原文 <span class="n">${orphanQuotes.length}</span></div>
  536. ${orphanQuotes.map((qq) => `<div class="quote"><div>「${esc(qq.text)}」</div><div class="who">${esc(qq.author || "")}${qq.ref ? " · " + esc(qq.ref) : ""}${qq.likes ? " · ♥" + fmt(qq.likes) : ""}</div></div>`).join("")}
  537. <p class="tiny muted">这些原文来自外部采集,没有对应的学员成果记录。</p></div>` : ""}
  538. <div class="block"><div class="bl">所属报告</div>
  539. <a href="#/report/${encodeURIComponent(report.id)}" data-act="nav" data-href="#/report/${encodeURIComponent(report.id)}" class="r-cite">${esc(report.report.title || report.shortTitle)}</a>
  540. </div>`;
  541. return {
  542. title: esc(e.claim || "证据链"),
  543. kicker: "",
  544. html: `<div class="drawer-h"><div style="min-width:0"><div class="kicker">${back ? `<a href="#" data-act="drawer-back" data-href="#/artifacts?focus=${encodeURIComponent(back.id)}">← 返回成果</a> · ` : ""}证据链 · 来自报告《${esc(report.report.title || report.shortTitle)}》</div><h3>${esc(e.claim || "")}</h3></div>
  545. <button class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button></div>
  546. <div class="drawer-b">${body}</div>
  547. <div class="drawer-f"><button class="btn btn-ghost" data-act="close-drawer">关闭</button>
  548. <a class="btn" href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}" data-act="nav" data-href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}">看提交者全部成果</a></div>`
  549. };
  550. }
  551. window.KF_REPORT = { index, view, evidenceDrawer, conclusionsCiting, reportStats, bind };
  552. })();