feat: 搭建二期项目架构

This commit is contained in:
R524809
2026-08-28 09:30:13 +08:00
parent 1378cd30eb
commit f0a785f8f8
9 changed files with 714 additions and 400 deletions
+88 -86
View File
@@ -1,98 +1,100 @@
import * as echarts from 'echarts';
import fs from 'fs';
// 项目根目录(从 test/regress.mjs 向上一层,或直接指定绝对路径)
const P = '/Users/joey/sites/vest-tools/echart-demo/';
const {parseCSV}=await import(P+'js/csv.js');
const all=[
['inventory','inventoryChart','data/inventory.csv'],
['margin','marginChart','data/margin.csv'],
['twoline','twoLineChart','data/margin.csv'],
['growth','growthChart','data/growth.csv'],
['fx','fxChart','data/fx.csv'],
['ppmt_assets','assetsChart','data/ppmt_assets.csv'],
['ppmt_profit','profitChart','data/ppmt_profit.csv'],
['ppmt_ip','ipChart','data/ppmt_ip.csv'],
['ppmt_category','categoryChart','data/ppmt_category.csv'],
['ppmt_sankey','sankeyChart','data/ppmt_sankey_2026h1.csv'],
['ppmt_region_yoy','regionChannelYoyChart','data/ppmt_region_channel_yoy.csv'],
['ppmt_growth','ppmtGrowthChart','data/ppmt_growth.csv'],
['ppmt_margins','ppmtMarginsChart','data/ppmt_margins.csv'],
['ppmt_bridge','ppmtBridgeChart','data/ppmt_margin_bridge.csv'],
['ppmt_core_profit','ppmtCoreProfitChart','data/ppmt_core_profit.csv'],
['ppmt_core_margin','ppmtCoreMarginChart','data/ppmt_core_margin.csv'],
['ppmt_ip_share','ppmtIpShareChart','data/ppmt_ip_share_2026h1.csv'],
['ppmt_ip_share_history','ppmtIpShareHistoryChart','data/ppmt_ip_share_history.csv'],
['ppmt_ip_ex_monsters','ppmtIpExMonstersChart','data/ppmt_ip_ex_monsters.csv'],
];
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';
// 可传图表名只跑指定的(改一张图不用全量回归):
// node test/regress.mjs ppmt_ip_share ppmt_core_margin
const only=process.argv.slice(2);
const targets=only.length>0 ? all.filter(([f])=>only.includes(f)) : all;
if(only.length>0){
const missing=only.filter(f=>!all.some(([name])=>name===f));
if(missing.length>0) console.log(`⚠ 未找到图表:${missing.join(', ')}`);
}
// 样式规范断言:从核心利润/利润率拆解图总结的标准
const STYLE_RULES={
legendTop:85, // 固定值:副标题到图例约25px
gridTop:145, // 固定值:图例到绘图区约60px
lineSeries:{ // 折线标准样式(空心圆点)
symbolSize:9,
borderWidth:2.5,
fill:'#fff', // 白心
},
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 },
};
let ok=0,fail=0,styleWarnings=[];
for(const [f,fn,csv] of targets){
try{
const mod=await import(P+`js/charts/${f}.js`);
const rows=parseCSV(fs.readFileSync(P+csv,'utf8'));
const c=echarts.init(null,null,{renderer:'svg',ssr:true,width:1100,height:720});
const opt=mod[fn](rows,{width:1100,height:720});
c.setOption(opt);
const svg=c.renderToSVGString();
// 1. SVG 格式检查(字体引号)
const badAttr=/style="[^"]*"[^"=>\s][^>]*>/.test(svg.slice(0,3000));
// 2. 样式规范检查(跳过无图例的图表)
if(opt.legend?.top!==undefined){
if(opt.legend.top!==STYLE_RULES.legendTop){
styleWarnings.push(`${f}: legend.top=${opt.legend.top} (期望${STYLE_RULES.legendTop})`);
}
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(opt.grid?.top!==undefined && opt.legend?.top!==undefined){
if(opt.grid.top!==STYLE_RULES.gridTop){
styleWarnings.push(`${f}: grid.top=${opt.grid.top} (期望${STYLE_RULES.gridTop})`);
}
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
throw new Error(`manifest 图表 ${chart.id} 的 defaultView 无效`);
}
// 3. 折线圆点样式检查(在 SVG 里验证 symbolSize 和 borderWidth)
const circles=[...svg.matchAll(/<path d="M1 0A1[^"]*" transform="matrix\(([\d.]+),0,0,[\d.]+,[^)]+\)" fill="#fff" stroke="[^"]*" stroke-width="([\d.]+)"/g)];
if(circles.length>0){
const scale=+circles[0][1], sw=+circles[0][2];
const symbolSize=scale*2, borderWidth=sw*scale;
if(Math.abs(symbolSize-STYLE_RULES.lineSeries.symbolSize)>0.1){
styleWarnings.push(`${f}: 折线 symbolSize=${symbolSize.toFixed(1)} (期望${STYLE_RULES.lineSeries.symbolSize})`);
}
if(Math.abs(borderWidth-STYLE_RULES.lineSeries.borderWidth)>0.1){
styleWarnings.push(`${f}: 折线 borderWidth=${borderWidth.toFixed(1)} (期望${STYLE_RULES.lineSeries.borderWidth})`);
}
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`);
}
console.log(`${badAttr?'⚠':'✓'} ${f.padEnd(17)} ${String(svg.length).padStart(6)}B`);
ok++; c.dispose();
}catch(e){console.log(`✗ ${f.padEnd(17)} ${e.message}`);fail++;}
sources.forEach(({ view, csv }) => entries.push({ ...chart, company, tab, view, csv }));
})));
return entries;
}
console.log(`\n${ok} 张成功 / ${fail} 张失败`);
if(styleWarnings.length>0){
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(/<path d="M1 0A1[^"]*" transform="matrix\(([\d.]+),0,0,[\d.]+,[^)]+\)" fill="#fff" stroke="[^"]*" stroke-width="([\d.]+)"/g)];
if (circles.length > 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(w=>console.log(` ${w}`));
}else{
styleWarnings.forEach((warning) => console.log(` ${warning}`));
} else {
console.log('✓ 样式规范全部符合');
}
process.exit(fail>0?1:0);
process.exit(fail > 0 || unknown.length > 0 ? 1 : 0);