import * as echarts from 'echarts'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseCSV } from '../js/csv.js'; import { getChartBuilder } from '../js/charts/index.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8')); const STYLE_RULES = { legendTop: 85, gridTop: 145, lineSeries: { symbolSize: 9, borderWidth: 2.5 }, }; function entriesFromManifest(value) { const ids = new Set(); const entries = []; value.companies.forEach((company) => company.tabs.forEach((tab) => (tab.charts || []).forEach((chart) => { if (!chart.id || ids.has(chart.id)) throw new Error(`manifest 图表 id 重复或为空:${chart.id || '(空)'}`); ids.add(chart.id); if (!getChartBuilder(chart.key)) throw new Error(`manifest 图表 ${chart.id} 引用了未注册 key:${chart.key}`); if (Boolean(chart.csv) === Boolean(chart.views)) { throw new Error(`manifest 图表 ${chart.id} 必须声明 csv 或 views 之一`); } if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) { throw new Error(`manifest 图表 ${chart.id} 的 defaultView 无效`); } const sources = chart.views ? Object.entries(chart.views).map(([view, csv]) => ({ view, csv })) : [{ view: null, csv: chart.csv }]; if (!sources.every(({ csv }) => typeof csv === 'string' && csv.length > 0)) { throw new Error(`manifest 图表 ${chart.id} 缺少 CSV`); } sources.forEach(({ view, csv }) => entries.push({ ...chart, company, tab, view, csv })); }))); return entries; } const all = entriesFromManifest(manifest); const only = process.argv.slice(2); const targets = only.length === 0 ? all : all.filter((entry) => only.includes(entry.id) || only.includes(entry.key)); const unknown = only.filter((target) => !all.some((entry) => entry.id === target || entry.key === target)); if (unknown.length) console.log(`⚠ 未找到图表:${unknown.join(', ')}`); let ok = 0; let fail = 0; const styleWarnings = []; for (const entry of targets) { const label = entry.view ? `${entry.id}:${entry.view}` : entry.id; try { const csvPath = path.resolve(ROOT, entry.csv); if (!csvPath.startsWith(`${ROOT}${path.sep}`)) throw new Error(`CSV 路径越过项目根目录:${entry.csv}`); const rows = parseCSV(fs.readFileSync(csvPath, 'utf8')); const chart = echarts.init(null, null, { renderer: 'svg', ssr: true, width: 1100, height: 720 }); const option = getChartBuilder(entry.key)(rows, { width: 1100, height: 720 }); chart.setOption(option); const svg = chart.renderToSVGString(); chart.dispose(); const badAttr = /style="[^"]*"[^"=>\s][^>]*>/.test(svg.slice(0, 3000)); if (option.legend?.top !== undefined && option.legend.top !== STYLE_RULES.legendTop) { styleWarnings.push(`${label}: legend.top=${option.legend.top} (期望 ${STYLE_RULES.legendTop})`); } if (option.grid?.top !== undefined && option.legend?.top !== undefined && option.grid.top !== STYLE_RULES.gridTop) { styleWarnings.push(`${label}: grid.top=${option.grid.top} (期望 ${STYLE_RULES.gridTop})`); } const circles = [...svg.matchAll(/ 0) { const scale = +circles[0][1]; const strokeWidth = +circles[0][2]; if (Math.abs(scale * 2 - STYLE_RULES.lineSeries.symbolSize) > 0.1) { styleWarnings.push(`${label}: 折线 symbolSize=${(scale * 2).toFixed(1)} (期望 ${STYLE_RULES.lineSeries.symbolSize})`); } if (Math.abs(strokeWidth * scale - STYLE_RULES.lineSeries.borderWidth) > 0.1) { styleWarnings.push(`${label}: 折线 borderWidth=${(strokeWidth * scale).toFixed(1)} (期望 ${STYLE_RULES.lineSeries.borderWidth})`); } } console.log(`${badAttr ? '⚠' : '✓'} ${label.padEnd(30)} ${String(svg.length).padStart(6)}B`); ok += 1; } catch (error) { console.log(`✗ ${label.padEnd(30)} ${error.message}`); fail += 1; } } console.log(`\n${ok} 张成功 / ${fail} 张失败`); if (styleWarnings.length) { console.log(`\n⚠ ${styleWarnings.length} 处样式偏离规范:`); styleWarnings.forEach((warning) => console.log(` ${warning}`)); } else { console.log('✓ 样式规范全部符合'); } process.exit(fail > 0 || unknown.length > 0 ? 1 : 0);