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) => `${esc(label)}`;
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 => `
${cell(v)}
`).join('');
if (typeof value === 'string' && evidenceIds.has(value)) return `${esc(evidence[value].claim || value)}`;
return linkedText(text(value));
};
const table = (cols, rows) => `${list(cols).length ? `${cols.map(c => `| ${esc(c)} | `).join('')}
` : ''}${list(rows).map(row => `${list(row).map((c, i) => `<${i ? 'td' : 'th scope="row"'}${text(c).length > 70 ? ' class="wide-cell"' : ''}>${cell(c)}${i ? 'td' : 'th'}>`).join('')}
`).join('')}
`;
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 `${esc(q.author || '作者未记录')}${sourceId ? `来源编号 ${esc(sourceId)}` : ''}${esc(q.text)}
${displayRef ? `
${esc(displayRef)}
` : ''}${url ? link(url, '查看原始来源 ↗') : '
本条未保存可打开的原文链接
'}
`;
}).join('');
const section = s => {
const title = s.title || s.no ? `${esc([s.no, s.title].filter(Boolean).join(' · '))}
` : '';
let body;
switch (s.kind) {
case 'para': case 'longpara': body = String(s.text || '').split(/\n{2,}/).filter(Boolean).map(p => `${linkedText(p)}
`).join(''); break;
case 'table': case 'matrix': body = table(s.data?.cols, s.data?.rows); break;
case 'findings': body = `${list(s.data).map(f => `
${esc(f.conclusion)}
${f.attitude ? `${esc(f.attitude)}
` : ''}${quotes(evidence[f.evidence], 2)}${evidenceIds.has(f.evidence) ? `查看该判断的全部已存依据 ↓` : ''}`).join('')}
`; break;
case 'two': body = `能做到的
${list(s.data?.can).map(x => `- ${esc(x)}
`).join('')}
做不到的(红线)
${list(s.data?.cannot).map(x => `- ${esc(x)}
`).join('')}
`; 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 = `${list(s.data?.ladder).map(([n, name, note]) => `- ${esc(n)}
`).join('')}
${s.data?.statement ? `${esc(s.data.statement)}
` : ''}${s.data?.for_who ? `写给谁:${esc(s.data.for_who)}
` : ''}`; break;
case 'statement': body = `${list(s.data?.alt).map(x => `- ${esc(x)}
`).join('')}
`; 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 = `以下结构尚无专用排版,保留已存内容:
${esc(JSON.stringify(s, null, 2))}`;
}
return ``;
};
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 `
${esc(title)}
跳至报告正文
${list(report.executive).length ? `先看关键判断
报告摘要
${report.executive.map(x => `- ${esc(x)}
`).join('')}
` : ''}
${chapters.map((c, i) => `${String(i + 1).padStart(2, '0')}${c.en ? `
${esc(c.en)}
` : ''}
${esc(c.title)}
${c.subtitle ? `${esc(c.subtitle)}
` : ''}${c.lead ? `${esc(c.lead)}
` : ''}${list(c.sections).map(section).join('')}`).join('')}
${evidenceIds.size ? `依据与适用范围
判断与原始依据
以下仅展示本版本已保存的材料。引用原话的存在不等于方案成立;依据的资格、反例与适用范围请结合正文判断。
${Object.entries(evidence).map(([key, entry]) => `${esc(entry.claim || key)}
${entry.sourceId ? `来源编号 ${esc(entry.sourceId)}
` : ''}${quotes(entry) || '
本判断未保存逐字引文,请结合正文中的实际依据核对。
'}
${list(entry.derivedFrom).length ? `关联成果编号:${esc(entry.derivedFrom.join('、'))}
` : ''}`).join('')}` : ''}
${pipeline.length ? `资料处理范围
以下为本报告记录的资料处理环节,不是消费者选择或购买转化率;统计资格、去重与分母以正文方法说明为准。
${table(['处理环节', '记录数值'], pipeline.map(x => [x.label, x.value]))}` : ''}
${list(task.pendingQuestions).length ? `本版待核与待验证
${task.pendingQuestions.map(q => `- ${esc(q)}
`).join('')}
请反馈正文判断或产品设定需要修改之处;需研究或测试的问题按实际条件继续验证,不能将未决项视为已获员工认可。
` : ''}
`;
}
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; }
}