Files
AriWeb-Suite/modules/webmaker/lib/report.js
T

33 lines
7.6 KiB
JavaScript

'use strict';
// 반영 검증 리포트를 독립된 HTML 페이지로 렌더링한다(생성 화면과 분리, 사용자가 선택했을 때만 열람).
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
const CAT = { cta: '행동 유도(CTA)', feature: '기능', page: '페이지', style: '디자인', copy: '문구·어조', facts: '사실정보', regulation: '규제 표현' };
const ST = { pass: ['통과', '#065f46', '#d1fae5'], partial: ['부분 반영', '#92400e', '#fef3c7'], fail: ['미반영', '#991b1b', '#fee2e2'] };
function render(site) {
const plan = site.plan || {}; const v = plan.verification; const b = plan.brief || {};
const at = v ? new Date(v.at).toLocaleString('ko-KR') : '';
let body = '';
if (!v) body = '<p class="muted">아직 검증이 실행되지 않았습니다. 생성 화면에서 "재검증"을 누르면 리포트가 만들어집니다.</p>';
else {
const r = v.rules; const s = r.summary;
const chip = (t, bad) => `<span class="chip ${bad ? 'bad' : 'ok'}">${esc(t)}</span>`;
body += `<section><h2>요약</h2><div class="chips">${chip(`페이지 파일 ${s.files}개`)}${chip(`누락 페이지 ${s.pagesMissing}`, s.pagesMissing > 0)}${chip(`[입력 필요] ${s.placeholders}곳`, s.placeholders > 0)}${chip(`Brief 에 없는 사실정보 ${s.extraneous}건`, s.extraneous > 0)}${chip(`규제 표현 ${s.regulated}건`, s.regulated > 0)}${v.ai && typeof v.ai.score === 'number' ? chip(`AI 대조 점수 ${v.ai.score}/100`, v.ai.score < 70) : ''}</div>${v.stale ? '<p class="muted">※ 마지막 검증 이후 페이지가 수정되었습니다. 최신 상태를 보려면 재검증하세요.</p>' : ''}</section>`;
body += `<section><h2>페이지 확인</h2><table><tr><th>페이지</th><th>파일</th><th>상태</th></tr>${r.pages.map((p) => `<tr><td>${esc(p.page)}</td><td>${esc(p.file)}</td><td class="${p.exists ? 'ok' : 'bad'}">${p.exists ? '있음' : '없음'}</td></tr>`).join('')}</table></section>`;
body += `<section><h2>입력이 필요한 항목</h2>${r.placeholders.length ? `<ul>${r.placeholders.map((p) => `<li>${esc(p.file)}: "[입력 필요]" ${p.count}곳</li>`).join('')}</ul><p class="muted">전화·주소·영업시간·가격·자격·후기 등은 입력한 값만 사용하며, 없는 값은 "[입력 필요]" 로 표시됩니다. 기본 사항의 사실정보를 입력한 뒤 수정 지시를 보내면 채워집니다.</p>` : '<p class="ok">없음 — 모든 사실정보가 채워져 있습니다.</p>'}</section>`;
body += `<section><h2>Brief 에 없는 사실정보 (제거 대상)</h2>${r.extraneous.length ? `<ul>${r.extraneous.map((e) => `<li>${esc(e.file)}: ${esc(e.type)} "${esc(e.value)}"</li>`).join('')}</ul>` : '<p class="ok">없음</p>'}</section>`;
body += `<section><h2>규제 표현 검사</h2>${r.regulated.length ? `<ul>${r.regulated.map((e) => `<li>${esc(e.file)}: "${esc(e.value)}"</li>`).join('')}</ul>` : `<p class="ok">발견되지 않음${b.regulated && b.regulated.flag ? ` (적용 제약: ${esc((b.regulated.constraints || []).join(' / '))})` : ''}</p>`}</section>`;
if (v.ai && v.ai.items) body += `<section><h2>AI 대조 결과 — Brief ↔ 생성 결과</h2><table><tr><th>구분</th><th>항목</th><th>상태</th><th>근거</th></tr>${v.ai.items.map((i) => { const st = ST[i.status] || ST.partial; return `<tr><td>${esc(CAT[i.category] || i.category)}</td><td>${esc(i.name)}</td><td><span class="st" style="color:${st[1]};background:${st[2]}">${st[0]}</span></td><td>${esc(i.detail)}</td></tr>`; }).join('')}</table>${v.ai.fixes && v.ai.fixes.length ? `<h3>제안된 수정</h3><ol>${v.ai.fixes.map((f) => `<li>${esc(f)}</li>`).join('')}</ol><p class="muted">생성 화면의 "검증 결과 자동 수정"으로 한 번에 반영할 수 있습니다.</p>` : ''}</section>`;
// v1.3.16 다국어 품질 게이트 (번역 누락률·원문 잔존율·링크 대응·언어별 검수)
if (v.i18n && v.i18n.languages) {
const pct = (x) => `${(x * 100).toFixed(1)}%`; const th = v.i18n.thresholds || {};
body += `<section><h2>다국어 품질 게이트 ${v.i18n.pass ? '<span class="st" style="color:#065f46;background:#d1fae5">통과</span>' : '<span class="st" style="color:#991b1b;background:#fee2e2">미통과</span>'}</h2><p class="muted">기준: 번역 누락률 ≤ ${pct(th.maxMissing || 0)} (시스템 문구 누락 0), 원문 잔존율 ≤ ${pct(th.maxResidue || 0)}, 사이트 안 링크가 기본 언어와 같은 짝, 언어별 검수 완료.</p><table><tr><th>언어</th><th>누락률</th><th>잔존율</th><th>링크</th><th>검수</th><th>결과</th></tr>${Object.entries(v.i18n.languages).map(([c, r]) => r.source ? `<tr><td>${esc(c)} (기본)</td><td colspan="4" class="muted">원문</td><td class="ok">—</td></tr>` : `<tr><td>${esc(c)}</td><td class="${r.checks.missing ? 'ok' : 'bad'}">${pct(r.missing.rate)}${r.runtimeMissing ? ` · 시스템 문구 ${r.runtimeMissing}` : ''}</td><td class="${r.checks.residue ? 'ok' : 'bad'}">${pct(r.residue.rate)}${r.residue.samples.length ? `<div class="muted">${r.residue.samples.map(esc).join('<br>')}</div>` : ''}</td><td class="${r.checks.links ? 'ok' : 'bad'}">${r.links.ok ? '일치' : esc([...r.links.issues, ...r.links.missingPages.map((f) => `${f} 없음`)].join(' / '))}</td><td class="${r.checks.review ? 'ok' : 'bad'}">${esc({ approved: '검수 완료', rejected: '수정 필요', pending: '검수 대기' }[r.review] || r.review)}</td><td class="${r.pass ? 'ok' : 'bad'}">${r.pass ? '통과' : '미통과'}</td></tr>`).join('')}</table></section>`;
}
if (v.ai && !v.ai.items && v.ai.error) body += `<section><h2>AI 대조 결과</h2><p class="muted">AI 대조를 완료하지 못했습니다. 재검증을 눌러 다시 시도하세요.</p></section>`;
}
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>검증 리포트 — ${esc(site.title)}</title>
<style>body{font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif;font-size:14px;color:#1f2933;background:#f4f5f7;margin:0;padding:24px}main{max-width:900px;margin:0 auto;background:#fff;border:1px solid #e3e6ea;border-radius:12px;padding:24px}h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:0 0 8px}h3{font-size:13px;margin:12px 0 6px}section{padding:14px 0;border-top:1px solid #e3e6ea}.muted{color:#6b7684;font-size:12px}.ok{color:#065f46}.bad{color:#991b1b;font-weight:600}.chips{display:flex;flex-wrap:wrap;gap:6px}.chip{border:1px solid #e3e6ea;border-radius:6px;padding:4px 10px;font-size:12px}.chip.ok{background:#d1fae5;border-color:#a7f3d0;color:#065f46}.chip.bad{background:#fee2e2;border-color:#fecaca;color:#991b1b}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:6px;border-bottom:1px solid #e3e6ea;vertical-align:top}.st{font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600;white-space:nowrap}ul,ol{margin:4px 0;padding-left:20px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:8px}.btn{font-size:12px;border:1px solid #e3e6ea;border-radius:8px;padding:6px 10px;text-decoration:none;color:#1f2933;background:#fff}@media print{body{background:#fff;padding:0}main{border:0}.btn{display:none}}</style></head>
<body><main><div class="top"><div><h1>반영 검증 리포트</h1><div class="muted">${esc(site.title)} · ${esc(site.url || '')}${at ? ` · 검증 시각 ${esc(at)}` : ''}</div></div><div><a class="btn" href="?download=1">파일로 저장</a> <a class="btn" href="javascript:window.print()">인쇄</a></div></div>${body}</main></body></html>`;
}
module.exports = { render };