9-view-report.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. /**
  2. * pages/view-report.js —— <view-report> 报告阅读页(保留 v3 能力)。
  3. *
  4. * - 吸顶章节导航 + 滚动高亮(IntersectionObserver)+ 平滑跳转,当前章写回 ?ch=,刷新还原
  5. * - 每章开头一句「这章在回答什么问题」
  6. * - 数据管线可见(2,727 采集 → 1,190 去噪 → 730 精准,过滤率 73%)
  7. * - KPI、交叉分析表、敢下判断的结论
  8. * - 结论旁的证据锚点 → 证据抽屉(N 人提到 / N 条原文 / N 条支撑成果,点名到人)
  9. * - 附录:所有结论 × 证据对照总表;「关联成果」区显示引用了本报告的归纳(反向)
  10. *
  11. * 所有 section.kind 都有渲染器;未知 kind 原样列出键值,不静默吞。
  12. */
  13. import { LitElement, css, html } from 'lit';
  14. import { useTokens } from '../components/base.js';
  15. import { reportOverview, findingsOf, fmtDate, chapterDigests, reportTypeOf } from '../core/index.js';
  16. import { reportCategoryOf } from '../core/report-delivery.js';
  17. import { reportAttachments } from '../core/report-attachments.js';
  18. import { navigate, href, safeReturnTo } from '../utils/router.js';
  19. import { t, VIEW_CSS } from './shared.js';
  20. import { icon } from '../components/icons.js';
  21. export class ViewReport extends LitElement {
  22. static properties = {
  23. db: { type: Object },
  24. artifactId: { type: String },
  25. routeQuery: { type: Object },
  26. _active: { state: true },
  27. };
  28. #r = null;
  29. #type = null;
  30. #resize = null;
  31. #chapterFrame = null;
  32. #smoothJump = false;
  33. #lastChapterTarget = '';
  34. #chapterVersion = 0;
  35. constructor() {
  36. super();
  37. this.db = null;
  38. this.artifactId = '';
  39. this.routeQuery = {};
  40. this._active = '';
  41. }
  42. connectedCallback() {
  43. super.connectedCallback();
  44. useTokens(this);
  45. }
  46. disconnectedCallback() {
  47. super.disconnectedCallback();
  48. this.#io?.disconnect();
  49. this.#resize?.disconnect();
  50. cancelAnimationFrame(this.#chapterFrame);
  51. this.#chapterVersion++;
  52. }
  53. #io = null;
  54. willUpdate(changed) {
  55. if (changed.has('artifactId')) this._active = '';
  56. }
  57. updated(changed) {
  58. if (!this.db) return;
  59. if (!this.#resize && typeof ResizeObserver !== 'undefined') {
  60. this.#resize = new ResizeObserver(() => this.#measureOffsets());
  61. const toc = this.renderRoot.querySelector('[data-toc]');
  62. if (toc) this.#resize.observe(toc);
  63. }
  64. if (changed.has('artifactId') || changed.has('db')) this.#measureOffsets();
  65. const chapter = String(this.routeQuery?.ch || '').replace(/^ch-/, '');
  66. const target = chapter ? `${this.artifactId}:${chapter}` : '';
  67. if (chapter && target !== this.#lastChapterTarget) {
  68. const smooth = this.#smoothJump;
  69. this.#smoothJump = false;
  70. this.#restoreChapter(`ch-${chapter}`, smooth ? 'smooth' : 'instant');
  71. } else if (target !== this.#lastChapterTarget) {
  72. this.#chapterVersion++;
  73. cancelAnimationFrame(this.#chapterFrame);
  74. }
  75. this.#lastChapterTarget = target;
  76. }
  77. #measureOffsets() {
  78. const top = window.matchMedia('(max-width: 767px)').matches ? 0 : 12;
  79. const tocHeight = Math.ceil(this.renderRoot.querySelector('[data-toc]')?.getBoundingClientRect().height || 44);
  80. this.style.setProperty('--report-topbar', `${top}px`);
  81. this.style.setProperty('--report-anchor', `${top + tocHeight + 16}px`);
  82. this.#observeChapters(top + tocHeight + 8);
  83. }
  84. async #restoreChapter(id, behavior) {
  85. const version = ++this.#chapterVersion;
  86. // 卡片的 Shadow DOM 和字体都会改变章节位置,等待实际布局稳定后再恢复锚点。
  87. await Promise.all([...this.renderRoot.querySelectorAll('*')].map(el => el.updateComplete));
  88. if (document.fonts) await document.fonts.ready;
  89. if (!this.isConnected || version !== this.#chapterVersion) return;
  90. cancelAnimationFrame(this.#chapterFrame);
  91. this.#chapterFrame = requestAnimationFrame(() => {
  92. this.#measureOffsets();
  93. this.#chapterFrame = requestAnimationFrame(() => {
  94. if (version === this.#chapterVersion) this.#scrollChapter(id, behavior);
  95. });
  96. });
  97. }
  98. #observeChapters(offset) {
  99. this.#io?.disconnect();
  100. // 滚动高亮是增强能力:环境不支持 IntersectionObserver 时整页照常可读,只是不自动高亮
  101. if (typeof IntersectionObserver === 'undefined') return;
  102. const root = this.renderRoot;
  103. const chapters = [...root.querySelectorAll('article.ch')];
  104. this.#io = new IntersectionObserver((entries) => {
  105. const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
  106. if (visible[0]) this._active = visible[0].target.id;
  107. }, { rootMargin: `-${offset}px 0px -45% 0px`, threshold: 0 });
  108. chapters.forEach((c) => this.#io.observe(c));
  109. }
  110. static styles = [VIEW_CSS, css`
  111. .report-image{display:block;max-width:100%;height:auto;border:1px solid var(--kf-line);border-radius:6px}.file-preview{display:block;width:100%;height:600px;max-height:75vh;border:1px solid var(--kf-line);margin-top:12px;background:white}.file-entry{padding:12px 0;border-bottom:1px solid var(--kf-line);overflow-wrap:anywhere}.file-entry details summary{cursor:pointer}
  112. /* kf-glass-card 的插槽内容必须包一层块容器:自定义元素宿主是 display:block,直接排多个块级子元素会和卡片的行盒互相叠 */
  113. .cover { --kf-card-pad: 24px; height: auto; margin-bottom: 20px; }
  114. .cover-in { display: grid; gap: 16px; min-width: 0; }
  115. .cover:has(.dgrid) .cover-in { gap: 12px; }
  116. .rail .r-in { display: grid; gap: 8px; min-width: 0; }
  117. .cover .top { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
  118. .cover h1 { margin: 4px 0 0; font-size: 26px; line-height: 1.45; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; overflow-wrap: anywhere; }
  119. .cover .sub { font-size: 14px; line-height: 1.6; color: var(--kf-ink-2); }
  120. .cover .meta { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); display: flex; gap: 16px; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
  121. .cover .pipe { padding: 16px 0 0; border-top: 1px solid var(--kf-line); }
  122. .exec { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; counter-reset: e; }
  123. .exec li { display: grid; grid-template-columns: 34px 1fr; gap: 10px; font-size: 14px; color: var(--kf-ink); line-height: 1.6; }
  124. .exec li::before { counter-increment: e; content: counter(e, decimal-leading-zero); font-family: var(--kf-font-num); font-size: 12px; color: var(--kf-accent-ink); padding-top: 4px; }
  125. /* 章节目录 = 上层玻璃(.72 + blur 32),和正文卡片拉开层次 */
  126. .toc[data-toc] { position: sticky; top: var(--report-topbar, 12px); z-index: 3; display: flex; gap: 4px; overflow-x: auto; padding: 6px; margin: 0 0 20px;
  127. border-radius: var(--kf-radius); background: var(--kf-surface); border: 1px solid var(--kf-line); box-shadow: var(--elev-2); scrollbar-width: thin; scrollbar-color: var(--kf-line-strong) transparent; }
  128. .dgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); gap: 10px; }
  129. a.d { display: grid; grid-template-columns: 32px minmax(0,1fr); gap: 2px 8px; padding: 12px; border-radius: var(--r-sm); text-decoration: none;
  130. background: rgba(255,255,255,0.4); border: 1px solid var(--kf-line); color: inherit; transition: var(--kf-t); }
  131. a.d:hover { background: rgba(255,255,255,0.7); box-shadow: var(--kf-shadow-chip); }
  132. a.d .dn { font: 600 12px/1.4 var(--kf-font-num); color: var(--kf-accent-ink); grid-row: span 2; padding-top: 2px; font-variant-numeric: tabular-nums; }
  133. a.d .dt { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
  134. a.d .ds { font-size: 12px; color: var(--kf-ink-2); grid-column: 2; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
  135. .toc a.jump, aside.rail a.jump { flex: none; font-size: 14px; color: var(--kf-ink-2); text-decoration: none;
  136. padding: 10px 12px; border-radius: var(--r-sm); display: inline-block; line-height: 1.4; transition: background var(--kf-dur) var(--kf-ease); }
  137. .toc a.jump { white-space: nowrap; font-weight: 500; }
  138. .toc a.jump:hover, aside.rail a.jump:hover { background: var(--kf-surface-btn); color: var(--kf-accent-ink); }
  139. .toc a.jump[aria-current="true"] { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
  140. aside.rail ol li + li { margin-top: 2px; }
  141. aside.rail ol { list-style: none; padding-left: 0; margin: 6px 0 0; }
  142. .layout { display: grid; grid-template-columns: minmax(0, 1fr) 264px; gap: 16px; align-items: start; }
  143. /* 列容器用块布局:kf-glass-card 是自定义元素,在 grid/flex 里会被当成 flex item 参与拉伸 */
  144. /* 视图宿主与列容器显式 block:否则自定义元素被排成行盒,块级子元素互相叠、互相撑高 */
  145. :host { display: block; }
  146. @media (prefers-reduced-motion: no-preference) {
  147. :host { animation: report-appear 160ms var(--kf-ease-out); }
  148. @keyframes report-appear { from { opacity: .65; } to { opacity: 1; } }
  149. }
  150. .col, .main-col { display: block; min-width: 0; }
  151. article.ch { padding: 24px; margin-bottom: 20px; scroll-margin-top: var(--report-anchor, 84px); min-width: 0; box-sizing: border-box; overflow-wrap: anywhere; }
  152. .ch-h { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; align-items: baseline; padding-bottom: 14px; margin-bottom: 4px; border-bottom: 1px solid var(--kf-line); }
  153. .ch-h .en { grid-column: 1 / -1; }
  154. .ch-h .en:empty { display: none; }
  155. .ch-no { font-family: var(--kf-font-num); font-size: 12px; line-height: 20px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: var(--r-sm); padding: 0 6px; font-variant-numeric: tabular-nums; }
  156. .ch-h h2 { margin: 0; font-size: 20px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
  157. .en { font-size: 12px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: 600; text-transform: uppercase; }
  158. /* 摘要块:6% 染色底 + 1px 同色半透明描边(v11 反例 #2:不用 3px 彩色左边栏) */
  159. .lead { margin: 12px 0 16px; padding: 12px 16px; background: color-mix(in srgb, var(--kf-accent) 6%, transparent); font-size: 14px; color: var(--kf-ink-2);
  160. line-height: var(--lh-body); border-radius: var(--r-md); box-shadow: 0 0 0 1px color-mix(in srgb, var(--kf-accent) 16%, transparent); }
  161. .lead b { color: var(--kf-accent-ink); font-weight: 600; }
  162. .sec { padding: 12px 0; border-top: 1px solid var(--kf-line); }
  163. .sec:first-of-type { border-top: 0; }
  164. .sec h4 { margin: 0 0 8px; font-size: 14px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); display: flex; gap: 8px; align-items: baseline; }
  165. .sec h4 .no { font-family: var(--kf-font-num); font-size: 12px; color: var(--kf-ink-3); }
  166. p.para { margin: 0 0 12px; font-size: 14px; line-height: 1.85; color: var(--kf-ink-2); max-width: 72ch; }
  167. .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr)); gap: 12px; }
  168. .tbl-wrap { overflow-x: auto; border: 1px solid var(--kf-line); border-radius: var(--r-md); }
  169. table.rt { width: 100%; border-collapse: collapse; font-size: 14px; }
  170. .rt th { background: var(--kf-surface-btn); text-align: left; padding: 10px 12px; color: var(--kf-ink-3); font-weight: 600; font-size: 12px; letter-spacing: var(--ls-cn-small); border-bottom: 1px solid var(--kf-line); white-space: nowrap; }
  171. .rt td { padding: 8px 12px; border-bottom: 1px solid var(--kf-line); vertical-align: middle; color: var(--kf-ink-2); font-variant-numeric: tabular-nums; }
  172. .rt tr:last-child td { border-bottom: 0; }
  173. .rt .k { color: var(--kf-ink); font-weight: 600; }
  174. .funnel { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
  175. .funnel li { display: grid; grid-template-columns: 44px minmax(0, 1fr) 120px; gap: 10px; align-items: center; font-size: 14px; }
  176. .bar { height: 8px; border-radius: var(--r-sm); background: rgba(20,36,70,0.07); overflow: hidden; }
  177. .bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--kf-s1), var(--kf-s2)); }
  178. .finding { padding: 12px 16px; border-radius: var(--r-sm); border: 1px solid var(--kf-line); background: rgba(255,255,255,0.45); margin-bottom: 12px; }
  179. .finding h5 { margin: 0; font-size: 14px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); }
  180. .finding p { margin: 4px 0 8px; font-size: 14px; line-height: 1.6; color: var(--kf-ink-2); }
  181. .ev { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 12px; color: var(--kf-ink-3); }
  182. .ev b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
  183. .anchor { display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid rgba(91, 91, 214, 0.22);
  184. background: rgba(91, 91, 214, 0.10); color: var(--kf-accent-ink); font: 600 12px/1 var(--kf-font); cursor: pointer; transition: var(--kf-t); }
  185. .anchor:hover { background: rgba(91, 91, 214, 0.10); border-color: rgba(91, 91, 214, 0.45); }
  186. .two { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 12px; }
  187. .two > div { padding: 12px; border-radius: var(--r-sm); border: 1px solid var(--kf-line); font-size: 14px; line-height: 1.6; }
  188. .two .yes { background: rgba(31,107,69,0.06); border-color: rgba(31,107,69,0.25); }
  189. .two .no { background: rgba(164,36,28,0.05); border-color: rgba(164,36,28,0.22); }
  190. .two ul { margin: 6px 0 0; padding-left: 18px; }
  191. aside.rail { position: sticky; top: var(--report-anchor, 84px); display: grid; gap: 12px; min-width: 0; }
  192. .rail .r { --kf-card-pad: 16px; height: auto; }
  193. .reading-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 20px; margin: 0 0 16px; font-size: 13px; }
  194. .reading-nav .report-back { margin-right: auto; font-weight: 600; }
  195. .reading-nav a { text-decoration: none; padding: 7px 10px; border-radius: var(--r-sm); color: var(--kf-ink-2); transition: var(--kf-t-colors); }
  196. .reading-nav .report-back { background: var(--kf-surface); border: 1px solid var(--kf-line); }
  197. .reading-nav a:hover { color: var(--kf-accent-ink); background: var(--kf-accent-soft); }
  198. .section-label { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
  199. .cover.digest .kf-kicker { color: var(--kf-ink-3); }
  200. .rail .r .k { font-size: 12px; color: var(--kf-ink-3); letter-spacing: var(--ls-cn-small); font-weight: 600; }
  201. .rail ol { margin: 6px 0 0; padding-left: 18px; font-size: 14px; color: var(--kf-ink-2); display: grid; gap: 4px; }
  202. .rail ol a { color: var(--kf-ink); text-decoration: none; border-bottom: 1px dashed var(--kf-line-strong); }
  203. .rail .stat { display: flex; justify-content: space-between; font-size: 14px; padding: 3px 0; color: var(--kf-ink-2); }
  204. .chain { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
  205. .chain div { display: grid; justify-items: center; gap: 4px; padding: 8px 4px; border-radius: var(--r-sm); background: rgba(255,255,255,0.45); border: 1px solid var(--kf-line); }
  206. .chain b { font: 600 14px/1.1 var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
  207. .chain i { font-style: normal; font-size: 12px; line-height: 1.3; color: var(--kf-accent-ink); text-align: center; }
  208. .rail .stat b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
  209. .muted2 { font-size: 12px; color: var(--kf-ink-3); }
  210. @media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } aside.rail { position: static; } }
  211. /* 手机(任务书 1.3 / 2.4):阅读稿单栏;章节导航吸在视口上方、左右通到屏幕边;封面标题 26→20;来源收成两行 */
  212. @media (max-width: 767px) {
  213. .toc[data-toc] { margin: 0 0 16px; padding: 4px; scrollbar-width: none; }
  214. .toc::-webkit-scrollbar { display: none; }
  215. .toc a.jump { font-size: 14px; padding: 10px 12px; }
  216. .cover { --kf-card-pad: 16px; margin-bottom: 16px; }
  217. article.ch { padding: 18px 16px; margin-bottom: 16px; }
  218. .ch-h h2 { font-size: 18px; }
  219. .reading-nav { gap: 4px 16px; }
  220. .reading-nav .report-back { flex: 1 1 100%; }
  221. .funnel li { grid-template-columns: 36px minmax(0, 1fr); }
  222. .funnel li > :last-child { grid-column: 2; }
  223. .cover .top { row-gap: 6px; }
  224. .cover .top > span:last-child { margin-left: 0 !important; width: 100%; justify-content: flex-start; }
  225. /* 类型名已在顶栏下方标题区,封面不再重复(任务书 0.5:同一信息在 tab / 标题 / 数据卡各出现一次) */
  226. .cover .typeb { display: none; }
  227. .cover h1 { font-size: 20px; line-height: 1.35; font-weight: 600; }
  228. .cover .sub { font-size: 14px; line-height: 1.55; }
  229. .cover .meta { font-size: 14px; gap: 12px; }
  230. .ch-no, .ch-h h2 { line-height: 1.4; }
  231. .art p, .ch-body p, .toc ~ * p { font-size: 14px; line-height: 1.7; }
  232. }
  233. `];
  234. #jump(id) {
  235. // 应用内路径 + 相对章节 id(ch3 而不是 ch-ch3)
  236. const key = id.replace(/^ch-/, '');
  237. if (String(this.routeQuery?.ch || '').replace(/^ch-/, '') === key) return this.#scrollChapter(id, 'smooth');
  238. this.#smoothJump = true;
  239. // 当前章写回 URL(?ch=3),刷新时还原到同一章;只改查询串,不新增历史
  240. const q = new URLSearchParams(this.routeQuery || {});
  241. q.set('ch', key);
  242. navigate(`/report/${encodeURIComponent(this.artifactId)}?${q.toString()}`, { replace: true });
  243. }
  244. #scrollChapter(id, behavior) {
  245. const el = this.renderRoot.querySelector(`#${CSS.escape(id)}`);
  246. if (!el) return;
  247. this._active = id;
  248. el.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : behavior, block: 'start' });
  249. }
  250. get typeInfo() { return this.#type; }
  251. #digest(d) {
  252. return html`<a class="d" href=${`#ch-${d.id}`} data-digest-ch=${d.id} @click=${(e) => { e.preventDefault(); this.#jump(`ch-${d.id}`); }}>
  253. <span class="dn">${String(d.no).padStart(2, '0')}</span>
  254. <span class="dt">${d.title}</span>
  255. <span class="ds">${d.summary || '这一章没有可抽取的句子,点到章内看原文。'}</span>
  256. </a>`;
  257. }
  258. #openEv(evId) {
  259. this.dispatchEvent(new CustomEvent('evidence-open', { detail: { reportId: this.artifactId, evId }, bubbles: true, composed: true }));
  260. }
  261. #artifact(id) {
  262. this.dispatchEvent(new CustomEvent('artifact-open', { detail: { id }, bubbles: true, composed: true }));
  263. }
  264. #anchor(ev, label) {
  265. if (!ev) return '';
  266. const e = this.#r?.report.evidence?.[ev];
  267. if (!e) return '';
  268. const n = (e.quotes || []).length;
  269. return html`<button class="anchor" data-anchor=${ev} @click=${() => this.#openEv(ev)}>${icon('link', 12)} ${label || '证据链'}${n ? html` · ${n} 条原文` : ''}</button>`;
  270. }
  271. render() {
  272. const db = this.db;
  273. if (!db) return html`<kf-empty kind="loading" ></kf-empty>`;
  274. const r = reportOverview(db, this.artifactId);
  275. this.#r = r;
  276. if (!r) {
  277. return html`<kf-empty kind="error" title="这份报告不存在或没有正文" hint=${`成果 ${this.artifactId} 暂无可读取的报告正文或原文;不会用摘要补造报告。`}
  278. action-label="返回" @empty-action=${() => navigate(safeReturnTo(this.routeQuery?.returnTo, '/reports'))}></kf-empty>`;
  279. }
  280. const findings = findingsOf(db, this.artifactId);
  281. const report = r.report;
  282. const attachments = reportAttachments(r.artifact, ['https://kangshifu.fmode.cn', location.origin]);
  283. const digests = chapterDigests(db, this.artifactId);
  284. this.#type = reportTypeOf(reportCategoryOf(r.artifact));
  285. if (!r.executive?.length) {
  286. // 执行摘要缺失时:用章节摘要替身,但明确标注来源,不冒充
  287. r.executive = digests.slice(0, 3).map((d) => `〔${d.title}〕${d.summary || ''}`);
  288. }
  289. const active = this._active || (this.routeQuery?.ch ? `ch-${String(this.routeQuery.ch).replace(/^ch-/, '')}` : '');
  290. const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
  291. return html`
  292. <nav class="reading-nav" aria-label="报告导航">
  293. <a class="report-back" href=${href(back)} data-link data-back>${back.startsWith('/pool/') ? '← 返回机会卡' : '← 返回列表'}</a>
  294. ${this.typeInfo ? html`<a href=${href('/reports/' + this.typeInfo.key)} data-link>${this.typeInfo.name}</a>` : ''}
  295. <a href=${href('/reports')} data-link>报告中心</a>
  296. </nav>
  297. <kf-glass-card surface="2" class="cover"><div class="cover-in">
  298. <div class="top">
  299. <kf-badge kind="stage" .value=${r.stages[0] || '—'} label=${r.stages.join(' · ') || '跨阶段'}></kf-badge>
  300. <kf-badge kind="plain" .value=${`${r.artifact.ownerName || '无出身'} · ${r.artifact.upload.methodLabel || '方式未记录'}`}></kf-badge>
  301. ${r.artifact.upload.agentName ? html`<kf-badge kind="method" .value=${r.artifact.upload.agentName}></kf-badge>` : ''}
  302. ${this.typeInfo ? html`<kf-badge class="typeb" kind="method" .value=${this.typeInfo.name} label=${'类型:' + this.typeInfo.name}></kf-badge>` : ''}
  303. </div>
  304. <div>
  305. <h1>${r.title}</h1>
  306. <div class="sub" style="margin-top:6px">${r.subtitle}</div>
  307. </div>
  308. <div class="meta">
  309. <span>${fmtDate(r.generatedAt, true)}</span><span>${t(`${r.quoteCount} 原文`)}</span><span>${t(`${r.refsInLib}/${r.refCount} 支撑`)}</span>
  310. </div>
  311. ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
  312. </div></kf-glass-card>
  313. <div class="toc kf-glass is-top is-overlay" aria-label="章节导航" data-toc>
  314. ${r.chapters.map((c) => html`<a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
  315. @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.no}. ${c.title}</a>`)}
  316. </div>
  317. <div class="main-col">
  318. <div class="layout">
  319. <div class="col">
  320. ${r.executive.length ? html`<kf-glass-card surface="2" class="cover exec-card" style="margin-bottom:16px" data-exec><div class="cover-in">
  321. <div class="kf-kicker">${report.executive?.length ? '报告摘要' : '章节摘要'}</div>
  322. <ol class="exec" data-exec-list>${r.executive.map((x, i) => html`<li data-exec-item=${String(i + 1)}>${x}</li>`)}</ol>
  323. </div></kf-glass-card>` : ''}
  324. <kf-glass-card surface="1" class="cover digest" style="margin-bottom:16px" data-digest><div class="cover-in">
  325. <div class="section-label">章节导读</div><div class="dgrid">${digests.map((d) => this.#digest(d))}</div>
  326. </div></kf-glass-card>
  327. ${r.chapters.map((c) => html`<article class="ch kf-card" id="ch-${c.id}" data-chapter=${c.id}>
  328. <div class="ch-h"><span class="en">${c.en}</span><span class="ch-no">${c.no}</span><h2>${c.title}</h2></div>
  329. ${c.subtitle ? html`<div class="muted2">${c.subtitle}</div>` : ''}
  330. ${c.lead ? html`<div class="lead">${c.lead}</div>` : ''}
  331. ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">${t(`对应阶段 ${c.stage.code} ${c.stage.name}`)}</div>` : ''}
  332. ${c.sections.map((s) => html`<section class="sec" id=${s.id || ''}>${this.#section(s, db)}</section>`)}
  333. </article>`)}
  334. ${attachments.length ? html`<article class="ch kf-card" data-report-attachments>
  335. <h2>报告文件与示意图</h2>
  336. ${attachments.map(file => html`<section class="file-entry">
  337. <h4>${file.name}</h4><p class="muted2">${Math.ceil(file.sizeBytes / 1024)} KB</p>
  338. ${file.image ? html`<a href=${file.previewUrl} target="_blank" rel="noopener noreferrer"><img class="report-image" src=${file.previewUrl} alt=${file.name} loading="lazy"></a>` : ''}
  339. ${file.document ? html`<details><summary>预览文件</summary><iframe class="file-preview" sandbox="" title=${file.name} loading="lazy" src=${file.previewUrl}></iframe></details>` : ''}
  340. <p><a href=${file.url} target="_blank" rel="noopener noreferrer">下载文件</a></p>
  341. </section>`)}
  342. </article>` : ''}
  343. <kf-glass-card surface="2" class="cover" style="margin-top:8px"><div class="cover-in">
  344. <div class="section-label">证据对照</div>
  345. <div class="tbl-wrap"><table class="rt">
  346. <thead><tr><th>结论</th><th>人</th><th>原文</th><th>成果</th><th></th></tr></thead>
  347. <tbody>${findings.map((f) => html`<tr>
  348. <td class="k">${f.conclusion}</td>
  349. <td>${f.ownerCount ?? '—'}</td><td>${f.quoteCount}</td><td>${f.derivedCount}</td>
  350. <td>${this.#anchor(f.evId, '证据链')}</td></tr>`)}</tbody>
  351. </table></div>
  352. </div></kf-glass-card>
  353. </div>
  354. <aside class="rail">
  355. <kf-glass-card surface="1" class="r"><div class="r-in">
  356. <div class="chain">
  357. <div title="结论"><b>${r.conclusionCount}</b><i>结论</i></div>
  358. <div title="带原文的结论"><b>${r.verifiedConclusions}</b><i>带原文</i></div>
  359. <div title="支撑成果"><b>${r.refsInLib}/${r.refCount}</b><i>支撑</i></div>
  360. <div title="原文片段"><b>${r.quoteCount}</b><i>引文</i></div>
  361. </div>
  362. </div></kf-glass-card>
  363. <kf-glass-card surface="1" class="r"><div class="r-in">
  364. <ol>${r.chapters.map((c) => html`<li><a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
  365. @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.title}</a></li>`)}</ol>
  366. </div></kf-glass-card>
  367. <kf-glass-card surface="1" class="r"><div class="r-in">
  368. <button class="kf-btn" @click=${() => this.#artifact(this.artifactId)}>证据链</button>
  369. </div></kf-glass-card>
  370. </aside>
  371. </div>
  372. </div>
  373. `;
  374. }
  375. /** section.kind → 渲染(未知 kind 原样列出,不吞) */
  376. #section(s) {
  377. const title = html`${s.no ? html`<span class="no">${s.no}</span>` : ''}${s.title || ''}`;
  378. switch (s.kind) {
  379. case 'para':
  380. return html`<h4>${title}</h4><p class="para">${s.text}</p>`;
  381. case 'longpara':
  382. return html`<h4>${title}</h4>${String(s.text || '').split(/\n{2,}/).filter(Boolean).map((p) => html`<p class="para">${p}</p>`)}`;
  383. case 'funnel':
  384. return html`<h4>${title}</h4><ol class="funnel">${(s.data || []).map((x) => {
  385. const max = Math.max(...(s.data || []).map((d) => Number(d.value) || 0), 1);
  386. const w = Math.max(12, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
  387. return html`<li><span class="ch-no">${x.stage}</span>
  388. <span><b class="kf-title">${x.name}</b> <span class="muted2">${x.scope}</span><div class="bar"><i style="width:${w}%"></i></div></span>
  389. <span class="kf-num"><b>${Number(x.value).toLocaleString('zh-CN')}</b> <span class="muted2">${x.unit || ''}</span></span></li>`;
  390. })}</ol>`;
  391. case 'findings':
  392. return html`<h4>${title}</h4>${(s.data || []).map((f) => {
  393. const e = this.#r.report.evidence?.[f.evidence] || {};
  394. return html`<div class="finding" data-finding=${f.id || ''}>
  395. <h5>${f.conclusion}</h5><p>${f.attitude}</p>
  396. <div class="ev">
  397. ${e.ownerCount != null ? html`<span><b>${e.ownerCount}</b> 人独立提到</span>` : ''}
  398. <span><b>${(e.quotes || []).length}</b> 条原文</span>
  399. <span><b>${(e.derivedFrom || []).filter(Boolean).length}</b> 条支撑成果</span>
  400. ${this.#anchor(f.evidence, '看证据链')}
  401. </div>
  402. </div>`;
  403. })}`;
  404. case 'matrix':
  405. return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
  406. <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  407. <tbody>${(s.data?.rows || []).map((row) => html`<tr>${row.map((cell, i) => html`<td class=${i === 0 ? 'k' : ''}>${this.#cell(cell)}</td>`)}</tr>`)}</tbody></table></div>`;
  408. case 'table':
  409. return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
  410. <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  411. <tbody>${(s.data?.rows || []).map((row) => html`<tr>${row.map((cell, i) => html`<td class=${i === 0 ? 'k' : ''}>${this.#cell(cell)}</td>`)}</tr>`)}</tbody></table></div>`;
  412. case 'two':
  413. return html`<h4>${title}</h4><div class="two">
  414. <div class="yes"><b>能做到的</b><ul>${(s.data?.can || []).map((x) => html`<li>${x}</li>`)}</ul></div>
  415. <div class="no"><b>做不到的(红线)</b><ul>${(s.data?.cannot || []).map((x) => html`<li>${x}</li>`)}</ul></div></div>`;
  416. case 'fit':
  417. return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
  418. <thead><tr>${(s.data?.transferCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  419. <tbody>${(s.data?.transfer || []).map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>
  420. ${(s.data?.redline || []).length ? html`<div class="tbl-wrap" style="margin-top:8px"><table class="rt">
  421. <thead><tr>${(s.data.redlineCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  422. <tbody>${s.data.redline.map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>` : ''}`;
  423. case 'score':
  424. return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
  425. <thead><tr><th>机会</th><th>需求</th><th>竞争</th><th>我方能力</th><th>总分</th><th>结论</th></tr></thead>
  426. <tbody>${(s.data || []).map(([n, a, b, c, t, v]) => html`<tr><td class="k">${n}</td><td>${a}</td><td>${b}</td><td>${c}</td>
  427. <td><div class="bar" style="display:inline-block;width:90px;vertical-align:middle"><i style="width:${Math.min(100, Number(t) || 0)}%"></i></div> <b class="kf-num">${t}</b></td><td>${v}</td></tr>`)}</tbody></table></div>`;
  428. case 'ladder': {
  429. const d = s.data || {};
  430. return html`<h4>${title}</h4><ol class="funnel">${(d.ladder || []).map(([no, name, note]) => html`<li><span class="ch-no">${no}</span><span><b class="kf-title">${name}</b> <span class="muted2">${note}</span></span></li>`)}</ol>
  431. ${d.statement ? html`<p class="para" style="margin-top:10px;font-size: 16px"><b>一句话定位:</b>「${d.statement}」</p>` : ''}
  432. ${d.for_who ? html`<div class="muted2">${t(`写给谁:${d.for_who}`)}</div>` : ''}`;
  433. }
  434. case 'statement':
  435. return html`${(s.data?.alt || []).length ? html`<h4>${title}</h4><ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;
  436. case 'milestones':
  437. return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt"><tbody>${(s.data || []).map(([m, name, checks, rule]) => html`<tr>
  438. <td class="k" style="width:56px">${m}</td><td><b>${name}</b><ul class="muted2" style="margin:4px 0 0;padding-left:16px">${(checks || []).map((c) => html`<li>${c}</li>`)}</ul></td>
  439. <td class="muted2">决策规则:${rule}</td></tr>`)}</tbody></table></div>`;
  440. default: {
  441. const flat = (v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v ?? ''));
  442. return html`<h4>${title}</h4><div class="muted2">未知的结构类型「${s.kind}」,原始数据:</div>
  443. <pre class="para" style="white-space:pre-wrap;font-size: 12px">${Object.entries(s.data && typeof s.data === 'object' ? s.data : s).map(([k, v]) => `${k}: ${flat(v)}`).join('\n')}</pre>`;
  444. }
  445. }
  446. }
  447. /** 单元格:字符串若是 evidence 键就渲染为结论 + 锚点;数组按行展示 */
  448. #cell(cell) {
  449. const ev = this.#r?.report?.evidence || {};
  450. if (Array.isArray(cell) && cell.every((x) => typeof x === 'string')) return cell.map((x) => html`<div>${x}</div>`);
  451. if (Array.isArray(cell)) return cell.map(([name, pct]) => html`<div>${name} <b class="kf-num">${pct}%</b></div>`);
  452. if (typeof cell === 'string' && ev[cell]) return html`<div>${ev[cell].claim || cell}</div>${this.#anchor(cell, '原文')}`;
  453. if (typeof cell === 'number') return html`<span class="kf-num">${cell.toLocaleString('zh-CN')}</span>`;
  454. return cell ?? '—';
  455. }
  456. }
  457. if (!customElements.get('view-report')) customElements.define('view-report', ViewReport);