| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- /* views/report.js —— 报告层:一份分析报告在这个看板里的"读法"。
- *
- * 三件事:
- * 1. 目录(index) —— 哪些成果带报告、每条报告的证据厚度(结论数 / 引用成果数 / 带原文条数)
- * 2. 阅读(view) —— 封面 → 结构总览 → 章节(章首导语 + KPI + 交叉表 + 结论块 + 证据锚点)→ 附录
- * 3. 证据链(drawer)—— 点结论旁的锚点,抽屉里列出支撑它的每条成果(可跳到成果/报告),
- * 每条证据都标出「库里有没有这条成果」,回溯不到就如实说。
- *
- * 报告正文的数据形状见 README「报告数据结构」。这里的所有正文都经过 esc(),
- * report 字段是数据不是 HTML;未知的 section.kind 会原样列出 key/value,不会静默吞掉。
- */
- (function () {
- "use strict";
- const T = window.KF_TAXONOMY;
- const U = window.KF_UTIL;
- const Q = window.KF_QUERY;
- const UI = window.KF_UI;
- const esc = U.esc;
- /* ---------------- 小工具 ---------------- */
- const fmt = (n) => (typeof n === "number" ? n.toLocaleString("zh-CN") : esc(n));
- const plain = (s) => String(s == null ? "" : s).replace(/<[^>]*>/g, "").trim();
- /* 报告里引用成果 objectId 的地方,统一得到"这条成果在库里长什么样" */
- function cite(db, id) {
- if (!id) return null;
- const a = db.artifactById[id];
- if (!a) return { missing: true, id };
- return { a, missing: false, id };
- }
- function citeLabel(db, id) {
- const c = cite(db, id);
- if (!c) return `<span class="r-cite-missing">未登记成果</span>`;
- if (c.missing) return `<span class="r-cite-missing" title="报告引用了 ${esc(id)},但库里已经没有这条成果">${esc(id)}(库里已不存在)</span>`;
- const a = c.a;
- 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>`;
- }
- /* 证据锚点:报告里出现结论的地方都放一个,点了打开 ?ev=抽屉 */
- function anchor(evId, label, count) {
- if (!evId) return "";
- return `<button class="r-anchor" data-act="open-evidence" data-id="${esc(evId)}" aria-label="查看证据链">
- ${UI.icons.link}<span>${esc(label || "证据链")}</span>${count != null ? `<b>${count}</b>` : ""}</button>`;
- }
- function evidenceOf(report, evId) {
- const e = report && report.evidence && evId ? report.evidence[evId] : null;
- if (!e) return null;
- const quotes = Array.isArray(e.quotes) ? e.quotes : [];
- const derived = Array.isArray(e.derivedFrom) ? e.derivedFrom.filter(Boolean) : [];
- return { ...e, quotes, derived };
- }
- /* ---------------- 目录页 ---------------- */
- function reportStats(db, a) {
- const r = a.report;
- const evidence = r.evidence || {};
- const ids = new Set();
- Object.values(evidence).forEach((e) => (e.derivedFrom || []).forEach((id) => id && ids.add(id)));
- const inLib = [...ids].filter((id) => db.artifactById[id]);
- const quotes = Object.values(evidence).reduce((n, e) => n + (Array.isArray(e.quotes) ? e.quotes.length : 0), 0);
- const verified = Object.values(evidence).filter((e) => (e.quotes || []).length > 0).length;
- return {
- chapters: (r.chapters || []).length,
- conclusions: Object.keys(evidence).length,
- refs: ids.size,
- refsInLib: inLib.length,
- quotes,
- verified,
- stages: [...new Set((r.chapters || []).map((c) => c.stage).filter(Boolean))]
- };
- }
- function indexCard(db, a) {
- const s = reportStats(db, a);
- const p = db.projectById[a.projectId];
- return `<a class="r-index-card" href="#/report/${encodeURIComponent(a.id)}" data-report-link="${esc(a.id)}">
- <div class="r-ic-top">
- <span class="r-ic-kicker">${esc(s.stages.length ? s.stages[0] + " → " + s.stages[s.stages.length - 1] : "报告")} · ${s.chapters} 章</span>
- ${UI.layerBadge(a.layer)}
- </div>
- <h3>${esc(a.report.title || a.shortTitle)}</h3>
- <p class="r-ic-sub">${esc(a.report.subtitle || a.summaryText || "")}</p>
- <div class="r-ic-stats">
- <span><b>${s.conclusions}</b>条结论</span>
- <span><b>${s.refsInLib}</b>条支撑成果</span>
- <span><b>${s.quotes}</b>条原文</span>
- </div>
- <div class="r-ic-foot">
- ${UI.person(a.ownerName)}${p ? `<span class="r-ic-proj">${esc(p.category || "")} · ${esc(p.title)}</span>` : ""}
- <span class="r-ic-go">${UI.icons.arrow}</span>
- </div>
- </a>`;
- }
- function index(db, c) {
- let reports = db.artifacts.filter((a) => a.report && typeof a.report === "object");
- if (c.q) {
- const q = c.q;
- reports = reports.filter((a) => {
- const r = a.report;
- const hay = [r.title, r.subtitle, a.ownerName, ...(r.executive || []),
- ...(r.chapters || []).flatMap((ch) => [ch.title, ch.lead, ...(ch.sections || []).map((s) => s.title)])]
- .filter(Boolean).join(" ").toLowerCase();
- return hay.includes(q);
- });
- }
- reports.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
- const body = !reports.length
- ? UI.empty({
- title: c.q ? "没有匹配的报告" : "还没有报告",
- body: c.q
- ? `现有没有报告命中「${esc(c.q)}」。报告会搜标题、章节目录和结论原文。`
- : "学员把一份完整的行业分析写进成果的 report 字段后,会在这里出现:可读的封面、章节、交叉表和结论,以及能一路追到原始成果的证据链。没有报告的成果仍可在「成果」页按台账查看。",
- actions: [{ label: "去成果台账", href: "#/artifacts" }]
- })
- : `<div class="r-index">${reports.map((a) => indexCard(db, a)).join("")}</div>`;
- const withReport = db.artifacts.filter((a) => a.report).length;
- return `<div class="view-head"><div>
- <h1>报告</h1>
- <p class="lede">不是一行标题,而是能翻开读的分析:每份报告有章节、有交叉表、有敢下判断的结论;每条结论旁都有证据锚点,能一路追到支撑它的原始成果。</p>
- </div>
- <div class="aside"><span class="r-count"><b>${withReport}</b> 份报告 / 库中 ${db.artifacts.length} 条成果</span></div>
- </div>
- <div data-list>${body}</div>`;
- }
- /* ---------------- 阅读页 ---------------- */
- /* 封面:项目名 / 方法论 / 数据源 / 数据管线 / 生成日期 / 出身 */
- function cover(db, a) {
- const r = a.report;
- const p = db.projectById[a.projectId];
- const s = reportStats(db, a);
- const pipeline = Array.isArray(r.pipeline) ? r.pipeline : [];
- const flow = pipeline.length
- ? `<div class="r-pipe" aria-label="数据管线">${pipeline.map((x, i) =>
- `${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("")}
- ${r.filterRate ? `<span class="r-pipe-rate">过滤率 ${esc(r.filterRate)}</span>` : ""}</div>`
- : "";
- const meta = [
- ["方法论", r.method], ["数据源", r.sources], ["报告结构", r.structure],
- ["生成日期", r.generatedAt ? `${U.fmtDate(r.generatedAt, true)}` : ""],
- ].filter(([, v]) => v);
- return `<header class="r-cover" id="cover">
- <div class="r-cover-main">
- <div class="r-kicker">
- ${UI.layerBadge(a.layer)}<span>${esc(a.stage)}</span><span>${esc(a.typeLabel)}</span>
- <span class="r-owner">${esc(a.ownerName || "未知成员")}${p ? ` · ${esc(p.title)}` : ""}</span>
- </div>
- <h1>${esc(r.title || a.shortTitle)}</h1>
- ${r.subtitle ? `<p class="r-sub">${esc(r.subtitle)}</p>` : ""}
- ${flow}
- ${meta.length ? `<dl class="r-meta">${meta.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join("")}</dl>` : ""}
- </div>
- <aside class="r-cover-side" aria-label="报告完整性">
- <div class="r-side-t">此报告的证据链</div>
- <div class="r-side-row"><span>结论</span><b class="num">${s.conclusions}</b></div>
- <div class="r-side-row"><span>引用成果</span><b class="num">${s.refs}</b></div>
- <div class="r-side-row ${s.refsInLib < s.refs ? "warn" : ""}"><span>其中库里可追</span><b class="num">${s.refsInLib}</b></div>
- <div class="r-side-row ${s.verified < s.conclusions ? "warn" : ""}"><span>带原文的结论</span><b class="num">${s.verified} / ${s.conclusions}</b></div>
- <div class="r-side-row"><span>原文片段</span><b class="num">${s.quotes}</b></div>
- <a class="r-side-link" href="#/artifacts?focus=${encodeURIComponent(a.id)}">${UI.icons.doc}在台账中查看这条成果</a>
- </aside>
- </header>`;
- }
- function structure(report) {
- const chs = report.chapters || [];
- if (!chs.length) return "";
- return `<section class="r-block r-structure" aria-label="报告结构">
- <h2>报告结构</h2>
- <div class="r-structure-grid">${chs.map((ch) => `
- <a class="r-ch-card" href="#" data-scroll-to="${esc(ch.id)}" title="跳到第 ${esc(ch.no)} 章">
- <span class="r-ch-no">${esc(ch.stage || "")} · 第 ${esc(ch.no)} 章${ch.en ? " · " + esc(ch.en) : ""}</span>
- <b>${esc(ch.title)}</b>
- <span>${esc(plain(ch.lead).slice(0, 46))}${plain(ch.lead).length > 46 ? "…" : ""}</span>
- </a>`).join("")}</div>
- </section>`;
- }
- function executive(report) {
- const ex = report.executive || [];
- if (!ex.length) return "";
- return `<section class="r-block r-exec" aria-label="执行摘要">
- <h2>执行摘要 <span>先看这四句</span></h2>
- <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>
- </section>`;
- }
- function kpis(report) {
- const ks = report.kpis || [];
- if (!ks.length) return "";
- return `<div class="r-kpis">${ks.map((k) => `
- <div class="r-kpi">
- <div class="r-kpi-v"><span class="num">${fmt(k.value)}</span><small>${esc(k.unit || "")}</small></div>
- <div class="r-kpi-l">${esc(k.label || "")}</div>
- ${k.note ? `<div class="r-kpi-n">${esc(k.note)}</div>` : ""}
- </div>`).join("")}</div>`;
- }
- /* ---------------- section 渲染器 ---------------- */
- const sections = {
- para(s) {
- return `<p class="r-para">${esc(s.text)}</p>`;
- },
- /* 长论证:按空行切成多段,保留长文的可读节奏(每段限宽 74ch) */
- longpara(s) {
- const paras = String(s.text || "").split(/\n{2,}/).map((x) => x.trim()).filter(Boolean);
- const one = paras.length ? paras : [String(s.text || "")];
- return one.map((x) => `<p class="r-para">${esc(x)}</p>`).join("");
- },
- /* 漏斗:逐级收窄,每级一行,宽度按数值对数尺度示意(不伪造比例) */
- funnel(s) {
- const rows = s.data || [];
- const max = Math.max(...rows.map((x) => Number(x.value) || 0), 1);
- return `<ol class="r-funnel">${rows.map((x) => {
- const w = Math.max(18, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
- return `<li>
- <span class="r-f-stage">${esc(x.stage)}</span>
- <div class="r-f-body">
- <div class="r-f-head"><b>${esc(x.name)}</b><span class="r-f-scope">${esc(x.scope)}</span></div>
- <div class="r-f-bar"><i style="width:${w}%"></i></div>
- ${x.note ? `<div class="r-f-note">${esc(x.note)}</div>` : ""}
- </div>
- <span class="r-f-v"><b class="num">${fmt(x.value)}</b><small>${esc(x.unit || "")}</small></span>
- </li>`;
- }).join("")}</ol>`;
- },
- /* 嵌套交叉表:多列 × 多维单元格;带 id 的列渲染成证据锚点 */
- matrix(s, ctx) {
- const d = s.data || {};
- return `<div class="r-tbl-wrap"><table class="r-tbl r-matrix"><thead><tr>${
- (d.cols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${
- (d.rows || []).map((row) => `<tr>${row.map((cell, i) => {
- if (i === 0) return `<td class="r-td-k">${esc(cell)}</td>`;
- /* 多行文字(如"未满足需求"):一行一条,绝不用字符串切片 */
- if (Array.isArray(cell) && cell.length && cell.every((x) => typeof x === "string")) {
- return `<td>${cell.map((x) => `<div class="r-cell-line">${esc(x)}</div>`).join("")}</td>`;
- }
- /* 产品选择:细条形 + 百分比,比纯数字更快看出"谁占多数" */
- if (Array.isArray(cell)) {
- return `<td class="r-td-pick">${cell.map(([name, pct]) => `
- <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>`;
- }
- if (typeof cell === "string" && ctx.evidence[cell]) {
- const e = ctx.evidence[cell];
- return `<td>${esc(e.claim)} ${anchor(cell, "看原文", (e.quotes || []).length)}</td>`;
- }
- if (typeof cell === "number") return `<td class="r-td-n num">${fmt(cell)}</td>`;
- return `<td>${esc(cell)}</td>`;
- }).join("")}</tr>`).join("")}</tbody></table></div>`;
- },
- /* 结论块带态度:标题是判断,正文是态度,右侧是证据锚点(人数 + 原文数) */
- findings(s, ctx) {
- return `<div class="r-findings">${(s.data || []).map((f) => {
- const e = ctx.evidence[f.evidence] || {};
- const quotes = (e.quotes || []).length;
- return `<article class="r-finding" data-finding="${esc(f.id || "")}">
- <div class="r-f-mark" aria-hidden="true"></div>
- <h4>${esc(f.conclusion)}</h4>
- <p>${esc(f.attitude)}</p>
- <div class="r-finding-ev">
- ${e.ownerCount != null ? `<span><b class="num">${e.ownerCount}</b>人独立提到</span>` : ""}
- <span class="${quotes ? "" : "warn"}"><b class="num">${quotes}</b>条原文</span>
- ${e.derived ? `<span><b class="num">${(e.derived || []).length}</b>条支撑成果</span>` : ""}
- ${anchor(f.evidence, "看证据链", null)}
- </div>
- </article>`;
- }).join("")}</div>`;
- },
- table(s, ctx) {
- const d = s.data || {};
- return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.cols || []).map((c, i) =>
- `<th class="${i && typeof (d.rows || [])[0]?.[i] === "number" ? "r" : ""}">${esc(c)}</th>`).join("")}</tr></thead><tbody>${
- (d.rows || []).map((row) => `<tr>${row.map((cell, i) =>
- `<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>`;
- },
- /* 能做到 / 做不到:两栏对照,红色一栏是红线 */
- two(s) {
- const d = s.data || {};
- return `<div class="r-two">
- <div class="r-two-col yes"><h5>✓ 能做到的</h5><ul>${(d.can || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
- <div class="r-two-col no"><h5>✗ 做不到的(红线)</h5><ul>${(d.cannot || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
- </div>`;
- },
- fit(s) {
- const d = s.data || {};
- return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.transferCols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead>
- <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>
- <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>
- <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>`;
- },
- score(s) {
- return `<div class="r-tbl-wrap"><table class="r-tbl r-score"><thead><tr>
- <th>机会</th><th class="r">需求强度</th><th class="r">竞争烈度</th><th class="r">我方能力</th><th>总分</th><th>结论</th></tr></thead><tbody>${
- (s.data || []).map(([name, need, comp, cap, total, verdict]) => `<tr>
- <td class="r-td-k">${esc(name)}</td>
- <td class="r num">${fmt(need)}</td><td class="r num">${fmt(comp)}</td><td class="r num">${fmt(cap)}</td>
- <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>
- <td>${esc(verdict)}</td></tr>`).join("")}</tbody></table></div>`;
- },
- /* 心智阶梯:从高到低的决策序列,最后一格是空位 */
- ladder(s) {
- const d = s.data || {};
- const alt = d.alt || [];
- const st = d.statement || "";
- return `<ol class="r-ladder">${(d.ladder || []).map(([no, name, note]) => `
- <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>
- ${st ? `<div class="r-stmt">
- <div class="r-stmt-k">一句话定位</div>
- <p class="r-stmt-v">「${esc(st)}」</p>
- ${d.for_who ? `<div class="r-stmt-k" style="margin-top:10px">写给谁:${esc(d.for_who)}</div>` : ""}
- </div>` : ""}`;
- },
- statement(s, ctx, sec) {
- /* 5.3 只渲染替代方案与自检;一句话定位已在 ladder 里给大字号 */
- const d = s.data || {};
- 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>` : ""}`;
- },
- milestones(s) {
- return `<div class="r-miles">${(s.data || []).map(([m, name, checks, rule]) => `
- <div class="r-mile"><div class="r-mile-m">${esc(m)}</div><h5>${esc(name)}</h5>
- <ul class="r-mile-checks">${(checks || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>
- <div class="r-mile-rule"><span>决策规则</span>${esc(rule)}</div></div>`).join("")}</div>`;
- },
- /* 未知结构不丢:原样列出键值,提醒作者补渲染器 */
- unknown(s) {
- const flat = (v) => (v && typeof v === "object" ? JSON.stringify(v) : String(v));
- return `<div class="r-unknown"><div class="r-muted tiny">未知的结构类型「${esc(s.kind)}」,以下为原始数据:</div>
- <dl>${Object.entries(s.data || s).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(flat(v))}</dd></div>`).join("")}</dl></div>`;
- }
- };
- function renderSection(s, ctx, sec) {
- const fn = sections[s.kind] || sections.unknown;
- return `<div class="r-sec" id="${esc(s.id)}" data-anchor="${esc(s.id)}">
- <h3 class="r-sec-h"><span class="num">${esc(s.no || "")}</span>${esc(s.title || "")}</h3>
- ${fn(s, ctx, sec)}
- ${s.note ? `<p class="r-sec-note">${esc(s.note)}</p>` : ""}
- </div>`;
- }
- function chapter(ch, ctx) {
- return `<section class="r-chapter" id="${esc(ch.id)}" data-chapter-id="${esc(ch.id)}" aria-labelledby="h-${esc(ch.id)}">
- <div class="r-ch-head">
- <div class="r-ch-kicker"><span>${esc(ch.stage || "")} · CHAPTER ${esc(ch.no)}</span><span class="r-ch-en">${esc(ch.en || "")}</span></div>
- <h2 id="h-${esc(ch.id)}">${esc(ch.title)}</h2>
- ${ch.subtitle ? `<p class="r-ch-sub">${esc(ch.subtitle)}</p>` : ""}
- </div>
- <p class="r-ch-lead">${esc(ch.lead || "")}</p>
- ${(ch.sections || []).map((s) => renderSection(s, ctx, ch)).join("")}
- </section>`;
- }
- /* 附录:本报告所有结论 → 支撑成果 → 原文,一张总表,方便一次性核对证据 */
- function appendix(db, a, report) {
- const ev = report.evidence || {};
- const ids = Object.keys(ev);
- if (!ids.length) return "";
- return `<section class="r-block" aria-label="证据附录">
- <h2>附录 · 结论与证据对照</h2>
- <p class="r-muted r-para">每条结论 → 由谁提到 → 支撑它的成果在库里是否可追 → 带几条原文。点结论可打开证据链,点成果回到台账。</p>
- <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>${
- ids.map((id) => {
- const e = ev[id];
- const quotes = Array.isArray(e.quotes) ? e.quotes.length : 0;
- const derived = (e.derivedFrom || []).filter(Boolean);
- const inLib = derived.filter((x) => db.artifactById[x]).length;
- return `<tr>
- <td class="r-td-k"><button class="r-linkbtn" data-act="open-evidence" data-id="${esc(id)}">${esc(e.claim || id)}</button></td>
- <td class="r num">${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</td>
- <td class="r num ${quotes ? "" : "warn"}">${quotes}</td>
- <td>${inLib} / ${derived.length}${inLib < derived.length ? `<span class="r-cite-missing">(${derived.length - inLib} 条未接入)</span>` : ""}</td>
- <td class="r">${anchor(id, "证据链")}</td></tr>`;
- }).join("")}</tbody></table></div>
- </section>`;
- }
- function toc(report) {
- const chs = report.chapters || [];
- return `<nav class="r-toc" aria-label="章节目录">
- <a href="#" data-scroll-to="cover" class="r-toc-cover">封面与摘要</a>
- <ol>${chs.map((ch) => `
- <li><a href="#" data-scroll-to="${esc(ch.id)}" data-chapter="${esc(ch.id)}">
- <span class="r-toc-no num">${esc(ch.no)}</span><span class="r-toc-t">${esc(ch.title)}</span>
- ${ch.stage ? `<span class="r-toc-s">${esc(ch.stage)}</span>` : ""}
- </a></li>`).join("")}</ol>
- </nav>`;
- }
- function view(db, id, params) {
- const a = db.artifactById[id];
- if (!a || !a.report) return null;
- const report = a.report;
- const ctx = { db, evidence: this_evidence(report) };
- const p = db.projectById[a.projectId];
- return `<div class="report" data-report="${esc(a.id)}">
- <div class="r-topbar">
- <a class="btn btn-ghost" href="#/artifacts">${UI.icons.back} 成果台账</a>
- ${p ? `<a class="btn btn-ghost" href="#/project/${encodeURIComponent(p.id)}">项目:${esc(p.title)}</a>` : ""}
- <span class="r-topbar-note">${esc(a.ownerName || "")} · ${esc(a.stage)}</span>
- </div>
- ${toc(report)}
- <article class="r-paper">
- ${cover(db, a)}
- ${structure(report)}
- ${executive(report)}
- ${kpis(report)}
- ${(report.chapters || []).map((ch) => chapter(ch, ctx)).join("")}
- ${appendix(db, a, report)}
- <footer class="r-foot">
- <span>报告由 ${esc(a.ownerName || "学员")} 在 ${esc(a.stage)} 产出 · 数据管线 ${esc((report.pipeline || []).map((x) => fmt(x.value)).join(" → ") || "—")}</span>
- <a href="#/artifacts?focus=${encodeURIComponent(a.id)}">回到台账查看这条成果与它的出身</a>
- </footer>
- </article>
- </div>`;
- }
- /* ---------------- 报告页的滚动高亮 ----------------
- * 不依赖 IntersectionObserver:在 scroll(rAF 节流)里实时算"当前章"——
- * 规则:章节顶部越过顶部偏下的一条基准线(视口 30% 处)的最后一个,就是当前章;
- * 章节比整屏还高时,只要它占住这条基准线就算当前章。
- * 点击目录后 650ms 内锁住高亮,让平滑滚动途中不来回跳。 */
- let segmentLockUntil = 0;
- function currentChapter(root, ids) {
- let cur = ids[0];
- const line = Math.max(120, window.innerHeight * 0.3);
- for (const id of ids) {
- const el = root.querySelector(`[data-chapter-id="${id}"]`);
- if (!el) continue;
- const r = el.getBoundingClientRect();
- if (r.top - line <= 0.5) cur = id;
- if (r.top <= line && r.bottom > line) return id;
- }
- return cur;
- }
- function paintToc(root, id) {
- const links = root.querySelectorAll(".r-toc a[data-chapter]");
- links.forEach((a) => {
- const on = a.dataset.chapter === id;
- a.classList.toggle("on", on);
- if (on) a.setAttribute("aria-current", "true"); else a.removeAttribute("aria-current");
- });
- }
- function bind(root, reportId) {
- const ids = [...root.querySelectorAll("[data-chapter-id]")].map((n) => n.dataset.chapterId);
- if (!ids.length) return;
- let lastPaint = "";
- const onScroll = () => {
- if (Date.now() < segmentLockUntil) return;
- const id = currentChapter(root, ids);
- if (id === lastPaint) return;
- lastPaint = id;
- paintToc(root, id);
- };
- window.addEventListener("scroll", onScroll, { passive: true });
- window.addEventListener("resize", onScroll, { passive: true });
- const jump = (id) => {
- const target = root.querySelector(`[data-chapter-id="${id}"]`) || root.querySelector("#cover");
- if (!target) return;
- segmentLockUntil = Date.now() + 700;
- paintToc(root, id);
- const top = target.getBoundingClientRect().top + window.scrollY - 96;
- window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
- window.__KF_ROUTER && window.KF_ROUTER.write({ ...window.KF_ROUTER.parseHash().params, ch: id }, { silent: true });
- };
- root.querySelectorAll("[data-scroll-to]").forEach((a) => {
- a.addEventListener("click", (ev) => {
- ev.preventDefault();
- const id = a.dataset.scrollTo;
- if (id === "cover") { segmentLockUntil = Date.now() + 700; window.scrollTo({ top: 0, behavior: "smooth" }); return; }
- jump(id);
- });
- });
- /* 直接带 ?ch= 打开:还原到那一章,并先画出高亮 */
- const want = (window.KF_ROUTER.parseHash().params || {}).ch;
- if (want && ids.includes(want)) {
- const t = root.querySelector(`[data-chapter-id="${want}"]`);
- segmentLockUntil = Date.now() + 250;
- paintToc(root, want);
- requestAnimationFrame(() => window.scrollTo(0, t.getBoundingClientRect().top + window.scrollY - 96));
- } else {
- paintToc(root, currentChapter(root, ids));
- }
- if (history.scrollRestoration) history.scrollRestoration = "manual";
- }
- function this_evidence(report) {
- const ev = report && report.evidence ? report.evidence : {};
- const out = {};
- Object.keys(ev).forEach((k) => { out[k] = ev[k]; });
- return out;
- }
- /* ---------------- 证据链抽屉 ---------------- */
- /* 找到所有"在报告里引用这条成果"的结论(跨库内全部报告) */
- function conclusionsCiting(db, artId) {
- const out = [];
- db.artifacts.forEach((a) => {
- const ev = a.report && a.report.evidence;
- if (!ev) return;
- Object.keys(ev).forEach((k) => {
- if ((ev[k].derivedFrom || []).includes(artId)) out.push({ report: a, evId: k, claim: ev[k].claim });
- });
- });
- return out;
- }
- function evidenceDrawer(db, evId, back) {
- /* 一份报告里的结论 ID 可能撞车(不同报告都有自己的 c-xxx),所以先把所有带该结论的报告找出来 */
- const owners = db.artifacts.filter((a) => a.report && a.report.evidence && a.report.evidence[evId]);
- if (!owners.length) {
- return { title: "证据链", html: UI.empty({ sm: true, title: "找不到这条结论", body: "链接可能来自旧版本报告,结论已经更新。" }) };
- }
- const report = owners[0];
- const e = report.report.evidence[evId];
- const quotes = Array.isArray(e.quotes) ? e.quotes : [];
- const derived = (e.derivedFrom || []).filter(Boolean);
- const inLib = derived.filter((x) => db.artifactById[x]);
- const rows = derived.map((artId, i) => {
- const a = db.artifactById[artId];
- const qs = quotes.filter((qq) => qq.artId === artId);
- if (!a) {
- return `<div class="trace-item"><span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
- <div><div class="t">${esc(artId)} <span class="r-cite-missing">库里已不存在</span></div>
- <div class="m">这条结论引用的成果在库中找不到,证据链在此断掉。</div></div><span></span></div>`;
- }
- const cs = conclusionsCiting(db, artId);
- return `<div class="trace-item">
- <span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
- <div>
- <a class="t" href="#/artifacts?focus=${encodeURIComponent(a.id)}" data-act="nav" data-href="#/artifacts?focus=${encodeURIComponent(a.id)}">${esc(a.shortTitle)}</a>
- <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>
- <div class="m">被 ${cs.length} 条结论引用${cs.length > 1 ? `(含本链:${esc(cs.map((x) => x.report.shortTitle).slice(0, 2).join("、"))})` : ""}</div>
- ${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("")
- : `<div class="evi r-muted">这条成果本身没有直接原文;结论里的原文来自其他成果。</div>`}
- </div>
- <a class="trace-btn" href="#/report/${encodeURIComponent(report.id)}" data-act="nav" data-href="#/report/${encodeURIComponent(report.id)}" title="打开所在报告">所在报告</a>
- </div>`;
- }).join("");
- const orphanQuotes = quotes.filter((qq) => !qq.artId || !derived.includes(qq.artId));
- const body = `
- <div class="block"><div class="bl">这条结论的判断依据</div>
- <div class="kpi-inline" style="margin-top:2px;justify-content:flex-start">
- <span><b>${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</b>人独立提到</span>
- <span class="${quotes.length ? "" : "warn"}"><b>${quotes.length}</b>条原文</span>
- <span><b>${derived.length}</b>条支撑成果</span>
- </div>
- <p class="tiny muted" style="margin-top:8px">${inLib.length < derived.length
- ? `<b class="warn-txt">${derived.length - inLib.length} 条引用的成果在库里已不存在</b>,证据链不完整——这本身就是一个需要补数据的信号。`
- : "证据链完整:每条引用的成果都能在台账里找到。"}</p>
- </div>
- <div class="block"><div class="bl">支撑成果 <span class="n">${derived.length}</span></div>
- ${derived.length ? rows : UI.empty({ sm: true, title: "没有支撑成果", body: "这条结论还没有指向任何过程层成果——按规则不能回灌。" })}
- </div>
- ${orphanQuotes.length ? `<div class="block"><div class="bl">未挂到成果上的原文 <span class="n">${orphanQuotes.length}</span></div>
- ${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("")}
- <p class="tiny muted">这些原文来自外部采集,没有对应的学员成果记录。</p></div>` : ""}
- <div class="block"><div class="bl">所属报告</div>
- <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>
- </div>`;
- return {
- title: esc(e.claim || "证据链"),
- kicker: "",
- 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>
- <button class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button></div>
- <div class="drawer-b">${body}</div>
- <div class="drawer-f"><button class="btn btn-ghost" data-act="close-drawer">关闭</button>
- <a class="btn" href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}" data-act="nav" data-href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}">看提交者全部成果</a></div>`
- };
- }
- window.KF_REPORT = { index, view, evidenceDrawer, conclusionsCiting, reportStats, bind };
- })();
|