view-report.js 36 KB

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