import puppeteer from 'puppeteer' import type { DailyOnlineStats } from '@/types/report' import path from 'path' import fs from 'fs' const CHART_WIDTH = 800 const CHART_HEIGHT = 400 // 在 Next.js webpack 环境下 require.resolve 可能失败,用文件系统路径 const ECHARTS_PATH = path.join(process.cwd(), 'node_modules', 'echarts', 'dist', 'echarts.min.js') export async function generateDailyOnlineChart( stats: DailyOnlineStats[], seriesKey: 'gpu' | 'storage' ): Promise { const label = seriesKey === 'gpu' ? 'GPU' : '存储' const totalKey = seriesKey === 'gpu' ? 'gpuTotal' : 'storageTotal' as const const onlineKey = seriesKey === 'gpu' ? 'gpuOnline' : 'storageOnline' as const const total = stats[0]?.[totalKey] ?? 0 const dates = stats.map(s => s.date.slice(5)) // "MM-DD" const onlineValues = stats.map(s => s[onlineKey]) const echartsScript = fs.readFileSync(ECHARTS_PATH, 'utf-8') const html = `
` const browser = await puppeteer.launch({ headless: true as any, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], }) try { const page = await browser.newPage() await page.setViewport({ width: CHART_WIDTH, height: CHART_HEIGHT, deviceScaleFactor: 1 }) await page.setContent(html, { waitUntil: 'networkidle0' }) // 注入 echarts(使用 eval 方式,兼容 Next.js webpack 环境) await page.evaluate((script: string) => { eval.call(null, script) }, echartsScript) // 渲染图表 await page.evaluate((params: { dates: string[]; onlineValues: number[]; total: number; label: string; }) => { const chartDom = document.getElementById('chart') if (!chartDom) return const myChart = (window as any).echarts.init(chartDom) // 动态 Y 轴范围:根据实际数据波动自动调整,使微小变化也能看清 const minOnline = Math.min(...params.onlineValues) const maxOnline = Math.max(...params.onlineValues) let yMin: number, yMax: number, yInterval: number if (minOnline === maxOnline) { // 无波动:Y 轴范围 total±2 yMin = Math.max(0, params.total - 2) yMax = params.total + 2 yInterval = 1 } else { const buffer = Math.max(2, Math.ceil(params.total * 0.05)) yMin = Math.max(0, minOnline - buffer) yMax = params.total + Math.ceil(buffer / 2) const yRange = yMax - yMin // 确保刻度数在 8~15 个 const rawStep = yRange / 10 const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))) const residual = rawStep / magnitude if (residual <= 1.5) yInterval = magnitude else if (residual <= 3.5) yInterval = 2 * magnitude else if (residual <= 7.5) yInterval = 5 * magnitude else yInterval = 10 * magnitude if (yInterval < 1) yInterval = 1 } myChart.setOption({ animation: false, title: { text: `${params.label}服务器每日在线节点数(共 ${params.total} 台)`, left: 'center', top: 10, textStyle: { fontFamily: 'SimSun, sans-serif', fontSize: 16, color: '#333' }, }, tooltip: { trigger: 'axis' }, grid: { left: 80, right: 80, top: 60, bottom: 80 }, xAxis: { type: 'category', data: params.dates, boundaryGap: false, axisLabel: { fontSize: 11, interval: 0, rotate: 45, hideOverlap: false }, }, yAxis: { type: 'value', min: yMin, max: yMax, name: '在线节点数', interval: yInterval, axisLabel: { fontSize: 11 }, }, series: [{ name: '在线节点', type: 'line', data: params.onlineValues, clip: false, sampling: 'none', smooth: false, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2, color: '#4472C4' }, itemStyle: { color: '#4472C4' }, areaStyle: { color: 'rgba(68, 114, 196, 0.1)' }, }], }) }, { dates, onlineValues, total, label }) // 等待渲染 await new Promise(resolve => setTimeout(resolve, 800)) // 截图 const chartElement = await page.$('#chart') if (!chartElement) throw new Error('图表元素未找到') const screenshot = await chartElement.screenshot({ type: 'png' }) return Buffer.from(screenshot) } finally { await browser.close() } }