140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
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<Buffer> {
|
||
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 = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: "SimSun", "Noto Sans CJK SC", sans-serif; }
|
||
#chart { width: ${CHART_WIDTH}px; height: ${CHART_HEIGHT}px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="chart"></div>
|
||
</body>
|
||
</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()
|
||
}
|
||
}
|