| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556 |
- /**
- * pages/view-report.js —— <view-report> 报告阅读页(保留 v3 能力)。
- *
- * - 吸顶章节导航 + 滚动高亮(IntersectionObserver)+ 平滑跳转,当前章写回 ?ch=,刷新还原
- * - 每章开头一句「这章在回答什么问题」
- * - 数据管线可见(2,727 采集 → 1,190 去噪 → 730 精准,过滤率 73%)
- * - KPI、交叉分析表、敢下判断的结论
- * - 结论旁的证据锚点 → 证据抽屉(N 人提到 / N 条原文 / N 条支撑成果,点名到人)
- * - 附录:所有结论 × 证据对照总表;「关联成果」区显示引用了本报告的归纳(反向)
- *
- * 所有 section.kind 都有渲染器;未知 kind 原样列出键值,不静默吞。
- */
- import { LitElement, html } from 'lit';
- import { keyed } from 'lit/directives/keyed.js';
- import { useTokens } from '../components/base.js';
- import { reportOverview, reportExecutive, findingsOf, fmtDate, chapterDigests, reportTypeOf, traceArtifact } from '../core/index.js';
- import { reportCategoryOf, reportBodyStatus } from '../core/report-delivery.js';
- import { reportAttachments } from '../core/report-attachments.js';
- import { evidenceExcerpts } from '../core/report-evidence.js';
- import { opportunitiesForReport, reportOpportunity } from '../core/opportunity/report.js';
- import '../components/kf-score-panel.js';
- import '../components/kf-evidence-chain.js';
- import { navigate, href, safeReturnTo, reportUrl, currentUrl } from '../utils/router.js';
- import { t, VIEW_CSS } from './shared.js';
- import { REPORT_CSS } from './report-styles.js';
- import { icon } from '../components/icons.js';
- export class ViewReport extends LitElement {
- static properties = {
- db: { type: Object },
- artifactId: { type: String },
- routeQuery: { type: Object },
- opportunityId: { type: String },
- me: { type: String },
- _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.opportunityId = '';
- this.me = '';
- 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, REPORT_CSS];
- #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);
- if (this.opportunityId) q.set('opportunity', this.opportunityId);
- 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) {
- navigate(reportUrl(id, currentUrl()));
- }
- #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>`;
- }
- #excerpts(evidenceId, limit = Infinity) {
- const all = evidenceExcerpts(this.#r?.report, evidenceId, this.db?.sourceById);
- if (!all.length) return html`<p class="evidence-note">未保存可展示的原句,请核对正文依据与来源;不根据摘要补造引文。</p>`;
- return html`<div class="evidence-excerpts" data-evidence-excerpts=${evidenceId}>
- ${all.slice(0, limit).map(quote => html`<figure class="evidence-excerpt">
- <figcaption class="evidence-caption">
- <span class="evidence-kicker">引用来源</span>
- <span class="evidence-source">${quote.sourceTitle || quote.ref || '来源名称未记录'}</span>
- ${quote.author ? html`<span class="evidence-author">作者:${quote.author}</span>` : ''}
- </figcaption>
- <blockquote>${quote.text}</blockquote>
- ${quote.url || quote.sourceId || quote.ref ? html`<div class="evidence-footer">
- <div class="evidence-actions">
- ${quote.url ? html`<a href=${quote.url} target="_blank" rel="noopener noreferrer">打开来源链接 ↗</a>` : ''}
- ${quote.sourceAvailable ? html`<button @click=${() => this.dispatchEvent(new CustomEvent('source-open', { detail: { id: quote.sourceId }, bubbles:true, composed:true }))}>查看来源记录</button>` : ''}
- </div>
- ${quote.ref || quote.sourceId && !quote.sourceAvailable ? html`<details class="source-meta">
- <summary>${icon('chev', 12)}出处与定位</summary>
- ${quote.ref ? html`<p>${quote.ref}</p>` : ''}
- ${quote.sourceId && !quote.sourceAvailable ? html`<p>来源编号:${quote.sourceId}(本页未载入原件)</p>` : ''}
- </details>` : ''}
- </div>` : ''}
- </figure>`)}
- ${all.length > limit ? html`<span class="evidence-note">另有 ${all.length - limit} 条已保存引文,可在下方“判断与原始依据”或证据链查看。</span>` : ''}
- </div>`;
- }
- #selectedOpportunity() {
- return this.artifactId ? reportOpportunity(this.db, this.artifactId, this.opportunityId)
- : this.db.opportunityById?.[this.opportunityId];
- }
- #rating() {
- const linked = this.artifactId ? opportunitiesForReport(this.db, this.artifactId) : [];
- const selected = this.#selectedOpportunity();
- if (!linked.length && !selected && !this.opportunityId) return '';
- return html`<kf-glass-card surface="1" class="r rating-card" id="opportunity-rating" data-opportunity-rating><section class="r-in">
- <h2 class="rating-title">多人评分</h2>
- ${linked.length > 1 || (this.artifactId && this.opportunityId && !selected) ? html`
- <label class="rating-label" for="rating-opportunity">选择评分与决策的机会</label>
- <select id="rating-opportunity" aria-label="评分与决策机会" @change=${e => {
- const q = new URLSearchParams(this.routeQuery || {});
- q.set('opportunity', e.target.value);
- navigate(`/report/${encodeURIComponent(this.artifactId)}?${q}`, { replace: true });
- }}><option value="" disabled .selected=${!selected}>请选择机会卡</option>
- ${linked.map(o => html`<option value=${o.id} .selected=${o.id === selected?.id}>${o.title}</option>`)}
- </select>` : ''}
- ${selected ? html`<p class="rating-target">${selected.title}</p>
- ${keyed(selected.id, html`<kf-score-panel compact .opportunityId=${selected.id} .stats=${selected.stats} .user=${this.me}></kf-score-panel>`)}`
- : html`<p class="evidence-note">${this.opportunityId ? '当前机会与这份报告没有有效关联,请重新选择后评分和决策。' : '这份报告关联多个机会,请先选择要评分和决策的机会。'}</p>`}
- </section></kf-glass-card>`;
- }
- #decision() {
- const selected = this.#selectedOpportunity();
- if (!selected) return '';
- const decision = selected.decision;
- const history = selected.decisionLog || [];
- return html`<kf-glass-card surface="1" class="r decision-card" data-opportunity-decision=${selected.id}><section class="r-in">
- <div class="decision-heading"><h2 class="rating-title">机会决策</h2><kf-badge kind="status" .value=${selected.status}></kf-badge></div>
- <p class="rating-target">${selected.title}</p>
- <div class="decision-actions" role="group" aria-label="机会决策">
- ${[['candidate', '候选'], ['approved', '立项'], ['rejected', '否决']].map(([status, label]) => html`
- <button type="button" class=${status} aria-pressed=${selected.status === status ? 'true' : 'false'}
- @click=${() => this.dispatchEvent(new CustomEvent('status-change', { detail: { id: selected.id, status }, bubbles: true, composed: true }))}>${label}</button>`)}
- </div>
- <section class="decision-summary"><h3>决策说明</h3>
- ${decision ? html`<p class="decision-label">${decision.label}</p>
- ${decision.reason ? html`<p class="decision-reason">${decision.reason}</p>` : html`<p class="evidence-note">未填写决策说明。</p>`}
- <p class="decision-meta">${decision.by || '未署名'} · ${fmtDate(decision.at, true) || '时间未记录'}</p>`
- : html`<p class="evidence-note">${selected.status === 'candidate' ? '尚无决策,等待评估。' : '尚无决策说明,等待补充记录。'}</p>`}
- </section>
- <details class="decision-history">
- <summary>决策记录 ${history.length} 条</summary>
- ${history.length ? html`<ol>${[...history].reverse().map(item => html`<li>
- <p class="decision-label">${item.label}</p>${item.reason ? html`<p class="decision-reason">${item.reason}</p>` : ''}
- <p class="decision-meta">${item.by || '未署名'} · ${fmtDate(item.at, true) || '时间未记录'}</p>
- </li>`)}</ol>` : html`<p class="evidence-note">暂无决策记录。</p>`}
- </details>
- </section></kf-glass-card>`;
- }
- #ratingJump() {
- if (!this.opportunityId && !opportunitiesForReport(this.db, this.artifactId).length) return '';
- return html`<a href="#opportunity-rating" @click=${e => {
- e.preventDefault();
- this.renderRoot.querySelector('#opportunity-rating')?.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'start' });
- }}>多人评分 ↓</a>`;
- }
- #pendingReport() {
- const opportunity = this.db.opportunityById?.[this.opportunityId];
- if (!opportunity) return html`<kf-empty kind="error" title="未找到这张机会卡" action-label="返回机会卡池" @empty-action=${() => navigate('/pool')}></kf-empty>`;
- const back = safeReturnTo(this.routeQuery?.returnTo, '/pool');
- return html`<nav class="reading-nav" aria-label="报告导航"><a href=${href(back)} data-link data-back>← 返回${back.startsWith('/pool') ? '机会卡池' : '列表'}</a>${this.#ratingJump()}</nav>
- <kf-glass-card surface="2" class="cover"><div class="cover-in"><h1>${opportunity.title}</h1><p class="sub">暂无完整报告</p></div></kf-glass-card>
- <div class="layout"><div class="col">
- <article class="ch kf-card" data-report-pending><h2>报告尚未收录</h2><p class="para">这张机会卡尚未关联可读取的报告正文。以下保留已提交的机会说明与来源,可先阅读并参与评分和决策。</p>
- ${opportunity.oneLiner ? html`<section class="sec"><h4>机会说明</h4><p class="para">${opportunity.oneLiner}</p></section>` : ''}
- <section class="sec"><h4>提交来源</h4><p class="para">${opportunity.upload.uploadedBy || '提交人未记录'} · ${opportunity.upload.agentName || '数字人未记录'} · ${opportunity.upload.methodLabel || '方式未记录'} · ${fmtDate(opportunity.upload.uploadedAt, true) || '时间未记录'}</p></section>
- </article></div>
- <aside class="rail with-rating"><kf-glass-card surface="1" class="r"><div class="r-in"><h2 class="rating-title">证据链</h2>
- ${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>`)
- : html`<p class="evidence-note">尚未关联成果来源。</p>`}
- </div></kf-glass-card>${this.#rating()}${this.#decision()}</aside>
- </div>`;
- }
- #reportInfo(artifact) {
- const fields = [
- ['提交人', artifact.ownerName || '未记录'],
- ['报告分类', reportTypeOf(reportCategoryOf(artifact))?.name || '未归类'],
- ['阶段', artifact.stageObj?.label || artifact.stage],
- ['证据', artifact.evidenceLabel],
- ['提交方式', artifact.upload?.methodLabel],
- ['数字人', artifact.upload?.agentName],
- ['提交时间', fmtDate(artifact.upload?.uploadedAt || artifact.createdAt, true)],
- ['研究课题', artifact.workshopKey],
- ['版本', artifact.version ? `V${artifact.version}` : ''],
- ['资料范围', ({ live: '真实资料', sample: '样例', manual: '人工提供' })[artifact.dataMode]],
- ].filter(([, value]) => value && value !== '—');
- return html`<kf-glass-card surface="1" class="r"><section class="r-in report-info" data-report-info>
- <h2 class="rating-title">报告信息</h2>
- <dl>${fields.map(([label, value]) => html`<div><dt>${label}</dt><dd>${value}</dd></div>`)}</dl>
- <details class="report-ids"><summary>资料编号</summary><code>${artifact.id}</code></details>
- </section></kf-glass-card>`;
- }
- #reportRelations(artifact) {
- return html`<kf-glass-card surface="2" class="cover"><section class="cover-in" data-report-relations>
- <h2 class="section-label">引用与关联</h2>
- <kf-evidence-chain embedded mode="artifact" .chain=${traceArtifact(this.db, artifact.id)}></kf-evidence-chain>
- ${artifact.rawText && reportBodyStatus(artifact) === 'report' ? html`<details class="saved-original"><summary>已保存原文</summary><p>${artifact.rawText}</p></details>` : ''}
- </section></kf-glass-card>`;
- }
- #reportFiles(artifact) {
- const attachments = reportAttachments(artifact, ['https://kangshifu.fmode.cn', location.origin]);
- return attachments.length ? html`<article class="ch kf-card" data-report-attachments>
- <h2 class="section-label">报告文件与示意图</h2>
- <div class="attachment-grid">${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>`)}</div>
- </article>` : '';
- }
- #registeredReport(artifact) {
- const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
- const summary = reportExecutive(artifact.report);
- this.#type = reportTypeOf(reportCategoryOf(artifact));
- return html`<nav class="reading-nav" aria-label="报告导航">
- <a class="report-back" href=${href(back)} data-link data-back>← 返回${back.split('?')[0] === '/workshop' ? '小组成果' : '列表'}</a>
- ${this.#ratingJump()}<a href=${href('/reports')} data-link>报告中心</a>
- </nav>
- <kf-glass-card surface="2" class="cover"><div class="cover-in">
- <div class="top">
- ${artifact.stageObj ? html`<kf-badge kind="stage" .value=${artifact.stageObj.code} label=${artifact.stageObj.label}></kf-badge>` : ''}
- ${this.typeInfo ? html`<kf-badge kind="plain" .value=${this.typeInfo.name}></kf-badge>` : ''}
- </div>
- <h1>${artifact.report?.title || artifact.title}</h1>
- </div></kf-glass-card>
- <div class="layout"><div class="col">
- <article class="ch kf-card" data-report-body-pending>
- <div class="section-head"><h2 class="section-label">报告正文</h2><span class="body-state">正文待补</span></div>
- ${summary.length ? html`<section class="sec"><h4>已保存摘要</h4>${summary.map(text => html`<p class="para">${text}</p>`)}</section>` : ''}
- ${artifact.quotes?.length ? html`<section class="sec"><h4>已保存引文</h4>${artifact.quotes.map(quote => html`
- <figure class="registered-quote"><blockquote>${quote.text || ''}</blockquote>${quote.author || quote.ref ? html`<figcaption>${[quote.author, quote.ref].filter(Boolean).join(' · ')}</figcaption>` : ''}</figure>`)}
- </section>` : ''}
- </article>
- ${this.#reportFiles(artifact)}${this.#reportRelations(artifact)}
- </div><aside class="rail with-rating">${this.#reportInfo(artifact)}${this.#rating()}${this.#decision()}</aside></div>`;
- }
- render() {
- const db = this.db;
- if (!db) return html`<kf-empty kind="loading" ></kf-empty>`;
- if (!this.artifactId && this.opportunityId) return this.#pendingReport();
- const r = reportOverview(db, this.artifactId);
- this.#r = r;
- if (!r) {
- const artifact = db.artifactById[this.artifactId];
- if (artifact) return this.#registeredReport(artifact);
- return html`<kf-empty kind="error" title="未找到这份报告"
- action-label="返回" @empty-action=${() => navigate(safeReturnTo(this.routeQuery?.returnTo, '/reports'))}></kf-empty>`;
- }
- const findings = findingsOf(db, this.artifactId);
- const report = r.report;
- const digests = chapterDigests(db, this.artifactId);
- this.#type = reportTypeOf(reportCategoryOf(r.artifact));
- const hasExecutive = r.executive.length > 0;
- 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.split('?')[0] === '/workshop' ? '← 返回小组成果' : back.split('?')[0] === '/pool' ? '← 返回机会卡池' : back.startsWith('/pool/') ? '← 返回机会卡' : '← 返回列表'}</a>
- ${this.#ratingJump()}
- ${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" data-exec><div class="cover-in">
- <div class="section-label">${hasExecutive ? '报告摘要' : '章节摘要'}</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" 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>`)}
- ${this.#reportFiles(r.artifact)}
- <kf-glass-card surface="2" class="cover evidence-panel"><div class="cover-in" data-evidence-reading>
- <div class="section-head"><h2 class="section-label">判断与原始依据</h2><span class="section-count">${r.quoteCount} 条引文</span></div>
- <div class="evidence-catalog">${findings.map(f => html`<section class="evidence-group" data-evidence-group=${f.evId}>
- <h3>${f.conclusion}</h3>
- ${this.#excerpts(f.evId)}
- ${this.#anchor(f.evId, '查看引用关系')}
- </section>`)}</div>
- ${!findings.length ? html`<p class="evidence-note">本报告尚未保存可展开的证据关联;请阅读正文中的依据说明。</p>` : ''}
- <details class="evidence-more"><summary>查看引用数量与关联成果</summary>
- <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></details>
- </div></kf-glass-card>
- ${this.#reportRelations(r.artifact)}
- </div>
- <aside class="rail ${this.opportunityId || opportunitiesForReport(db, this.artifactId).length ? 'with-rating' : ''}">
- <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>
- ${this.#reportInfo(r.artifact)}
- ${this.#rating()}
- ${this.#decision()}
- </aside>
- </div>
- </div>
- `;
- }
- /** section.kind → 渲染(未知 kind 原样列出,不吞) */
- #section(s) {
- const title = s.title || s.no ? html`<h4>${s.no ? html`<span class="no">${s.no}</span>` : ''}${s.title || ''}</h4>` : '';
- switch (s.kind) {
- case 'para':
- return html`${title}<p class="para">${s.text}</p>`;
- case 'longpara':
- return html`${title}${String(s.text || '').split(/\n{2,}/).filter(Boolean).map((p) => html`<p class="para">${p}</p>`)}`;
- case 'funnel':
- return html`${title}<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`${title}<div class="findings-grid">${(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>${f.attitude ? html`<p class="finding-explanation">${f.attitude}</p>` : ''}
- ${this.#excerpts(f.evidence, 2)}
- <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>`;
- })}</div>`;
- case 'matrix':
- return html`${title}<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`${title}<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`${title}<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`${title}<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`${title}<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`${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>
- ${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`${title}<ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;
- case 'milestones':
- return html`${title}<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`${title}<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);
|