issue-ai/src/lib/monthly-report-charts.ts

140 lines
4.7 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 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()
}
}