Files
L2keka/server/src/services/pdf.service.ts
T

253 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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();
}
}