| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- /**
- * pages/view-report.js —— <view-report> 报告阅读页(保留 v3 能力)。
- *
- * - 吸顶章节导航 + 滚动高亮(IntersectionObserver)+ 平滑跳转,当前章写回 ?ch=,刷新还原
- * - 每章开头一句「这章在回答什么问题」
- * - 数据管线可见(2,727 采集 → 1,190 去噪 → 730 精准,过滤率 73%)
- * - KPI、交叉分析表、敢下判断的结论
- * - 结论旁的证据锚点 → 证据抽屉(N 人提到 / N 条原文 / N 条支撑成果,点名到人)
- * - 附录:所有结论 × 证据对照总表;「关联成果」区显示引用了本报告的归纳(反向)
- *
- * 所有 section.kind 都有渲染器;未知 kind 原样列出键值,不静默吞。
- */
- import { LitElement, css, html } from 'lit';
- import { useTokens } from '../components/base.js';
- import { reportOverview, findingsOf, fmtDate, chapterDigests, reportTypeOf } from '../core/index.js';
- import { reportCategoryOf } from '../core/report-delivery.js';
- import { reportAttachments } from '../core/report-attachments.js';
- import { navigate, href, safeReturnTo } from '../utils/router.js';
- import { t, VIEW_CSS } from './shared.js';
- import { icon } from '../components/icons.js';
- export class ViewReport extends LitElement {
- static properties = {
- db: { type: Object },
- artifactId: { type: String },
- routeQuery: { type: Object },
- _active: { state: true },
- };
- #r = null;
- #type = null;
- #resize = null;
- #chapterFrame = null;
- #smoothJump = false;
- #lastChapterTarget = '';
- #chapterVersion = 0;
- constructor() {
- super();
- this.db = null;
- this.artifactId = '';
- this.routeQuery = {};
- this._active = '';
- }
- connectedCallback() {
- super.connectedCallback();
- useTokens(this);
- }
- disconnectedCallback() {
- super.disconnectedCallback();
- this.#io?.disconnect();
- this.#resize?.disconnect();
- cancelAnimationFrame(this.#chapterFrame);
- this.#chapterVersion++;
- }
- #io = null;
- willUpdate(changed) {
- if (changed.has('artifactId')) this._active = '';
- }
- updated(changed) {
- if (!this.db) return;
- if (!this.#resize && typeof ResizeObserver !== 'undefined') {
- this.#resize = new ResizeObserver(() => this.#measureOffsets());
- const toc = this.renderRoot.querySelector('[data-toc]');
- if (toc) this.#resize.observe(toc);
- }
- if (changed.has('artifactId') || changed.has('db')) this.#measureOffsets();
- const chapter = String(this.routeQuery?.ch || '').replace(/^ch-/, '');
- const target = chapter ? `${this.artifactId}:${chapter}` : '';
- if (chapter && target !== this.#lastChapterTarget) {
- const smooth = this.#smoothJump;
- this.#smoothJump = false;
- this.#restoreChapter(`ch-${chapter}`, smooth ? 'smooth' : 'instant');
- } else if (target !== this.#lastChapterTarget) {
- this.#chapterVersion++;
- cancelAnimationFrame(this.#chapterFrame);
- }
- this.#lastChapterTarget = target;
- }
- #measureOffsets() {
- const top = window.matchMedia('(max-width: 767px)').matches ? 0 : 12;
- const tocHeight = Math.ceil(this.renderRoot.querySelector('[data-toc]')?.getBoundingClientRect().height || 44);
- this.style.setProperty('--report-topbar', `${top}px`);
- this.style.setProperty('--report-anchor', `${top + tocHeight + 16}px`);
- this.#observeChapters(top + tocHeight + 8);
- }
- async #restoreChapter(id, behavior) {
- const version = ++this.#chapterVersion;
- // 卡片的 Shadow DOM 和字体都会改变章节位置,等待实际布局稳定后再恢复锚点。
- await Promise.all([...this.renderRoot.querySelectorAll('*')].map(el => el.updateComplete));
- if (document.fonts) await document.fonts.ready;
- if (!this.isConnected || version !== this.#chapterVersion) return;
- cancelAnimationFrame(this.#chapterFrame);
- this.#chapterFrame = requestAnimationFrame(() => {
- this.#measureOffsets();
- this.#chapterFrame = requestAnimationFrame(() => {
- if (version === this.#chapterVersion) this.#scrollChapter(id, behavior);
- });
- });
- }
- #observeChapters(offset) {
- this.#io?.disconnect();
- // 滚动高亮是增强能力:环境不支持 IntersectionObserver 时整页照常可读,只是不自动高亮
- if (typeof IntersectionObserver === 'undefined') return;
- const root = this.renderRoot;
- const chapters = [...root.querySelectorAll('article.ch')];
- this.#io = new IntersectionObserver((entries) => {
- const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
- if (visible[0]) this._active = visible[0].target.id;
- }, { rootMargin: `-${offset}px 0px -45% 0px`, threshold: 0 });
- chapters.forEach((c) => this.#io.observe(c));
- }
- static styles = [VIEW_CSS, css`
- .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}
- /* kf-glass-card 的插槽内容必须包一层块容器:自定义元素宿主是 display:block,直接排多个块级子元素会和卡片的行盒互相叠 */
- .cover { --kf-card-pad: 24px; height: auto; margin-bottom: 20px; }
- .cover-in { display: grid; gap: 16px; min-width: 0; }
- .cover:has(.dgrid) .cover-in { gap: 12px; }
- .rail .r-in { display: grid; gap: 8px; min-width: 0; }
- .cover .top { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
- .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; }
- .cover .sub { font-size: 14px; line-height: 1.6; color: var(--kf-ink-2); }
- .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; }
- .cover .pipe { padding: 16px 0 0; border-top: 1px solid var(--kf-line); }
- .exec { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; counter-reset: e; }
- .exec li { display: grid; grid-template-columns: 34px 1fr; gap: 10px; font-size: 14px; color: var(--kf-ink); line-height: 1.6; }
- .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; }
- /* 章节目录 = 上层玻璃(.72 + blur 32),和正文卡片拉开层次 */
- .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;
- 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; }
- .dgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); gap: 10px; }
- a.d { display: grid; grid-template-columns: 32px minmax(0,1fr); gap: 2px 8px; padding: 12px; border-radius: var(--r-sm); text-decoration: none;
- background: rgba(255,255,255,0.4); border: 1px solid var(--kf-line); color: inherit; transition: var(--kf-t); }
- a.d:hover { background: rgba(255,255,255,0.7); box-shadow: var(--kf-shadow-chip); }
- 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; }
- a.d .dt { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
- 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; }
- .toc a.jump, aside.rail a.jump { flex: none; font-size: 14px; color: var(--kf-ink-2); text-decoration: none;
- padding: 10px 12px; border-radius: var(--r-sm); display: inline-block; line-height: 1.4; transition: background var(--kf-dur) var(--kf-ease); }
- .toc a.jump { white-space: nowrap; font-weight: 500; }
- .toc a.jump:hover, aside.rail a.jump:hover { background: var(--kf-surface-btn); color: var(--kf-accent-ink); }
- .toc a.jump[aria-current="true"] { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
- aside.rail ol li + li { margin-top: 2px; }
- aside.rail ol { list-style: none; padding-left: 0; margin: 6px 0 0; }
- .layout { display: grid; grid-template-columns: minmax(0, 1fr) 264px; gap: 16px; align-items: start; }
- /* 列容器用块布局:kf-glass-card 是自定义元素,在 grid/flex 里会被当成 flex item 参与拉伸 */
- /* 视图宿主与列容器显式 block:否则自定义元素被排成行盒,块级子元素互相叠、互相撑高 */
- :host { display: block; }
- @media (prefers-reduced-motion: no-preference) {
- :host { animation: report-appear 160ms var(--kf-ease-out); }
- @keyframes report-appear { from { opacity: .65; } to { opacity: 1; } }
- }
- .col, .main-col { display: block; min-width: 0; }
- article.ch { padding: 24px; margin-bottom: 20px; scroll-margin-top: var(--report-anchor, 84px); min-width: 0; box-sizing: border-box; overflow-wrap: anywhere; }
- .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); }
- .ch-h .en { grid-column: 1 / -1; }
- .ch-h .en:empty { display: none; }
- .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; }
- .ch-h h2 { margin: 0; font-size: 20px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
- .en { font-size: 12px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: 600; text-transform: uppercase; }
- /* 摘要块:6% 染色底 + 1px 同色半透明描边(v11 反例 #2:不用 3px 彩色左边栏) */
- .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);
- 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); }
- .lead b { color: var(--kf-accent-ink); font-weight: 600; }
- .sec { padding: 12px 0; border-top: 1px solid var(--kf-line); }
- .sec:first-of-type { border-top: 0; }
- .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; }
- .sec h4 .no { font-family: var(--kf-font-num); font-size: 12px; color: var(--kf-ink-3); }
- p.para { margin: 0 0 12px; font-size: 14px; line-height: 1.85; color: var(--kf-ink-2); max-width: 72ch; }
- .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(152px, 1fr)); gap: 12px; }
- .tbl-wrap { overflow-x: auto; border: 1px solid var(--kf-line); border-radius: var(--r-md); }
- table.rt { width: 100%; border-collapse: collapse; font-size: 14px; }
- .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; }
- .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; }
- .rt tr:last-child td { border-bottom: 0; }
- .rt .k { color: var(--kf-ink); font-weight: 600; }
- .funnel { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
- .funnel li { display: grid; grid-template-columns: 44px minmax(0, 1fr) 120px; gap: 10px; align-items: center; font-size: 14px; }
- .bar { height: 8px; border-radius: var(--r-sm); background: rgba(20,36,70,0.07); overflow: hidden; }
- .bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--kf-s1), var(--kf-s2)); }
- .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; }
- .finding h5 { margin: 0; font-size: 14px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); }
- .finding p { margin: 4px 0 8px; font-size: 14px; line-height: 1.6; color: var(--kf-ink-2); }
- .ev { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 12px; color: var(--kf-ink-3); }
- .ev b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
- .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);
- 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); }
- .anchor:hover { background: rgba(91, 91, 214, 0.10); border-color: rgba(91, 91, 214, 0.45); }
- .two { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 12px; }
- .two > div { padding: 12px; border-radius: var(--r-sm); border: 1px solid var(--kf-line); font-size: 14px; line-height: 1.6; }
- .two .yes { background: rgba(31,107,69,0.06); border-color: rgba(31,107,69,0.25); }
- .two .no { background: rgba(164,36,28,0.05); border-color: rgba(164,36,28,0.22); }
- .two ul { margin: 6px 0 0; padding-left: 18px; }
- aside.rail { position: sticky; top: var(--report-anchor, 84px); display: grid; gap: 12px; min-width: 0; }
- .rail .r { --kf-card-pad: 16px; height: auto; }
- .reading-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 20px; margin: 0 0 16px; font-size: 13px; }
- .reading-nav .report-back { margin-right: auto; font-weight: 600; }
- .reading-nav a { text-decoration: none; padding: 7px 10px; border-radius: var(--r-sm); color: var(--kf-ink-2); transition: var(--kf-t-colors); }
- .reading-nav .report-back { background: var(--kf-surface); border: 1px solid var(--kf-line); }
- .reading-nav a:hover { color: var(--kf-accent-ink); background: var(--kf-accent-soft); }
- .section-label { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
- .cover.digest .kf-kicker { color: var(--kf-ink-3); }
- .rail .r .k { font-size: 12px; color: var(--kf-ink-3); letter-spacing: var(--ls-cn-small); font-weight: 600; }
- .rail ol { margin: 6px 0 0; padding-left: 18px; font-size: 14px; color: var(--kf-ink-2); display: grid; gap: 4px; }
- .rail ol a { color: var(--kf-ink); text-decoration: none; border-bottom: 1px dashed var(--kf-line-strong); }
- .rail .stat { display: flex; justify-content: space-between; font-size: 14px; padding: 3px 0; color: var(--kf-ink-2); }
- .chain { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
- .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); }
- .chain b { font: 600 14px/1.1 var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
- .chain i { font-style: normal; font-size: 12px; line-height: 1.3; color: var(--kf-accent-ink); text-align: center; }
- .rail .stat b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
- .muted2 { font-size: 12px; color: var(--kf-ink-3); }
- @media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } aside.rail { position: static; } }
- /* 手机(任务书 1.3 / 2.4):阅读稿单栏;章节导航吸在视口上方、左右通到屏幕边;封面标题 26→20;来源收成两行 */
- @media (max-width: 767px) {
- .toc[data-toc] { margin: 0 0 16px; padding: 4px; scrollbar-width: none; }
- .toc::-webkit-scrollbar { display: none; }
- .toc a.jump { font-size: 14px; padding: 10px 12px; }
- .cover { --kf-card-pad: 16px; margin-bottom: 16px; }
- article.ch { padding: 18px 16px; margin-bottom: 16px; }
- .ch-h h2 { font-size: 18px; }
- .reading-nav { gap: 4px 16px; }
- .reading-nav .report-back { flex: 1 1 100%; }
- .funnel li { grid-template-columns: 36px minmax(0, 1fr); }
- .funnel li > :last-child { grid-column: 2; }
- .cover .top { row-gap: 6px; }
- .cover .top > span:last-child { margin-left: 0 !important; width: 100%; justify-content: flex-start; }
- /* 类型名已在顶栏下方标题区,封面不再重复(任务书 0.5:同一信息在 tab / 标题 / 数据卡各出现一次) */
- .cover .typeb { display: none; }
- .cover h1 { font-size: 20px; line-height: 1.35; font-weight: 600; }
- .cover .sub { font-size: 14px; line-height: 1.55; }
- .cover .meta { font-size: 14px; gap: 12px; }
- .ch-no, .ch-h h2 { line-height: 1.4; }
- .art p, .ch-body p, .toc ~ * p { font-size: 14px; line-height: 1.7; }
- }
- `];
- #jump(id) {
- // 应用内路径 + 相对章节 id(ch3 而不是 ch-ch3)
- const key = id.replace(/^ch-/, '');
- if (String(this.routeQuery?.ch || '').replace(/^ch-/, '') === key) return this.#scrollChapter(id, 'smooth');
- this.#smoothJump = true;
- // 当前章写回 URL(?ch=3),刷新时还原到同一章;只改查询串,不新增历史
- const q = new URLSearchParams(this.routeQuery || {});
- q.set('ch', key);
- navigate(`/report/${encodeURIComponent(this.artifactId)}?${q.toString()}`, { replace: true });
- }
- #scrollChapter(id, behavior) {
- const el = this.renderRoot.querySelector(`#${CSS.escape(id)}`);
- if (!el) return;
- this._active = id;
- el.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : behavior, block: 'start' });
- }
- get typeInfo() { return this.#type; }
- #digest(d) {
- return html`<a class="d" href=${`#ch-${d.id}`} data-digest-ch=${d.id} @click=${(e) => { e.preventDefault(); this.#jump(`ch-${d.id}`); }}>
- <span class="dn">${String(d.no).padStart(2, '0')}</span>
- <span class="dt">${d.title}</span>
- <span class="ds">${d.summary || '这一章没有可抽取的句子,点到章内看原文。'}</span>
- </a>`;
- }
- #openEv(evId) {
- this.dispatchEvent(new CustomEvent('evidence-open', { detail: { reportId: this.artifactId, evId }, bubbles: true, composed: true }));
- }
- #artifact(id) {
- this.dispatchEvent(new CustomEvent('artifact-open', { detail: { id }, bubbles: true, composed: true }));
- }
- #anchor(ev, label) {
- if (!ev) return '';
- const e = this.#r?.report.evidence?.[ev];
- if (!e) return '';
- const n = (e.quotes || []).length;
- return html`<button class="anchor" data-anchor=${ev} @click=${() => this.#openEv(ev)}>${icon('link', 12)} ${label || '证据链'}${n ? html` · ${n} 条原文` : ''}</button>`;
- }
- render() {
- const db = this.db;
- if (!db) return html`<kf-empty kind="loading" ></kf-empty>`;
- const r = reportOverview(db, this.artifactId);
- this.#r = r;
- if (!r) {
- return html`<kf-empty kind="error" title="这份报告不存在或没有正文" hint=${`成果 ${this.artifactId} 暂无可读取的报告正文或原文;不会用摘要补造报告。`}
- action-label="返回" @empty-action=${() => navigate(safeReturnTo(this.routeQuery?.returnTo, '/reports'))}></kf-empty>`;
- }
- const findings = findingsOf(db, this.artifactId);
- const report = r.report;
- const attachments = reportAttachments(r.artifact, ['https://kangshifu.fmode.cn', location.origin]);
- const digests = chapterDigests(db, this.artifactId);
- this.#type = reportTypeOf(reportCategoryOf(r.artifact));
- if (!r.executive?.length) {
- // 执行摘要缺失时:用章节摘要替身,但明确标注来源,不冒充
- r.executive = digests.slice(0, 3).map((d) => `〔${d.title}〕${d.summary || ''}`);
- }
- const active = this._active || (this.routeQuery?.ch ? `ch-${String(this.routeQuery.ch).replace(/^ch-/, '')}` : '');
- const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
- return html`
- <nav class="reading-nav" aria-label="报告导航">
- <a class="report-back" href=${href(back)} data-link data-back>${back.startsWith('/pool/') ? '← 返回机会卡' : '← 返回列表'}</a>
- ${this.typeInfo ? html`<a href=${href('/reports/' + this.typeInfo.key)} data-link>${this.typeInfo.name}</a>` : ''}
- <a href=${href('/reports')} data-link>报告中心</a>
- </nav>
- <kf-glass-card surface="2" class="cover"><div class="cover-in">
- <div class="top">
- <kf-badge kind="stage" .value=${r.stages[0] || '—'} label=${r.stages.join(' · ') || '跨阶段'}></kf-badge>
- <kf-badge kind="plain" .value=${`${r.artifact.ownerName || '无出身'} · ${r.artifact.upload.methodLabel || '方式未记录'}`}></kf-badge>
- ${r.artifact.upload.agentName ? html`<kf-badge kind="method" .value=${r.artifact.upload.agentName}></kf-badge>` : ''}
- ${this.typeInfo ? html`<kf-badge class="typeb" kind="method" .value=${this.typeInfo.name} label=${'类型:' + this.typeInfo.name}></kf-badge>` : ''}
- </div>
- <div>
- <h1>${r.title}</h1>
- <div class="sub" style="margin-top:6px">${r.subtitle}</div>
- </div>
- <div class="meta">
- <span>${fmtDate(r.generatedAt, true)}</span><span>${t(`${r.quoteCount} 原文`)}</span><span>${t(`${r.refsInLib}/${r.refCount} 支撑`)}</span>
- </div>
- ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
- </div></kf-glass-card>
- <div class="toc kf-glass is-top is-overlay" aria-label="章节导航" data-toc>
- ${r.chapters.map((c) => html`<a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
- @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.no}. ${c.title}</a>`)}
- </div>
- <div class="main-col">
- <div class="layout">
- <div class="col">
- ${r.executive.length ? html`<kf-glass-card surface="2" class="cover exec-card" style="margin-bottom:16px" data-exec><div class="cover-in">
- <div class="kf-kicker">${report.executive?.length ? '报告摘要' : '章节摘要'}</div>
- <ol class="exec" data-exec-list>${r.executive.map((x, i) => html`<li data-exec-item=${String(i + 1)}>${x}</li>`)}</ol>
- </div></kf-glass-card>` : ''}
- <kf-glass-card surface="1" class="cover digest" style="margin-bottom:16px" data-digest><div class="cover-in">
- <div class="section-label">章节导读</div><div class="dgrid">${digests.map((d) => this.#digest(d))}</div>
- </div></kf-glass-card>
- ${r.chapters.map((c) => html`<article class="ch kf-card" id="ch-${c.id}" data-chapter=${c.id}>
- <div class="ch-h"><span class="en">${c.en}</span><span class="ch-no">${c.no}</span><h2>${c.title}</h2></div>
- ${c.subtitle ? html`<div class="muted2">${c.subtitle}</div>` : ''}
- ${c.lead ? html`<div class="lead">${c.lead}</div>` : ''}
- ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">${t(`对应阶段 ${c.stage.code} ${c.stage.name}`)}</div>` : ''}
- ${c.sections.map((s) => html`<section class="sec" id=${s.id || ''}>${this.#section(s, db)}</section>`)}
- </article>`)}
- ${attachments.length ? html`<article class="ch kf-card" data-report-attachments>
- <h2>报告文件与示意图</h2>
- ${attachments.map(file => html`<section class="file-entry">
- <h4>${file.name}</h4><p class="muted2">${Math.ceil(file.sizeBytes / 1024)} KB</p>
- ${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>` : ''}
- ${file.document ? html`<details><summary>预览文件</summary><iframe class="file-preview" sandbox="" title=${file.name} loading="lazy" src=${file.previewUrl}></iframe></details>` : ''}
- <p><a href=${file.url} target="_blank" rel="noopener noreferrer">下载文件</a></p>
- </section>`)}
- </article>` : ''}
- <kf-glass-card surface="2" class="cover" style="margin-top:8px"><div class="cover-in">
- <div class="section-label">证据对照</div>
- <div class="tbl-wrap"><table class="rt">
- <thead><tr><th>结论</th><th>人</th><th>原文</th><th>成果</th><th></th></tr></thead>
- <tbody>${findings.map((f) => html`<tr>
- <td class="k">${f.conclusion}</td>
- <td>${f.ownerCount ?? '—'}</td><td>${f.quoteCount}</td><td>${f.derivedCount}</td>
- <td>${this.#anchor(f.evId, '证据链')}</td></tr>`)}</tbody>
- </table></div>
- </div></kf-glass-card>
- </div>
- <aside class="rail">
- <kf-glass-card surface="1" class="r"><div class="r-in">
- <div class="chain">
- <div title="结论"><b>${r.conclusionCount}</b><i>结论</i></div>
- <div title="带原文的结论"><b>${r.verifiedConclusions}</b><i>带原文</i></div>
- <div title="支撑成果"><b>${r.refsInLib}/${r.refCount}</b><i>支撑</i></div>
- <div title="原文片段"><b>${r.quoteCount}</b><i>引文</i></div>
- </div>
- </div></kf-glass-card>
- <kf-glass-card surface="1" class="r"><div class="r-in">
- <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'}
- @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.title}</a></li>`)}</ol>
- </div></kf-glass-card>
- <kf-glass-card surface="1" class="r"><div class="r-in">
- <button class="kf-btn" @click=${() => this.#artifact(this.artifactId)}>证据链</button>
- </div></kf-glass-card>
- </aside>
- </div>
- </div>
- `;
- }
- /** section.kind → 渲染(未知 kind 原样列出,不吞) */
- #section(s) {
- const title = html`${s.no ? html`<span class="no">${s.no}</span>` : ''}${s.title || ''}`;
- switch (s.kind) {
- case 'para':
- return html`<h4>${title}</h4><p class="para">${s.text}</p>`;
- case 'longpara':
- return html`<h4>${title}</h4>${String(s.text || '').split(/\n{2,}/).filter(Boolean).map((p) => html`<p class="para">${p}</p>`)}`;
- case 'funnel':
- return html`<h4>${title}</h4><ol class="funnel">${(s.data || []).map((x) => {
- const max = Math.max(...(s.data || []).map((d) => Number(d.value) || 0), 1);
- const w = Math.max(12, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
- return html`<li><span class="ch-no">${x.stage}</span>
- <span><b class="kf-title">${x.name}</b> <span class="muted2">${x.scope}</span><div class="bar"><i style="width:${w}%"></i></div></span>
- <span class="kf-num"><b>${Number(x.value).toLocaleString('zh-CN')}</b> <span class="muted2">${x.unit || ''}</span></span></li>`;
- })}</ol>`;
- case 'findings':
- return html`<h4>${title}</h4>${(s.data || []).map((f) => {
- const e = this.#r.report.evidence?.[f.evidence] || {};
- return html`<div class="finding" data-finding=${f.id || ''}>
- <h5>${f.conclusion}</h5><p>${f.attitude}</p>
- <div class="ev">
- ${e.ownerCount != null ? html`<span><b>${e.ownerCount}</b> 人独立提到</span>` : ''}
- <span><b>${(e.quotes || []).length}</b> 条原文</span>
- <span><b>${(e.derivedFrom || []).filter(Boolean).length}</b> 条支撑成果</span>
- ${this.#anchor(f.evidence, '看证据链')}
- </div>
- </div>`;
- })}`;
- case 'matrix':
- return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
- <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
- <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>`;
- case 'table':
- return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
- <thead><tr>${(s.data?.cols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
- <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>`;
- case 'two':
- return html`<h4>${title}</h4><div class="two">
- <div class="yes"><b>能做到的</b><ul>${(s.data?.can || []).map((x) => html`<li>${x}</li>`)}</ul></div>
- <div class="no"><b>做不到的(红线)</b><ul>${(s.data?.cannot || []).map((x) => html`<li>${x}</li>`)}</ul></div></div>`;
- case 'fit':
- return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
- <thead><tr>${(s.data?.transferCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
- <tbody>${(s.data?.transfer || []).map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>
- ${(s.data?.redline || []).length ? html`<div class="tbl-wrap" style="margin-top:8px"><table class="rt">
- <thead><tr>${(s.data.redlineCols || []).map((c) => html`<th>${c}</th>`)}</tr></thead>
- <tbody>${s.data.redline.map((row) => html`<tr>${row.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>` : ''}`;
- case 'score':
- return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt">
- <thead><tr><th>机会</th><th>需求</th><th>竞争</th><th>我方能力</th><th>总分</th><th>结论</th></tr></thead>
- <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>
- <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>`;
- case 'ladder': {
- const d = s.data || {};
- 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>
- ${d.statement ? html`<p class="para" style="margin-top:10px;font-size: 16px"><b>一句话定位:</b>「${d.statement}」</p>` : ''}
- ${d.for_who ? html`<div class="muted2">${t(`写给谁:${d.for_who}`)}</div>` : ''}`;
- }
- case 'statement':
- return html`${(s.data?.alt || []).length ? html`<h4>${title}</h4><ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;
- case 'milestones':
- return html`<h4>${title}</h4><div class="tbl-wrap"><table class="rt"><tbody>${(s.data || []).map(([m, name, checks, rule]) => html`<tr>
- <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>
- <td class="muted2">决策规则:${rule}</td></tr>`)}</tbody></table></div>`;
- default: {
- const flat = (v) => (v && typeof v === 'object' ? JSON.stringify(v) : String(v ?? ''));
- return html`<h4>${title}</h4><div class="muted2">未知的结构类型「${s.kind}」,原始数据:</div>
- <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>`;
- }
- }
- }
- /** 单元格:字符串若是 evidence 键就渲染为结论 + 锚点;数组按行展示 */
- #cell(cell) {
- const ev = this.#r?.report?.evidence || {};
- if (Array.isArray(cell) && cell.every((x) => typeof x === 'string')) return cell.map((x) => html`<div>${x}</div>`);
- if (Array.isArray(cell)) return cell.map(([name, pct]) => html`<div>${name} <b class="kf-num">${pct}%</b></div>`);
- if (typeof cell === 'string' && ev[cell]) return html`<div>${ev[cell].claim || cell}</div>${this.#anchor(cell, '原文')}`;
- if (typeof cell === 'number') return html`<span class="kf-num">${cell.toLocaleString('zh-CN')}</span>`;
- return cell ?? '—';
- }
- }
- if (!customElements.get('view-report')) customElements.define('view-report', ViewReport);
|