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, '''); } 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 ` ${pct}%`; }).join(''); const axes = Array.from({ length: n }, (_, i) => { const p = pt(i, r); return ``; }).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 `${escapeHtml(d.name)}`; }).join(''); const scoreDots = dims.map((d, i) => { const p = scorePoly[i]; return ``; }).join(''); return ` ${ringLabels} ${axes} ${scoreDots} ${labels} `; } 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 ` ${escapeHtml(e.title)} ${e.score}分`; }).join(''); return ` 分数分布 ${bars} `; } export function buildEntryHtml(entry: any, project: any, report: any, dims: any[]): string { const rows = dims.map((d: any) => ` ${escapeHtml(d.name)} ${d.score} ${d.maxScore} ${d.maxScore > 0 ? Math.round(d.score / d.maxScore * 100) : 0}% ${escapeHtml(d.comment || '-')}${d.verifiability?.note ? `
${escapeHtml(d.verifiability.note)}
` : ''} `).join(''); return `

AI 评审报告

项目:${escapeHtml(project?.name || '')}
条目:${escapeHtml(entry.title)}
仓库:${escapeHtml(entry.repo_url)}
及格线:${escapeHtml(entry.pass_line || '-')}分
${report.totalScore} / ${report.maxTotal} 分
通过率 ${report.pct}% ${entry.final_score >= entry.pass_line ? '✅ 达标' : '❌ 未达标'} ${entry.late_days > 0 ? `
迟交 ${entry.late_days} 天,扣除 ${entry.raw_score - entry.final_score} 分` : ''} ${entry.attempt > 1 ? `
第 ${entry.attempt} 次提交(最高 ${entry.max_score_cap} 分)` : ''}
${report.overview || report.overall ? `
整体评价: ${report.overview ? `
项目总览
${escapeHtml(report.overview)}
` : ''} ${report.overall && Array.isArray(report.overall.highlights) && report.overall.highlights.length ? `
核心亮点点评:${report.overall.highlights.map((h: any) => escapeHtml(h.point) + (h.review ? ` —— ${escapeHtml(h.review)}` : '')).join(';')}
` : ''} ${report.overall && Array.isArray(report.overall.weaknesses) && report.overall.weaknesses.length ? `
主要不足点评:${report.overall.weaknesses.map((w: any) => escapeHtml(w.point) + (w.review ? ` —— ${escapeHtml(w.review)}` : '')).join(';')}
` : ''} ${report.overall && report.overall.verdict ? `
${escapeHtml(report.overall.verdict)}
` : ''}
` : ''}

维度评分雷达图

${buildRadarSvg(dims)}
${rows}
评审项得分满分得分率评语
${entry.revisions?.length > 0 ? `
已修正 ${entry.revisions.length} 次
` : ''} `; } 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 `

${isQ ? '题目 ' + escapeHtml(cat.category) : escapeHtml(cat.category)}(${cat.entries.length}个条目)

${hasLevel ? '' : ''} ${cat.entries.map((e: any) => ` `).join('')}
排名标题参赛者得分及格线认定结果
${e.rank} ${escapeHtml(e.title)} ${escapeHtml(e.participant || '-')} ${e.score} ${e.pass_line} ${e.final_level ? `${e.final_level}` : (e.passed ? '通过' : '未通过')}
`}).join(''); const participantSection = (summary.participants || []).length > 0 ? `

${isTalent ? 'L2/L3' : 'L2'} 合格判定

${summary.participants.map((p: any) => ` `).join('')}
参赛者题目得分结果
${escapeHtml(p.participant)} ${p.entries.map((e: any) => escapeHtml(e.title)).join(', ')} ${p.entries.map((e: any) => e.score).join(' / ')} ${p.passed ? '通过' : '未通过'}
` : ''; return `

评审汇总报告 ${escapeHtml(project.track || '')}

项目:${escapeHtml(project.name)} | ${summary.totalEntries} 个条目 | 生成时间:${new Date().toLocaleString('zh-CN')}
${catSections} ${participantSection} `; } export async function generateEntryPdf(entry: any, project: any): Promise { 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 { const html = buildSummaryHtml(project, summary); return generatePdf(html, `${project.name}_汇总排名.pdf`); } async function generatePdf(html: string, filename: string): Promise { 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(); } }