| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091 |
- import fs from 'node:fs';
- import path from 'node:path';
- import { fileURLToPath } from 'node:url';
- // One content source: render only saved report fields, never generate research findings.
- const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
- const list = value => Array.isArray(value) ? value : [];
- const text = value => value && typeof value === 'object' ? JSON.stringify(value) : String(value ?? '');
- const safeUrl = value => {
- try { const u = new URL(String(value || '')); return ['http:', 'https:'].includes(u.protocol) && !u.username && !u.password ? u.href : ''; }
- catch { return ''; }
- };
- const link = (url, label) => `<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${esc(label)}</a>`;
- const linkedText = value => {
- const input = String(value ?? ''); let output = '', cursor = 0;
- for (const match of input.matchAll(/https?:\/\/[^\s<>"'||;,。]+/g)) {
- output += esc(input.slice(cursor, match.index));
- const url = safeUrl(match[0]); output += url ? link(url, match[0]) : esc(match[0]);
- cursor = match.index + match[0].length;
- }
- return output + esc(input.slice(cursor));
- };
- export function renderReportHtml(task) {
- const report = task?.report;
- if (!report || !Array.isArray(report.chapters) || !report.chapters.length) throw Object.assign(new Error('REPORT_BODY_REQUIRED'), { code: 'REPORT_BODY_REQUIRED' });
- const evidence = report.evidence || {};
- const evidenceIds = new Map(Object.keys(evidence).map((key, i) => [key, `evidence-${i + 1}`]));
- const cell = value => {
- if (Array.isArray(value)) return value.map(v => `<div>${cell(v)}</div>`).join('');
- if (typeof value === 'string' && evidenceIds.has(value)) return `<a href="#${evidenceIds.get(value)}">${esc(evidence[value].claim || value)}</a>`;
- return linkedText(text(value));
- };
- const table = (cols, rows) => `<div class="table-wrap" tabindex="0" role="region" aria-label="对照表,可横向滚动"><table>${list(cols).length ? `<thead><tr>${cols.map(c => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead>` : ''}<tbody>${list(rows).map(row => `<tr>${list(row).map((c, i) => `<${i ? 'td' : 'th scope="row"'}${text(c).length > 70 ? ' class="wide-cell"' : ''}>${cell(c)}</${i ? 'td' : 'th'}>`).join('')}</tr>`).join('')}</tbody></table></div>`;
- const quotes = (entry, limit = Infinity) => list(entry?.quotes).filter(q => q && typeof q.text === 'string' && q.text.trim()).slice(0, limit).map(q => {
- const sourceId = q.sourceId || entry.sourceId;
- const url = safeUrl(q.ref) || safeUrl(String(q.ref || '').match(/https?:\/\/[^\s<>"'||;,。]+/)?.[0]);
- const displayRef = url ? String(q.ref || '').replace(url, '').replace(/[;;\s]+$/, '') : q.ref;
- return `<figure class="quote-card"><figcaption>${esc(q.author || '作者未记录')}${sourceId ? `<span>来源编号 ${esc(sourceId)}</span>` : ''}</figcaption><blockquote>${esc(q.text)}</blockquote><div class="quote-ref">${displayRef ? `<p>${esc(displayRef)}</p>` : ''}${url ? link(url, '查看原始来源 ↗') : '<p>本条未保存可打开的原文链接</p>'}</div></figure>`;
- }).join('');
- const section = s => {
- const title = s.title || s.no ? `<h3>${esc([s.no, s.title].filter(Boolean).join(' · '))}</h3>` : '';
- let body;
- switch (s.kind) {
- case 'para': case 'longpara': body = String(s.text || '').split(/\n{2,}/).filter(Boolean).map(p => `<p>${linkedText(p)}</p>`).join(''); break;
- case 'table': case 'matrix': body = table(s.data?.cols, s.data?.rows); break;
- case 'findings': body = `<div class="findings">${list(s.data).map(f => `<article class="finding"><h4>${esc(f.conclusion)}</h4>${f.attitude ? `<p>${esc(f.attitude)}</p>` : ''}${quotes(evidence[f.evidence], 2)}${evidenceIds.has(f.evidence) ? `<a class="evidence-link" href="#${evidenceIds.get(f.evidence)}">查看该判断的全部已存依据 ↓</a>` : ''}</article>`).join('')}</div>`; break;
- case 'two': body = `<div class="pair"><div><h4>能做到的</h4><ul>${list(s.data?.can).map(x => `<li>${esc(x)}</li>`).join('')}</ul></div><div><h4>做不到的(红线)</h4><ul>${list(s.data?.cannot).map(x => `<li>${esc(x)}</li>`).join('')}</ul></div></div>`; break;
- case 'fit': body = table(s.data?.transferCols, s.data?.transfer) + (list(s.data?.redline).length ? table(s.data?.redlineCols, s.data.redline) : ''); break;
- case 'score': body = table(['机会', '需求', '竞争', '我方能力', '总分', '结论'], s.data); break;
- case 'ladder': body = `<ol class="steps">${list(s.data?.ladder).map(([n, name, note]) => `<li><span>${esc(n)}</span><div><b>${esc(name)}</b><p>${esc(note)}</p></div></li>`).join('')}</ol>${s.data?.statement ? `<p class="statement">${esc(s.data.statement)}</p>` : ''}${s.data?.for_who ? `<p>写给谁:${esc(s.data.for_who)}</p>` : ''}`; break;
- case 'statement': body = `<ul>${list(s.data?.alt).map(x => `<li>${esc(x)}</li>`).join('')}</ul>`; break;
- case 'milestones': body = table(['节点', '工作', '检查项', '决策规则'], list(s.data).map(([m, name, checks, rule]) => [m, name, checks, rule])); break;
- case 'funnel': body = table(['环节', '名称', '范围', '数值', '单位'], list(s.data).map(x => [x.stage, x.name, x.scope, x.value, x.unit])); break;
- default: body = `<p class="note">以下结构尚无专用排版,保留已存内容:</p><pre>${esc(JSON.stringify(s, null, 2))}</pre>`;
- }
- return `<section class="section">${title}${body}</section>`;
- };
- const chapters = report.chapters;
- const pipeline = list(report.pipeline);
- const meta = [task.stageCode, report.delivery?.moduleId, task.version != null ? `V${task.version}` : '', ({ live: '真实资料', sample: '样例资料', manual: '人工提供资料' })[task.dataMode]].filter(Boolean);
- const title = task.title || report.title || '工作坊研究报告';
- return `<!doctype html>
- <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"><title>${esc(title)}</title><style>${REPORT_HTML_CSS}</style></head>
- <body><a class="skip" href="#content">跳至报告正文</a><main id="content">
- <header class="cover"><div class="eyebrow">康师傅 · 产品创新工作坊</div><div class="status">课堂过程成果 · 待员工核对</div><h1>${esc(title)}</h1>${report.subtitle ? `<p class="subtitle">${esc(report.subtitle)}</p>` : ''}<div class="meta">${meta.map(x => `<span>${esc(x)}</span>`).join('')}</div><p class="review-note">请核对本版本的关键判断、产品设定与未决问题。课堂保存、员工反馈与正式批准分别记录;此文件不代表员工已确认。</p></header>
- ${list(report.executive).length ? `<section class="summary"><div class="eyebrow">先看关键判断</div><h2>报告摘要</h2><ol>${report.executive.map(x => `<li>${esc(x)}</li>`).join('')}</ol></section>` : ''}
- <nav class="contents" aria-label="报告目录"><b>阅读导航</b>${chapters.map((c, i) => `<a href="#chapter-${i + 1}"><span>${String(i + 1).padStart(2, '0')}</span>${esc(c.title)}</a>`).join('')}${evidenceIds.size ? '<a href="#sources"><span>附</span>判断与原始依据</a>' : ''}${pipeline.length ? '<a href="#sample-scope"><span>附</span>资料处理范围</a>' : ''}</nav>
- ${chapters.map((c, i) => `<article class="chapter" id="chapter-${i + 1}"><div class="chapter-heading"><span class="chapter-no">${String(i + 1).padStart(2, '0')}</span><div>${c.en ? `<div class="eyebrow">${esc(c.en)}</div>` : ''}<h2>${esc(c.title)}</h2></div></div>${c.subtitle ? `<p class="subtitle">${esc(c.subtitle)}</p>` : ''}${c.lead ? `<p class="lead">${esc(c.lead)}</p>` : ''}${list(c.sections).map(section).join('')}</article>`).join('')}
- ${evidenceIds.size ? `<article class="chapter" id="sources"><div class="eyebrow">依据与适用范围</div><h2>判断与原始依据</h2><p class="note">以下仅展示本版本已保存的材料。引用原话的存在不等于方案成立;依据的资格、反例与适用范围请结合正文判断。</p>${Object.entries(evidence).map(([key, entry]) => `<section class="source-group" id="${evidenceIds.get(key)}"><h3>${esc(entry.claim || key)}</h3>${entry.sourceId ? `<p class="note">来源编号 ${esc(entry.sourceId)}</p>` : ''}<div class="quotes">${quotes(entry) || '<p class="note">本判断未保存逐字引文,请结合正文中的实际依据核对。</p>'}</div>${list(entry.derivedFrom).length ? `<p class="note">关联成果编号:${esc(entry.derivedFrom.join('、'))}</p>` : ''}</section>`).join('')}</article>` : ''}
- ${pipeline.length ? `<article class="chapter" id="sample-scope"><h2>资料处理范围</h2><p class="note">以下为本报告记录的资料处理环节,不是消费者选择或购买转化率;统计资格、去重与分母以正文方法说明为准。</p>${table(['处理环节', '记录数值'], pipeline.map(x => [x.label, x.value]))}</article>` : ''}
- ${list(task.pendingQuestions).length ? `<article class="chapter"><h2>本版待核与待验证</h2><ul>${task.pendingQuestions.map(q => `<li>${esc(q)}</li>`).join('')}</ul><p class="note">请反馈正文判断或产品设定需要修改之处;需研究或测试的问题按实际条件继续验证,不能将未决项视为已获员工认可。</p></article>` : ''}
- <footer>课堂过程成果 · 请向数字人反馈需修订的判断、产品设定及版本。来源与计算口径以本报告实际记录为准。</footer></main></body></html>`;
- }
- const REPORT_HTML_CSS = `
- :root{color-scheme:light;--ink:#202f30;--muted:#596b6b;--accent:#176c62;--line:#dbe5e0;--paper:#fff;--tint:#f1f6f2}
- *{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:24px}body{margin:0;background:#f4f5ef;color:var(--ink);font:16px/1.9 system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif}main{max-width:1120px;margin:auto;padding:48px 32px 24px}a{color:var(--accent);text-underline-offset:4px;overflow-wrap:anywhere}a:focus-visible,[tabindex]:focus-visible{outline:3px solid #b97b23;outline-offset:4px}.skip{position:absolute;left:16px;top:-80px}.skip:focus{top:12px}h1,h2,h3,h4,p,figure{margin:0}h1{max-width:880px;margin:22px 0;font-size:clamp(28px,4vw,46px);line-height:1.4;letter-spacing:-.03em}h2{font-size:25px;line-height:1.5}h3{font-size:19px;line-height:1.6;margin-bottom:16px}h4{font-size:18px;line-height:1.7}p{white-space:pre-line;overflow-wrap:anywhere}p+p{margin-top:16px}li{overflow-wrap:anywhere}b,strong{font-weight:650}.eyebrow{color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.12em}.cover{padding:44px 46px;background:var(--paper);border:1px solid var(--line);border-top:5px solid var(--accent);border-radius:16px}.status{display:inline-block;margin-top:20px;padding:3px 12px;border:1px solid #d6dfca;border-radius:30px;color:#4e6847;font-size:13px;background:#f5f8ef}.subtitle{color:var(--muted);margin-top:10px}.meta{display:flex;gap:8px 20px;flex-wrap:wrap;margin-top:22px;font-size:13px;color:var(--muted)}.review-note{font-size:13px;color:var(--muted);padding-top:20px;border-top:1px solid var(--line);margin-top:24px}.summary{margin:24px 0;padding:32px 40px;background:#e8f1e9;border-radius:16px;border:1px solid #d2e1d3}.summary h2{margin:8px 0 16px}.summary ol{margin:0;padding-left:24px;display:grid;gap:14px}.summary li{padding-left:8px}.summary li::marker{color:var(--accent);font-weight:700}.contents{display:flex;gap:12px 22px;flex-wrap:wrap;padding:24px 4px 30px;font-size:14px;align-items:baseline}.contents b{flex-basis:100%;font-size:13px;color:var(--muted)}.contents a{text-decoration:none}.contents span{font-size:12px;color:var(--muted);margin-right:8px}.chapter{background:var(--paper);border:1px solid var(--line);border-radius:16px;padding:36px 40px;margin-bottom:24px;min-width:0;scroll-margin-top:24px}.chapter-heading{display:flex;gap:18px;align-items:baseline;border-bottom:1px solid var(--line);padding-bottom:24px;margin-bottom:24px}.chapter-no{font-size:30px;color:#8ba59a;font-weight:350}.lead{padding:18px 22px;border-left:3px solid var(--accent);background:var(--tint);margin:20px 0}.section{margin-top:26px}.section:first-child{margin-top:0}.table-wrap{max-width:100%;overflow:auto;border:1px solid var(--line);border-radius:10px;margin:16px 0;overscroll-behavior-x:contain}table{width:100%;border-collapse:collapse;text-align:left;font-size:14px;line-height:1.8}th,td{padding:16px;vertical-align:top;border-bottom:1px solid var(--line);min-width:130px;overflow-wrap:anywhere;white-space:pre-line}thead th{background:var(--tint);color:var(--muted);font-size:13px}tbody th{font-weight:600}td{min-width:180px}.wide-cell{min-width:260px}tbody tr:last-child>*{border-bottom:0}.findings{display:grid;gap:20px}.finding{padding:24px;border:1px solid var(--line);border-radius:12px;background:#fafcf9}.finding>p{margin-top:10px}.finding .quote-card{margin-top:18px;background:#fff}.evidence-link{display:inline-block;font-size:13px;margin-top:12px}.quote-card{border:1px solid #d7e1da;border-radius:10px;overflow:hidden;min-width:0;height:auto;break-inside:avoid}.quote-card figcaption{display:flex;flex-wrap:wrap;gap:4px 16px;padding:12px 18px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--line);background:var(--tint);overflow-wrap:anywhere}.quote-card figcaption span{color:var(--muted)}blockquote{margin:0;padding:20px 22px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:15px;line-height:1.95}.quote-ref{border-top:1px solid var(--line);padding:12px 18px;font-size:12px;color:var(--muted);overflow-wrap:anywhere}.quotes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start;gap:16px;margin-top:16px}.source-group{padding-top:26px;margin-top:26px;border-top:1px solid var(--line)}.note{font-size:13px;color:var(--muted);margin-top:10px}.pair{display:grid;grid-template-columns:1fr 1fr;gap:20px}.pair>div{padding:22px;background:var(--tint);border-radius:10px}.pair>div:last-child{background:#faf3e8}.pair ul{margin-bottom:0;padding-left:22px}.steps{list-style:none;padding:0;display:grid;gap:14px}.steps li{display:flex;gap:16px;padding:18px;border-bottom:1px solid var(--line)}.steps li>span{color:var(--accent);font-weight:700}.statement{font-size:20px;font-weight:600;padding:24px;background:var(--tint)}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px}footer{padding:16px 4px 30px;color:var(--muted);font-size:12px}
- @media(max-width:600px){body{font-size:15px}main{padding:16px 12px}.cover{padding:26px 22px}.summary,.chapter{padding:24px 22px;border-radius:12px}.summary{margin:16px 0}.chapter{margin-bottom:16px}h2{font-size:22px}h3{font-size:18px}.contents{gap:12px 18px;padding:18px 8px 24px}.chapter-heading{gap:12px;padding-bottom:18px}.chapter-no{font-size:25px}.quotes,.pair{grid-template-columns:1fr}.finding{padding:18px}.lead{padding:14px 16px}blockquote{padding:18px;font-size:14px}th,td{padding:12px;min-width:140px}.meta{gap:6px 12px}}
- @media(max-width:360px){main{padding:12px 8px}.cover,.summary,.chapter{padding:22px 18px}h1{font-size:25px}h2{font-size:20px}.finding{padding:14px}blockquote{padding:16px}.table-wrap td.wide-cell{min-width:260px}}
- @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}@media print{body{background:#fff;font-size:11pt}main{max-width:none;margin:0;padding:0}.cover,.summary,.chapter{border-radius:0;padding:18pt;border-color:#ccc;background:#fff;box-shadow:none}.contents,.skip{display:none}.chapter{break-before:auto}.chapter-heading,h2,h3,h4{break-after:avoid}.table-wrap{overflow:visible;border:0}table{font-size:9pt;table-layout:fixed}th,td,td.wide-cell{min-width:0;padding:8pt;overflow-wrap:anywhere}thead{display:table-header-group}.quote-card,.finding{break-inside:auto}.quotes,.pair{display:block}.quote-card{margin-bottom:10pt}.quote-ref a::after{content:" (" attr(href) ")";font-size:8pt}.status,.meta,.eyebrow,.note{color:#333}footer{font-size:9pt}}`;
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- try {
- const args = process.argv.slice(2);
- if (args.length !== 4 || args[0] !== '--input' || args[2] !== '--out' || !path.isAbsolute(args[1]) || !path.isAbsolute(args[3])) throw new Error('USAGE');
- const content = renderReportHtml(JSON.parse(fs.readFileSync(args[1], 'utf8')));
- fs.writeFileSync(args[3], content, { flag: 'wx', mode: 0o600 });
- console.log(JSON.stringify({ ok: true, htmlPath: args[3], uploaded: false, employeeConfirmed: false }));
- } catch (error) { console.error(JSON.stringify({ ok: false, errorCode: error.code === 'EEXIST' ? 'OUTPUT_EXISTS' : error.code || 'RENDER_FAILED' })); process.exitCode = 1; }
- }
|