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 ``;
}
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 ``;
}
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}% |
`).join('');
return `
AI 评审报告
${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)}
${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) => `
| ${e.rank} |
${escapeHtml(e.title)} |
${escapeHtml(e.participant || '-')} |
${e.score} |
${e.pass_line} |
${e.final_level ? `${e.final_level}` : (e.passed ? '通过' : '未通过')} |
`).join('')}
`}).join('');
const participantSection = (summary.participants || []).length > 0 ? `
${isTalent ? 'L2/L3' : 'L2'} 合格判定
| 参赛者 | 题目 | 得分 | 结果 |
${summary.participants.map((p: any) => `
| ${escapeHtml(p.participant)} |
${p.entries.map((e: any) => escapeHtml(e.title)).join(', ')} |
${p.entries.map((e: any) => e.score).join(' / ')} |
${p.passed ? '通过' : '未通过'} |
`).join('')}
` : '';
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();
}
}