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 => ``).join('')}` : ''}${list(rows).map(row => `${list(row).map((c, i) => `<${i ? 'td' : 'th scope="row"'}${text(c).length > 70 ? ' class="wide-cell"' : ''}>${cell(c)}`).join('')}`).join('')}
${esc(c)}
`; 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 = `

能做到的

做不到的(红线)

`; 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]) => `
  1. ${esc(n)}
    ${esc(name)}

    ${esc(note)}

  2. `).join('')}
${s.data?.statement ? `

${esc(s.data.statement)}

` : ''}${s.data?.for_who ? `

写给谁:${esc(s.data.for_who)}

` : ''}`; break; case 'statement': body = ``; 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 `
${title}${body}
`; }; 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)}
康师傅 · 产品创新工作坊
课堂过程成果 · 待员工核对

${esc(title)}

${report.subtitle ? `

${esc(report.subtitle)}

` : ''}
${meta.map(x => `${esc(x)}`).join('')}

请核对本版本的关键判断、产品设定与未决问题。课堂保存、员工反馈与正式批准分别记录;此文件不代表员工已确认。

${list(report.executive).length ? `
先看关键判断

报告摘要

    ${report.executive.map(x => `
  1. ${esc(x)}
  2. `).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 ? `

本版待核与待验证

请反馈正文判断或产品设定需要修改之处;需研究或测试的问题按实际条件继续验证,不能将未决项视为已获员工认可。

` : ''}
`; } 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; } }