view-report.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  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, html } from 'lit';
  14. import { keyed } from 'lit/directives/keyed.js';
  15. import { useTokens } from '../components/base.js';
  16. import { reportOverview, reportExecutive, findingsOf, fmtDate, chapterDigests, reportTypeOf, traceArtifact } from '../core/index.js';
  17. import { reportCategoryOf, reportBodyStatus } from '../core/report-delivery.js';
  18. import { reportAttachments } from '../core/report-attachments.js';
  19. import { evidenceExcerpts } from '../core/report-evidence.js';
  20. import { opportunitiesForReport, reportOpportunity } from '../core/opportunity/report.js';
  21. import '../components/kf-score-panel.js';
  22. import '../components/kf-evidence-chain.js';
  23. import { navigate, href, safeReturnTo, reportUrl, currentUrl } from '../utils/router.js';
  24. import { t, VIEW_CSS } from './shared.js';
  25. import { REPORT_CSS } from './report-styles.js';
  26. import { icon } from '../components/icons.js';
  27. export class ViewReport extends LitElement {
  28. static properties = {
  29. db: { type: Object },
  30. artifactId: { type: String },
  31. routeQuery: { type: Object },
  32. opportunityId: { type: String },
  33. me: { type: String },
  34. _active: { state: true },
  35. };
  36. #r = null;
  37. #type = null;
  38. #resize = null;
  39. #chapterFrame = null;
  40. #smoothJump = false;
  41. #lastChapterTarget = '';
  42. #chapterVersion = 0;
  43. constructor() {
  44. super();
  45. this.db = null;
  46. this.artifactId = '';
  47. this.routeQuery = {};
  48. this.opportunityId = '';
  49. this.me = '';
  50. this._active = '';
  51. }
  52. connectedCallback() {
  53. super.connectedCallback();
  54. useTokens(this);
  55. }
  56. disconnectedCallback() {
  57. super.disconnectedCallback();
  58. this.#io?.disconnect();
  59. this.#resize?.disconnect();
  60. cancelAnimationFrame(this.#chapterFrame);
  61. this.#chapterVersion++;
  62. }
  63. #io = null;
  64. willUpdate(changed) {
  65. if (changed.has('artifactId')) this._active = '';
  66. }
  67. updated(changed) {
  68. if (!this.db) return;
  69. if (!this.#resize && typeof ResizeObserver !== 'undefined') {
  70. this.#resize = new ResizeObserver(() => this.#measureOffsets());
  71. const toc = this.renderRoot.querySelector('[data-toc]');
  72. if (toc) this.#resize.observe(toc);
  73. }
  74. if (changed.has('artifactId') || changed.has('db')) this.#measureOffsets();
  75. const chapter = String(this.routeQuery?.ch || '').replace(/^ch-/, '');
  76. const target = chapter ? `${this.artifactId}:${chapter}` : '';
  77. if (chapter && target !== this.#lastChapterTarget) {
  78. const smooth = this.#smoothJump;
  79. this.#smoothJump = false;
  80. this.#restoreChapter(`ch-${chapter}`, smooth ? 'smooth' : 'instant');
  81. } else if (target !== this.#lastChapterTarget) {
  82. this.#chapterVersion++;
  83. cancelAnimationFrame(this.#chapterFrame);
  84. }
  85. this.#lastChapterTarget = target;
  86. }
  87. #measureOffsets() {
  88. const top = window.matchMedia('(max-width: 767px)').matches ? 0 : 12;
  89. const tocHeight = Math.ceil(this.renderRoot.querySelector('[data-toc]')?.getBoundingClientRect().height || 44);
  90. this.style.setProperty('--report-topbar', `${top}px`);
  91. this.style.setProperty('--report-anchor', `${top + tocHeight + 16}px`);
  92. this.#observeChapters(top + tocHeight + 8);
  93. }
  94. async #restoreChapter(id, behavior) {
  95. const version = ++this.#chapterVersion;
  96. // 卡片的 Shadow DOM 和字体都会改变章节位置,等待实际布局稳定后再恢复锚点。
  97. await Promise.all([...this.renderRoot.querySelectorAll('*')].map(el => el.updateComplete));
  98. if (document.fonts) await document.fonts.ready;
  99. if (!this.isConnected || version !== this.#chapterVersion) return;
  100. cancelAnimationFrame(this.#chapterFrame);
  101. this.#chapterFrame = requestAnimationFrame(() => {
  102. this.#measureOffsets();
  103. this.#chapterFrame = requestAnimationFrame(() => {
  104. if (version === this.#chapterVersion) this.#scrollChapter(id, behavior);
  105. });
  106. });
  107. }
  108. #observeChapters(offset) {
  109. this.#io?.disconnect();
  110. // 滚动高亮是增强能力:环境不支持 IntersectionObserver 时整页照常可读,只是不自动高亮
  111. if (typeof IntersectionObserver === 'undefined') return;
  112. const root = this.renderRoot;
  113. const chapters = [...root.querySelectorAll('article.ch')];
  114. this.#io = new IntersectionObserver((entries) => {
  115. const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
  116. if (visible[0]) this._active = visible[0].target.id;
  117. }, { rootMargin: `-${offset}px 0px -45% 0px`, threshold: 0 });
  118. chapters.forEach((c) => this.#io.observe(c));
  119. }
  120. static styles = [VIEW_CSS, REPORT_CSS];
  121. #jump(id) {
  122. // 应用内路径 + 相对章节 id(ch3 而不是 ch-ch3)
  123. const key = id.replace(/^ch-/, '');
  124. if (String(this.routeQuery?.ch || '').replace(/^ch-/, '') === key) return this.#scrollChapter(id, 'smooth');
  125. this.#smoothJump = true;
  126. // 当前章写回 URL(?ch=3),刷新时还原到同一章;只改查询串,不新增历史
  127. const q = new URLSearchParams(this.routeQuery || {});
  128. q.set('ch', key);
  129. if (this.opportunityId) q.set('opportunity', this.opportunityId);
  130. navigate(`/report/${encodeURIComponent(this.artifactId)}?${q.toString()}`, { replace: true });
  131. }
  132. #scrollChapter(id, behavior) {
  133. const el = this.renderRoot.querySelector(`#${CSS.escape(id)}`);
  134. if (!el) return;
  135. this._active = id;
  136. el.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : behavior, block: 'start' });
  137. }
  138. get typeInfo() { return this.#type; }
  139. #digest(d) {
  140. return html`<a class="d" href=${`#ch-${d.id}`} data-digest-ch=${d.id} @click=${(e) => { e.preventDefault(); this.#jump(`ch-${d.id}`); }}>
  141. <span class="dn">${String(d.no).padStart(2, '0')}</span>
  142. <span class="dt">${d.title}</span>
  143. <span class="ds">${d.summary || '这一章没有可抽取的句子,点到章内看原文。'}</span>
  144. </a>`;
  145. }
  146. #openEv(evId) {
  147. this.dispatchEvent(new CustomEvent('evidence-open', { detail: { reportId: this.artifactId, evId }, bubbles: true, composed: true }));
  148. }
  149. #artifact(id) {
  150. navigate(reportUrl(id, currentUrl()));
  151. }
  152. #anchor(ev, label) {
  153. if (!ev) return '';
  154. const e = this.#r?.report.evidence?.[ev];
  155. if (!e) return '';
  156. const n = (e.quotes || []).length;
  157. return html`<button class="anchor" data-anchor=${ev} @click=${() => this.#openEv(ev)}>${icon('link', 12)} ${label || '证据链'}${n ? html` · ${n} 条原文` : ''}</button>`;
  158. }
  159. #excerpts(evidenceId, limit = Infinity) {
  160. const all = evidenceExcerpts(this.#r?.report, evidenceId, this.db?.sourceById);
  161. if (!all.length) return html`<p class="evidence-note">未保存可展示的原句,请核对正文依据与来源;不根据摘要补造引文。</p>`;
  162. return html`<div class="evidence-excerpts" data-evidence-excerpts=${evidenceId}>
  163. ${all.slice(0, limit).map(quote => html`<figure class="evidence-excerpt">
  164. <figcaption class="evidence-caption">
  165. <span class="evidence-kicker">引用来源</span>
  166. <span class="evidence-source">${quote.sourceTitle || quote.ref || '来源名称未记录'}</span>
  167. ${quote.author ? html`<span class="evidence-author">作者:${quote.author}</span>` : ''}
  168. </figcaption>
  169. <blockquote>${quote.text}</blockquote>
  170. ${quote.url || quote.sourceId || quote.ref ? html`<div class="evidence-footer">
  171. <div class="evidence-actions">
  172. ${quote.url ? html`<a href=${quote.url} target="_blank" rel="noopener noreferrer">打开来源链接 ↗</a>` : ''}
  173. ${quote.sourceAvailable ? html`<button @click=${() => this.dispatchEvent(new CustomEvent('source-open', { detail: { id: quote.sourceId }, bubbles:true, composed:true }))}>查看来源记录</button>` : ''}
  174. </div>
  175. ${quote.ref || quote.sourceId && !quote.sourceAvailable ? html`<details class="source-meta">
  176. <summary>${icon('chev', 12)}出处与定位</summary>
  177. ${quote.ref ? html`<p>${quote.ref}</p>` : ''}
  178. ${quote.sourceId && !quote.sourceAvailable ? html`<p>来源编号:${quote.sourceId}(本页未载入原件)</p>` : ''}
  179. </details>` : ''}
  180. </div>` : ''}
  181. </figure>`)}
  182. ${all.length > limit ? html`<span class="evidence-note">另有 ${all.length - limit} 条已保存引文,可在下方“判断与原始依据”或证据链查看。</span>` : ''}
  183. </div>`;
  184. }
  185. #selectedOpportunity() {
  186. return this.artifactId ? reportOpportunity(this.db, this.artifactId, this.opportunityId)
  187. : this.db.opportunityById?.[this.opportunityId];
  188. }
  189. #rating() {
  190. const linked = this.artifactId ? opportunitiesForReport(this.db, this.artifactId) : [];
  191. const selected = this.#selectedOpportunity();
  192. if (!linked.length && !selected && !this.opportunityId) return '';
  193. return html`<kf-glass-card surface="1" class="r rating-card" id="opportunity-rating" data-opportunity-rating><section class="r-in">
  194. <h2 class="rating-title">多人评分</h2>
  195. ${linked.length > 1 || (this.artifactId && this.opportunityId && !selected) ? html`
  196. <label class="rating-label" for="rating-opportunity">选择评分与决策的机会</label>
  197. <select id="rating-opportunity" aria-label="评分与决策机会" @change=${e => {
  198. const q = new URLSearchParams(this.routeQuery || {});
  199. q.set('opportunity', e.target.value);
  200. navigate(`/report/${encodeURIComponent(this.artifactId)}?${q}`, { replace: true });
  201. }}><option value="" disabled .selected=${!selected}>请选择机会卡</option>
  202. ${linked.map(o => html`<option value=${o.id} .selected=${o.id === selected?.id}>${o.title}</option>`)}
  203. </select>` : ''}
  204. ${selected ? html`<p class="rating-target">${selected.title}</p>
  205. ${keyed(selected.id, html`<kf-score-panel compact .opportunityId=${selected.id} .stats=${selected.stats} .user=${this.me}></kf-score-panel>`)}`
  206. : html`<p class="evidence-note">${this.opportunityId ? '当前机会与这份报告没有有效关联,请重新选择后评分和决策。' : '这份报告关联多个机会,请先选择要评分和决策的机会。'}</p>`}
  207. </section></kf-glass-card>`;
  208. }
  209. #decision() {
  210. const selected = this.#selectedOpportunity();
  211. if (!selected) return '';
  212. const decision = selected.decision;
  213. const history = selected.decisionLog || [];
  214. return html`<kf-glass-card surface="1" class="r decision-card" data-opportunity-decision=${selected.id}><section class="r-in">
  215. <div class="decision-heading"><h2 class="rating-title">机会决策</h2><kf-badge kind="status" .value=${selected.status}></kf-badge></div>
  216. <p class="rating-target">${selected.title}</p>
  217. <div class="decision-actions" role="group" aria-label="机会决策">
  218. ${[['candidate', '候选'], ['approved', '立项'], ['rejected', '否决']].map(([status, label]) => html`
  219. <button type="button" class=${status} aria-pressed=${selected.status === status ? 'true' : 'false'}
  220. @click=${() => this.dispatchEvent(new CustomEvent('status-change', { detail: { id: selected.id, status }, bubbles: true, composed: true }))}>${label}</button>`)}
  221. </div>
  222. <section class="decision-summary"><h3>决策说明</h3>
  223. ${decision ? html`<p class="decision-label">${decision.label}</p>
  224. ${decision.reason ? html`<p class="decision-reason">${decision.reason}</p>` : html`<p class="evidence-note">未填写决策说明。</p>`}
  225. <p class="decision-meta">${decision.by || '未署名'} · ${fmtDate(decision.at, true) || '时间未记录'}</p>`
  226. : html`<p class="evidence-note">${selected.status === 'candidate' ? '尚无决策,等待评估。' : '尚无决策说明,等待补充记录。'}</p>`}
  227. </section>
  228. <details class="decision-history">
  229. <summary>决策记录 ${history.length} 条</summary>
  230. ${history.length ? html`<ol>${[...history].reverse().map(item => html`<li>
  231. <p class="decision-label">${item.label}</p>${item.reason ? html`<p class="decision-reason">${item.reason}</p>` : ''}
  232. <p class="decision-meta">${item.by || '未署名'} · ${fmtDate(item.at, true) || '时间未记录'}</p>
  233. </li>`)}</ol>` : html`<p class="evidence-note">暂无决策记录。</p>`}
  234. </details>
  235. </section></kf-glass-card>`;
  236. }
  237. #ratingJump() {
  238. if (!this.opportunityId && !opportunitiesForReport(this.db, this.artifactId).length) return '';
  239. return html`<a href="#opportunity-rating" @click=${e => {
  240. e.preventDefault();
  241. this.renderRoot.querySelector('#opportunity-rating')?.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'start' });
  242. }}>多人评分 ↓</a>`;
  243. }
  244. #pendingReport() {
  245. const opportunity = this.db.opportunityById?.[this.opportunityId];
  246. if (!opportunity) return html`<kf-empty kind="error" title="未找到这张机会卡" action-label="返回机会卡池" @empty-action=${() => navigate('/pool')}></kf-empty>`;
  247. const back = safeReturnTo(this.routeQuery?.returnTo, '/pool');
  248. return html`<nav class="reading-nav" aria-label="报告导航"><a href=${href(back)} data-link data-back>← 返回${back.startsWith('/pool') ? '机会卡池' : '列表'}</a>${this.#ratingJump()}</nav>
  249. <kf-glass-card surface="2" class="cover"><div class="cover-in"><h1>${opportunity.title}</h1><p class="sub">暂无完整报告</p></div></kf-glass-card>
  250. <div class="layout"><div class="col">
  251. <article class="ch kf-card" data-report-pending><h2>报告尚未收录</h2><p class="para">这张机会卡尚未关联可读取的报告正文。以下保留已提交的机会说明与来源,可先阅读并参与评分和决策。</p>
  252. ${opportunity.oneLiner ? html`<section class="sec"><h4>机会说明</h4><p class="para">${opportunity.oneLiner}</p></section>` : ''}
  253. <section class="sec"><h4>提交来源</h4><p class="para">${opportunity.upload.uploadedBy || '提交人未记录'} · ${opportunity.upload.agentName || '数字人未记录'} · ${opportunity.upload.methodLabel || '方式未记录'} · ${fmtDate(opportunity.upload.uploadedAt, true) || '时间未记录'}</p></section>
  254. </article></div>
  255. <aside class="rail with-rating"><kf-glass-card surface="1" class="r"><div class="r-in"><h2 class="rating-title">证据链</h2>
  256. ${opportunity.linkedArtifacts.length ? opportunity.linkedArtifacts.map(a => a.missing ? html`<p class="evidence-note">${a.title}</p>` : html`<button class="linked-evidence" @click=${() => this.#artifact(a.id)}>${a.shortTitle || a.title}</button>`)
  257. : html`<p class="evidence-note">尚未关联成果来源。</p>`}
  258. </div></kf-glass-card>${this.#rating()}${this.#decision()}</aside>
  259. </div>`;
  260. }
  261. #reportInfo(artifact) {
  262. const fields = [
  263. ['提交人', artifact.ownerName || '未记录'],
  264. ['报告分类', reportTypeOf(reportCategoryOf(artifact))?.name || '未归类'],
  265. ['阶段', artifact.stageObj?.label || artifact.stage],
  266. ['证据', artifact.evidenceLabel],
  267. ['提交方式', artifact.upload?.methodLabel],
  268. ['数字人', artifact.upload?.agentName],
  269. ['提交时间', fmtDate(artifact.upload?.uploadedAt || artifact.createdAt, true)],
  270. ['研究课题', artifact.workshopKey],
  271. ['版本', artifact.version ? `V${artifact.version}` : ''],
  272. ['资料范围', ({ live: '真实资料', sample: '样例', manual: '人工提供' })[artifact.dataMode]],
  273. ].filter(([, value]) => value && value !== '—');
  274. return html`<kf-glass-card surface="1" class="r"><section class="r-in report-info" data-report-info>
  275. <h2 class="rating-title">报告信息</h2>
  276. <dl>${fields.map(([label, value]) => html`<div><dt>${label}</dt><dd>${value}</dd></div>`)}</dl>
  277. <details class="report-ids"><summary>资料编号</summary><code>${artifact.id}</code></details>
  278. </section></kf-glass-card>`;
  279. }
  280. #reportRelations(artifact) {
  281. return html`<kf-glass-card surface="2" class="cover"><section class="cover-in" data-report-relations>
  282. <h2 class="section-label">引用与关联</h2>
  283. <kf-evidence-chain embedded mode="artifact" .chain=${traceArtifact(this.db, artifact.id)}></kf-evidence-chain>
  284. ${artifact.rawText && reportBodyStatus(artifact) === 'report' ? html`<details class="saved-original"><summary>已保存原文</summary><p>${artifact.rawText}</p></details>` : ''}
  285. </section></kf-glass-card>`;
  286. }
  287. #reportFiles(artifact) {
  288. const attachments = reportAttachments(artifact, ['https://kangshifu.fmode.cn', location.origin]);
  289. return attachments.length ? html`<article class="ch kf-card" data-report-attachments>
  290. <h2 class="section-label">报告文件与示意图</h2>
  291. <div class="attachment-grid">${attachments.map(file => html`<section class="file-entry">
  292. <h4>${file.name}</h4><p class="muted2">${Math.ceil(file.sizeBytes / 1024)} KB</p>
  293. ${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>` : ''}
  294. ${file.document ? html`<details><summary>预览文件</summary><iframe class="file-preview" sandbox="" title=${file.name} loading="lazy" src=${file.previewUrl}></iframe></details>` : ''}
  295. <p><a href=${file.url} target="_blank" rel="noopener noreferrer">下载文件</a></p>
  296. </section>`)}</div>
  297. </article>` : '';
  298. }
  299. #registeredReport(artifact) {
  300. const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
  301. const summary = reportExecutive(artifact.report);
  302. this.#type = reportTypeOf(reportCategoryOf(artifact));
  303. return html`<nav class="reading-nav" aria-label="报告导航">
  304. <a class="report-back" href=${href(back)} data-link data-back>← 返回${back.split('?')[0] === '/workshop' ? '小组成果' : '列表'}</a>
  305. ${this.#ratingJump()}<a href=${href('/reports')} data-link>报告中心</a>
  306. </nav>
  307. <kf-glass-card surface="2" class="cover"><div class="cover-in">
  308. <div class="top">
  309. ${artifact.stageObj ? html`<kf-badge kind="stage" .value=${artifact.stageObj.code} label=${artifact.stageObj.label}></kf-badge>` : ''}
  310. ${this.typeInfo ? html`<kf-badge kind="plain" .value=${this.typeInfo.name}></kf-badge>` : ''}
  311. </div>
  312. <h1>${artifact.report?.title || artifact.title}</h1>
  313. </div></kf-glass-card>
  314. <div class="layout"><div class="col">
  315. <article class="ch kf-card" data-report-body-pending>
  316. <div class="section-head"><h2 class="section-label">报告正文</h2><span class="body-state">正文待补</span></div>
  317. ${summary.length ? html`<section class="sec"><h4>已保存摘要</h4>${summary.map(text => html`<p class="para">${text}</p>`)}</section>` : ''}
  318. ${artifact.quotes?.length ? html`<section class="sec"><h4>已保存引文</h4>${artifact.quotes.map(quote => html`
  319. <figure class="registered-quote"><blockquote>${quote.text || ''}</blockquote>${quote.author || quote.ref ? html`<figcaption>${[quote.author, quote.ref].filter(Boolean).join(' · ')}</figcaption>` : ''}</figure>`)}
  320. </section>` : ''}
  321. </article>
  322. ${this.#reportFiles(artifact)}${this.#reportRelations(artifact)}
  323. </div><aside class="rail with-rating">${this.#reportInfo(artifact)}${this.#rating()}${this.#decision()}</aside></div>`;
  324. }
  325. render() {
  326. const db = this.db;
  327. if (!db) return html`<kf-empty kind="loading" ></kf-empty>`;
  328. if (!this.artifactId && this.opportunityId) return this.#pendingReport();
  329. const r = reportOverview(db, this.artifactId);
  330. this.#r = r;
  331. if (!r) {
  332. const artifact = db.artifactById[this.artifactId];
  333. if (artifact) return this.#registeredReport(artifact);
  334. return html`<kf-empty kind="error" title="未找到这份报告"
  335. action-label="返回" @empty-action=${() => navigate(safeReturnTo(this.routeQuery?.returnTo, '/reports'))}></kf-empty>`;
  336. }
  337. const findings = findingsOf(db, this.artifactId);
  338. const report = r.report;
  339. const digests = chapterDigests(db, this.artifactId);
  340. this.#type = reportTypeOf(reportCategoryOf(r.artifact));
  341. const hasExecutive = r.executive.length > 0;
  342. if (!r.executive?.length) {
  343. // 执行摘要缺失时:用章节摘要替身,但明确标注来源,不冒充
  344. r.executive = digests.slice(0, 3).map((d) => `〔${d.title}〕${d.summary || ''}`);
  345. }
  346. const active = this._active || (this.routeQuery?.ch ? `ch-${String(this.routeQuery.ch).replace(/^ch-/, '')}` : '');
  347. const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
  348. return html`
  349. <nav class="reading-nav" aria-label="报告导航">
  350. <a class="report-back" href=${href(back)} data-link data-back>${back.split('?')[0] === '/workshop' ? '← 返回小组成果' : back.split('?')[0] === '/pool' ? '← 返回机会卡池' : back.startsWith('/pool/') ? '← 返回机会卡' : '← 返回列表'}</a>
  351. ${this.#ratingJump()}
  352. ${this.typeInfo ? html`<a href=${href('/reports/' + this.typeInfo.key)} data-link>${this.typeInfo.name}</a>` : ''}
  353. <a href=${href('/reports')} data-link>报告中心</a>
  354. </nav>
  355. <kf-glass-card surface="2" class="cover"><div class="cover-in">
  356. <div class="top">
  357. <kf-badge kind="stage" .value=${r.stages[0] || '—'} label=${r.stages.join(' · ') || '跨阶段'}></kf-badge>
  358. <kf-badge kind="plain" .value=${`${r.artifact.ownerName || '无出身'} · ${r.artifact.upload.methodLabel || '方式未记录'}`}></kf-badge>
  359. ${r.artifact.upload.agentName ? html`<kf-badge kind="method" .value=${r.artifact.upload.agentName}></kf-badge>` : ''}
  360. ${this.typeInfo ? html`<kf-badge class="typeb" kind="method" .value=${this.typeInfo.name} label=${'类型:' + this.typeInfo.name}></kf-badge>` : ''}
  361. </div>
  362. <div>
  363. <h1>${r.title}</h1>
  364. <div class="sub" style="margin-top:6px">${r.subtitle}</div>
  365. </div>
  366. <div class="meta">
  367. <span>${fmtDate(r.generatedAt, true)}</span><span>${t(`${r.quoteCount} 原文`)}</span><span>${t(`${r.refsInLib}/${r.refCount} 关联成果`)}</span>
  368. </div>
  369. ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
  370. </div></kf-glass-card>
  371. <div class="toc kf-glass is-top is-overlay" aria-label="章节导航" data-toc>
  372. ${r.chapters.map((c) => html`<a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
  373. @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.no}. ${c.title}</a>`)}
  374. </div>
  375. <div class="main-col">
  376. <div class="layout">
  377. <div class="col">
  378. ${r.executive.length ? html`<kf-glass-card surface="2" class="cover exec-card" data-exec><div class="cover-in">
  379. <div class="section-label">${hasExecutive ? '报告摘要' : '章节摘要'}</div>
  380. <ol class="exec" data-exec-list>${r.executive.map((x, i) => html`<li data-exec-item=${String(i + 1)}>${x}</li>`)}</ol>
  381. </div></kf-glass-card>` : ''}
  382. <kf-glass-card surface="1" class="cover digest" data-digest><div class="cover-in">
  383. <div class="section-label">章节导读</div><div class="dgrid">${digests.map((d) => this.#digest(d))}</div>
  384. </div></kf-glass-card>
  385. ${r.chapters.map((c) => html`<article class="ch kf-card" id="ch-${c.id}" data-chapter=${c.id}>
  386. <div class="ch-h"><span class="en">${c.en}</span><span class="ch-no">${c.no}</span><h2>${c.title}</h2></div>
  387. ${c.subtitle ? html`<div class="muted2">${c.subtitle}</div>` : ''}
  388. ${c.lead ? html`<div class="lead">${c.lead}</div>` : ''}
  389. ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">${t(`对应阶段 ${c.stage.code} ${c.stage.name}`)}</div>` : ''}
  390. ${c.sections.map((s) => html`<section class="sec" id=${s.id || ''}>${this.#section(s, db)}</section>`)}
  391. </article>`)}
  392. ${this.#reportFiles(r.artifact)}
  393. <kf-glass-card surface="2" class="cover evidence-panel"><div class="cover-in" data-evidence-reading>
  394. <div class="section-head"><h2 class="section-label">判断与原始依据</h2><span class="section-count">${r.quoteCount} 条引文</span></div>
  395. <div class="evidence-catalog">${findings.map(f => html`<section class="evidence-group" data-evidence-group=${f.evId}>
  396. <h3>${f.conclusion}</h3>
  397. ${this.#excerpts(f.evId)}
  398. ${this.#anchor(f.evId, '查看引用关系')}
  399. </section>`)}</div>
  400. ${!findings.length ? html`<p class="evidence-note">本报告尚未保存可展开的证据关联;请阅读正文中的依据说明。</p>` : ''}
  401. <details class="evidence-more"><summary>查看引用数量与关联成果</summary>
  402. <div class="tbl-wrap"><table class="rt">
  403. <thead><tr><th>结论</th><th>人</th><th>原文</th><th>成果</th><th></th></tr></thead>
  404. <tbody>${findings.map((f) => html`<tr>
  405. <td class="k">${f.conclusion}</td>
  406. <td>${f.ownerCount ?? '—'}</td><td>${f.quoteCount}</td><td>${f.derivedCount}</td>
  407. <td>${this.#anchor(f.evId, '证据链')}</td></tr>`)}</tbody>
  408. </table></div></details>
  409. </div></kf-glass-card>
  410. ${this.#reportRelations(r.artifact)}
  411. </div>
  412. <aside class="rail ${this.opportunityId || opportunitiesForReport(db, this.artifactId).length ? 'with-rating' : ''}">
  413. <kf-glass-card surface="1" class="r"><div class="r-in">
  414. <div class="chain">
  415. <div title="结论"><b>${r.conclusionCount}</b><i>结论</i></div>
  416. <div title="带原文的结论"><b>${r.verifiedConclusions}</b><i>带原文</i></div>
  417. <div title="关联成果"><b>${r.refsInLib}/${r.refCount}</b><i>关联</i></div>
  418. <div title="原文片段"><b>${r.quoteCount}</b><i>引文</i></div>
  419. </div>
  420. </div></kf-glass-card>
  421. <kf-glass-card surface="1" class="r"><div class="r-in">
  422. <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'}
  423. @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.title}</a></li>`)}</ol>
  424. </div></kf-glass-card>
  425. ${this.#reportInfo(r.artifact)}
  426. ${this.#rating()}
  427. ${this.#decision()}
  428. </aside>
  429. </div>
  430. </div>
  431. `;
  432. }
  433. /** section.kind → 渲染(未知 kind 原样列出,不吞) */
  434. #section(s) {
  435. const title = s.title || s.no ? html`<h4>${s.no ? html`<span class="no">${s.no}</span>` : ''}${s.title || ''}</h4>` : '';
  436. switch (s.kind) {
  437. case 'para':
  438. return html`${title}<p class="para">${s.text}</p>`;
  439. case 'longpara':
  440. return html`${title}${String(s.text || '').split(/\n{2,}/).filter(Boolean).map((p) => html`<p class="para">${p}</p>`)}`;
  441. case 'funnel':
  442. return html`${title}<ol class="funnel">${(s.data || []).map((x) => {
  443. const max = Math.max(...(s.data || []).map((d) => Number(d.value) || 0), 1);
  444. const w = Math.max(12, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
  445. return html`<li><span class="ch-no">${x.stage}</span>
  446. <span><b class="kf-title">${x.name}</b> <span class="muted2">${x.scope}</span><div class="bar"><i style="width:${w}%"></i></div></span>
  447. <span class="kf-num"><b>${Number(x.value).toLocaleString('zh-CN')}</b> <span class="muted2">${x.unit || ''}</span></span></li>`;
  448. })}</ol>`;
  449. case 'findings':
  450. return html`${title}<div class="findings-grid">${(s.data || []).map((f) => {
  451. const e = this.#r.report.evidence?.[f.evidence] || {};
  452. return html`<div class="finding" data-finding=${f.id || ''}>
  453. <h5>${f.conclusion}</h5>${f.attitude ? html`<p class="finding-explanation">${f.attitude}</p>` : ''}
  454. ${this.#excerpts(f.evidence, 2)}
  455. <div class="ev">
  456. ${e.ownerCount != null ? html`<span>报告记录人数 <b>${e.ownerCount}</b></span>` : ''}
  457. <span><b>${(e.quotes || []).length}</b> 条原文</span>
  458. <span><b>${(e.derivedFrom || []).filter(Boolean).length}</b> 条关联成果</span>
  459. ${this.#anchor(f.evidence, '看证据链')}
  460. </div>
  461. </div>`;
  462. })}</div>`;
  463. case 'matrix':
  464. return html`${title}<div class="tbl-wrap"><table class="rt">
  465. <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  466. <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>`;
  467. case 'table':
  468. return html`${title}<div class="tbl-wrap"><table class="rt">
  469. <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  470. <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>`;
  471. case 'two':
  472. return html`${title}<div class="two">
  473. <div class="yes"><b>能做到的</b><ul>${(s.data?.can || []).map((x) => html`<li>${x}</li>`)}</ul></div>
  474. <div class="no"><b>做不到的(红线)</b><ul>${(s.data?.cannot || []).map((x) => html`<li>${x}</li>`)}</ul></div></div>`;
  475. case 'fit':
  476. return html`${title}<div class="tbl-wrap"><table class="rt">
  477. <thead><tr>${(s.data?.transferCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  478. <tbody>${(s.data?.transfer || []).map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>
  479. ${(s.data?.redline || []).length ? html`<div class="tbl-wrap" style="margin-top:8px"><table class="rt">
  480. <thead><tr>${(s.data.redlineCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
  481. <tbody>${s.data.redline.map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>` : ''}`;
  482. case 'score':
  483. return html`${title}<div class="tbl-wrap"><table class="rt">
  484. <thead><tr><th>机会</th><th>需求</th><th>竞争</th><th>我方能力</th><th>总分</th><th>结论</th></tr></thead>
  485. <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>
  486. <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>`;
  487. case 'ladder': {
  488. const d = s.data || {};
  489. return html`${title}<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>
  490. ${d.statement ? html`<p class="para" style="margin-top:10px;font-size: 16px"><b>一句话定位:</b>「${d.statement}」</p>` : ''}
  491. ${d.for_who ? html`<div class="muted2">${t(`写给谁:${d.for_who}`)}</div>` : ''}`;
  492. }
  493. case 'statement':
  494. return html`${(s.data?.alt || []).length ? html`${title}<ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;
  495. case 'milestones':
  496. return html`${title}<div class="tbl-wrap"><table class="rt"><tbody>${(s.data || []).map(([m, name, checks, rule]) => html`<tr>
  497. <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>
  498. <td class="muted2">决策规则:${rule}</td></tr>`)}</tbody></table></div>`;
  499. default: {
  500. const flat = (v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v ?? ''));
  501. return html`${title}<div class="muted2">未知的结构类型「${s.kind}」,原始数据:</div>
  502. <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>`;
  503. }
  504. }
  505. }
  506. /** 单元格:字符串若是 evidence 键就渲染为结论 + 锚点;数组按行展示 */
  507. #cell(cell) {
  508. const ev = this.#r?.report?.evidence || {};
  509. if (Array.isArray(cell) && cell.every((x) => typeof x === 'string')) return cell.map((x) => html`<div>${x}</div>`);
  510. if (Array.isArray(cell)) return cell.map(([name, pct]) => html`<div>${name} <b class="kf-num">${pct}%</b></div>`);
  511. if (typeof cell === 'string' && ev[cell]) return html`<div>${ev[cell].claim || cell}</div>${this.#anchor(cell, '原文')}`;
  512. if (typeof cell === 'number') return html`<span class="kf-num">${cell.toLocaleString('zh-CN')}</span>`;
  513. return cell ?? '—';
  514. }
  515. }
  516. if (!customElements.get('view-report')) customElements.define('view-report', ViewReport);