253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
import path from 'path';
|
||
import fs from 'fs';
|
||
import puppeteer from 'puppeteer-core';
|
||
|
||
const EDGE_PATH = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
|
||
const CHROME_PATH = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
|
||
const OUTPUT_DIR = path.resolve(__dirname, '../../data/reports');
|
||
|
||
export function escapeHtml(value: unknown): string {
|
||
return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function findBrowser(): string {
|
||
if (fs.existsSync(EDGE_PATH)) return EDGE_PATH;
|
||
if (fs.existsSync(CHROME_PATH)) return CHROME_PATH;
|
||
throw new Error('未找到 Chrome/Edge 浏览器');
|
||
}
|
||
|
||
function buildRadarSvg(dims: any[]): string {
|
||
const n = dims.length;
|
||
if (n === 0) return '';
|
||
const cx = 250, cy = 250, r = 180;
|
||
const labelR = r + 30;
|
||
|
||
const angle = (i: number) => (2 * Math.PI * i) / n - Math.PI / 2;
|
||
const pt = (i: number, radius: number) => {
|
||
const a = angle(i);
|
||
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) };
|
||
};
|
||
|
||
const scorePoly = dims.map((d, i) => {
|
||
const pct = d.maxScore > 0 ? d.score / d.maxScore : 0;
|
||
return pt(i, r * pct);
|
||
});
|
||
const scorePath = scorePoly.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ') + ' Z';
|
||
|
||
const ringLabels = [25, 50, 75, 100].map(pct => {
|
||
const rr = r * pct / 100;
|
||
const points = Array.from({ length: n }, (_, i) => {
|
||
const p = pt(i, rr);
|
||
return `${p.x},${p.y}`;
|
||
}).join(' ');
|
||
return `<polygon points="${points}" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,3" />
|
||
<text x="${cx}" y="${cy - rr}" font-size="8" fill="#999" text-anchor="middle" dominant-baseline="middle">${pct}%</text>`;
|
||
}).join('');
|
||
|
||
const axes = Array.from({ length: n }, (_, i) => {
|
||
const p = pt(i, r);
|
||
return `<line x1="${cx}" y1="${cy}" x2="${p.x}" y2="${p.y}" stroke="#e5e7eb" stroke-width="1" />`;
|
||
}).join('');
|
||
|
||
const labels = dims.map((d, i) => {
|
||
const p = pt(i, labelR);
|
||
const anchor = p.x > cx + 5 ? 'start' : p.x < cx - 5 ? 'end' : 'middle';
|
||
return `<text x="${p.x}" y="${p.y}" font-size="9" fill="#333" text-anchor="${anchor}" dominant-baseline="middle">${escapeHtml(d.name)}</text>`;
|
||
}).join('');
|
||
|
||
const scoreDots = dims.map((d, i) => {
|
||
const p = scorePoly[i];
|
||
return `<circle cx="${p.x}" cy="${p.y}" r="3.5" fill="#4f46e5" />`;
|
||
}).join('');
|
||
|
||
return `<svg width="500" height="500" viewBox="0 0 500 500" style="margin:0 auto;display:block">
|
||
${ringLabels}
|
||
${axes}
|
||
<polygon points="${scorePath}" fill="rgba(79,70,229,0.15)" stroke="#4f46e5" stroke-width="2" />
|
||
${scoreDots}
|
||
${labels}
|
||
</svg>`;
|
||
}
|
||
|
||
function buildBarChart(categories: any[]): string {
|
||
const all = categories.flatMap((c: any) => c.entries);
|
||
if (all.length === 0) return '';
|
||
const barH = 28, gap = 8, labelW = 180, chartW = 400, topPad = 20;
|
||
const h = all.length * (barH + gap) + topPad;
|
||
const maxScore = Math.max(...all.map((e: any) => e.score));
|
||
|
||
const bars = all.map((e, i) => {
|
||
const y = topPad + i * (barH + gap);
|
||
const w = maxScore > 0 ? (e.score / maxScore) * chartW : 0;
|
||
const color = e.passed ? '#10b981' : '#ef4444';
|
||
return `
|
||
<text x="${labelW - 6}" y="${y + barH / 2}" font-size="10" fill="#333" text-anchor="end" dominant-baseline="middle">${escapeHtml(e.title)}</text>
|
||
<rect x="${labelW}" y="${y}" width="${w}" height="${barH}" rx="4" fill="${color}" opacity="0.85" />
|
||
<text x="${labelW + w + 4}" y="${y + barH / 2}" font-size="10" fill="${color}" dominant-baseline="middle">${e.score}分</text>`;
|
||
}).join('');
|
||
|
||
return `<svg width="${labelW + chartW + 60}" height="${h}" style="margin:0 auto;display:block">
|
||
<text x="${labelW}" y="14" font-size="11" fill="#666">分数分布</text>
|
||
${bars}
|
||
</svg>`;
|
||
}
|
||
|
||
export function buildEntryHtml(entry: any, project: any, report: any, dims: any[]): string {
|
||
const rows = dims.map((d: any) => `
|
||
<tr>
|
||
<td>${escapeHtml(d.name)}</td>
|
||
<td class="score">${d.score}</td>
|
||
<td>${d.maxScore}</td>
|
||
<td class="pct">${d.maxScore > 0 ? Math.round(d.score / d.maxScore * 100) : 0}%</td>
|
||
<td class="comment">${escapeHtml(d.comment || '-')}${d.verifiability?.note ? `<div class="verif-note">${escapeHtml(d.verifiability.note)}</div>` : ''}</td>
|
||
</tr>`).join('');
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh"><head><meta charset="utf-8">
|
||
<style>
|
||
@page { margin: 20mm 15mm; }
|
||
body { font-family: 'Segoe UI', sans-serif; color: #333; font-size: 12px; }
|
||
h1 { font-size: 20px; color: #4f46e5; border-bottom: 2px solid #4f46e5; padding-bottom: 6px; }
|
||
.meta { color: #666; font-size: 11px; line-height: 1.8; margin: 10px 0 20px; }
|
||
.score-big { font-size: 32px; font-weight: bold; color: #4f46e5; margin: 16px 0; }
|
||
.pass-info { font-size: 11px; color: #666; }
|
||
.pass { color: #10b981; font-weight: bold; }
|
||
.fail { color: #ef4444; font-weight: bold; }
|
||
table { width: 100%; border-collapse: collapse; margin: 16px 0; }
|
||
th { background: #f3f4f6; padding: 8px 10px; text-align: left; font-size: 11px; border-bottom: 2px solid #e5e7eb; }
|
||
td { padding: 6px 10px; border-bottom: 1px solid #e5e7eb; font-size: 11px; }
|
||
.score { font-weight: bold; text-align: center; }
|
||
.pct { text-align: center; color: #666; }
|
||
.comment { color: #555; font-size: 10px; line-height: 1.5; }
|
||
.footer { margin-top: 30px; font-size: 9px; color: #999; border-top: 1px solid #e5e7eb; padding-top: 10px; }
|
||
.correction { background: #fef3c7; font-size: 10px; padding: 8px; border-radius: 4px; margin: 8px 0; }
|
||
.chart-wrap { text-align: center; margin: 24px 0; page-break-inside: avoid; }
|
||
.chart-wrap h3 { font-size: 14px; color: #333; margin-bottom: 12px; }
|
||
</style></head><body>
|
||
<h1>AI 评审报告</h1>
|
||
<div class="meta">
|
||
<div><strong>项目:</strong>${escapeHtml(project?.name || '')}</div>
|
||
<div><strong>条目:</strong>${escapeHtml(entry.title)}</div>
|
||
<div><strong>仓库:</strong>${escapeHtml(entry.repo_url)}</div>
|
||
<div><strong>及格线:</strong>${escapeHtml(entry.pass_line || '-')}分</div>
|
||
</div>
|
||
<div class="score-big">${report.totalScore} / ${report.maxTotal} 分</div>
|
||
<div class="pass-info">通过率 ${report.pct}%
|
||
${entry.final_score >= entry.pass_line ? '<span class="pass">✅ 达标</span>' : '<span class="fail">❌ 未达标</span>'}
|
||
${entry.late_days > 0 ? `<br>迟交 ${entry.late_days} 天,扣除 ${entry.raw_score - entry.final_score} 分` : ''}
|
||
${entry.attempt > 1 ? `<br>第 ${entry.attempt} 次提交(最高 ${entry.max_score_cap} 分)` : ''}
|
||
</div>
|
||
${report.overview || report.overall ? `
|
||
<div class="correction" style="background:#fff7ed;border:1px solid #fdba74">
|
||
<strong>整体评价:</strong>
|
||
${report.overview ? `<div style="font-weight:600;margin-top:4px">项目总览</div><div style="margin-top:2px">${escapeHtml(report.overview)}</div>` : ''}
|
||
${report.overall && Array.isArray(report.overall.highlights) && report.overall.highlights.length ? `<div style="color:#2e7d32;margin-top:4px"><strong>核心亮点点评:</strong>${report.overall.highlights.map((h: any) => escapeHtml(h.point) + (h.review ? ` —— ${escapeHtml(h.review)}` : '')).join(';')}</div>` : ''}
|
||
${report.overall && Array.isArray(report.overall.weaknesses) && report.overall.weaknesses.length ? `<div style="color:#c62828;margin-top:4px"><strong>主要不足点评:</strong>${report.overall.weaknesses.map((w: any) => escapeHtml(w.point) + (w.review ? ` —— ${escapeHtml(w.review)}` : '')).join(';')}</div>` : ''}
|
||
${report.overall && report.overall.verdict ? `<div style="margin-top:4px">${escapeHtml(report.overall.verdict)}</div>` : ''}
|
||
</div>` : ''}
|
||
<div class="chart-wrap">
|
||
<h3>维度评分雷达图</h3>
|
||
${buildRadarSvg(dims)}
|
||
</div>
|
||
<table>
|
||
<tr><th>评审项</th><th>得分</th><th>满分</th><th>得分率</th><th>评语</th></tr>
|
||
${rows}
|
||
</table>
|
||
${entry.revisions?.length > 0 ? `<div class="correction">已修正 ${entry.revisions.length} 次</div>` : ''}
|
||
<div class="footer">生成时间:${new Date().toLocaleString('zh-CN')}</div>
|
||
</body></html>`;
|
||
}
|
||
|
||
export function buildSummaryHtml(project: any, summary: any): string {
|
||
const isTalent = summary.categories?.some((c: any) => /^Q\d$/.test(c.category));
|
||
const hasLevel = summary.categories?.some((c: any) => c.entries?.some((e: any) => e.final_level));
|
||
|
||
const catSections = (summary.categories || []).map((cat: any) => {
|
||
const isQ = /^Q\d$/.test(cat.category);
|
||
return `
|
||
<h2>${isQ ? '题目 ' + escapeHtml(cat.category) : escapeHtml(cat.category)}(${cat.entries.length}个条目)</h2>
|
||
<table>
|
||
<tr><th>排名</th><th>标题</th><th>参赛者</th><th>得分</th><th>及格线</th>${hasLevel ? '<th>认定</th>' : '<th>结果</th>'}</tr>
|
||
${cat.entries.map((e: any) => `
|
||
<tr>
|
||
<td class="rank">${e.rank}</td>
|
||
<td>${escapeHtml(e.title)}</td>
|
||
<td>${escapeHtml(e.participant || '-')}</td>
|
||
<td class="score">${e.score}</td>
|
||
<td>${e.pass_line}</td>
|
||
<td>${e.final_level ? `<span class="${e.final_level === 'L3' ? '' : e.final_level === 'L2' ? 'pass' : 'fail'}">${e.final_level}</span>` : (e.passed ? '<span class="pass">通过</span>' : '<span class="fail">未通过</span>')}</td>
|
||
</tr>`).join('')}
|
||
</table>`}).join('');
|
||
|
||
const participantSection = (summary.participants || []).length > 0 ? `
|
||
<h2>${isTalent ? 'L2/L3' : 'L2'} 合格判定</h2>
|
||
<table>
|
||
<tr><th>参赛者</th><th>题目</th><th>得分</th><th>结果</th></tr>
|
||
${summary.participants.map((p: any) => `
|
||
<tr>
|
||
<td><strong>${escapeHtml(p.participant)}</strong></td>
|
||
<td>${p.entries.map((e: any) => escapeHtml(e.title)).join(', ')}</td>
|
||
<td>${p.entries.map((e: any) => e.score).join(' / ')}</td>
|
||
<td>${p.passed ? '<span class="pass">通过</span>' : '<span class="fail">未通过</span>'}</td>
|
||
</tr>`).join('')}
|
||
</table>` : '';
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="zh"><head><meta charset="utf-8">
|
||
<style>
|
||
@page { margin: 20mm 15mm; }
|
||
body { font-family: 'Segoe UI', sans-serif; color: #333; font-size: 12px; }
|
||
h1 { font-size: 20px; color: #4f46e5; border-bottom: 2px solid #4f46e5; padding-bottom: 6px; }
|
||
.track-badge { display: inline-block; background: #eef2ff; color: #4f46e5; padding: 2px 10px; border-radius: 4px; font-size: 11px; margin-left: 8px; }
|
||
h2 { font-size: 15px; margin-top: 24px; color: #333; }
|
||
.meta { color: #666; font-size: 11px; margin: 8px 0 16px; }
|
||
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||
th { background: #f3f4f6; padding: 8px 10px; text-align: left; font-size: 11px; border-bottom: 2px solid #e5e7eb; }
|
||
td { padding: 6px 10px; border-bottom: 1px solid #e5e7eb; font-size: 11px; }
|
||
.rank { font-weight: bold; color: #4f46e5; }
|
||
.score { font-weight: bold; text-align: center; }
|
||
.pass { color: #10b981; font-weight: bold; }
|
||
.fail { color: #ef4444; font-weight: bold; }
|
||
.footer { margin-top: 30px; font-size: 9px; color: #999; border-top: 1px solid #e5e7eb; padding-top: 10px; }
|
||
</style></head><body>
|
||
<h1>评审汇总报告 <span class="track-badge">${escapeHtml(project.track || '')}</span></h1>
|
||
<div class="meta">项目:${escapeHtml(project.name)} | ${summary.totalEntries} 个条目 | 生成时间:${new Date().toLocaleString('zh-CN')}</div>
|
||
${catSections}
|
||
${participantSection}
|
||
<div class="footer">AI-Review System</div>
|
||
</body></html>`;
|
||
}
|
||
|
||
export async function generateEntryPdf(entry: any, project: any): Promise<string> {
|
||
const report = JSON.parse(entry.ai_report);
|
||
const dims = report.dimensions || [];
|
||
const html = buildEntryHtml(entry, project, report, dims);
|
||
return generatePdf(html, `${project.name}_${entry.title}_评审报告.pdf`);
|
||
}
|
||
|
||
export async function generateSummaryPdf(project: any, summary: any): Promise<string> {
|
||
const html = buildSummaryHtml(project, summary);
|
||
return generatePdf(html, `${project.name}_汇总排名.pdf`);
|
||
}
|
||
|
||
async function generatePdf(html: string, filename: string): Promise<string> {
|
||
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||
|
||
const filePath = path.join(OUTPUT_DIR, Date.now() + '_' + Math.random().toString(36).slice(2, 10) + '.pdf');
|
||
|
||
const browser = await puppeteer.launch({
|
||
executablePath: findBrowser(),
|
||
headless: true,
|
||
args: ['--no-sandbox', '--disable-gpu'],
|
||
});
|
||
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.setContent(html, { waitUntil: 'load' });
|
||
await page.pdf({ path: filePath, format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' } });
|
||
return filePath;
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
}
|