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 entries = []; value.companies.forEach((company) => { if (!company.chartCatalog || !Array.isArray(company.tabs)) { throw new Error(`公司 ${company.id || '(空)'} 缺少 chartCatalog 或 tabs`); } const chartIds = Object.keys(company.chartCatalog); company.tabs.forEach((tab) => { if (!Array.isArray(tab.chartRefs)) throw new Error(`栏目 ${tab.id} 缺少 chartRefs`); const refs = new Set(); tab.chartRefs.forEach((chartId) => { if (!company.chartCatalog[chartId]) throw new Error(`栏目 ${tab.id} 引用了不存在的图表:${chartId}`); if (refs.has(chartId)) throw new Error(`栏目 ${tab.id} 重复引用图表:${chartId}`); refs.add(chartId); }); }); chartIds.forEach((id) => { const chart = company.chartCatalog[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, id, company, 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, view: entry.view || undefined }); 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);