feat: 搭建二期项目架构
This commit is contained in:
@@ -4,9 +4,6 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# V2 运行时数据(SQLite + 本地媒体)
|
|
||||||
data/
|
|
||||||
|
|
||||||
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
|
# 反编译参考资料(约 40 个 bundle),设计结论已写入 docs/extension/plan.md §2
|
||||||
reference/
|
reference/
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ php -S localhost:8899
|
|||||||
|
|
||||||
### 2. 打开浏览器
|
### 2. 打开浏览器
|
||||||
|
|
||||||
访问 **http://localhost:8899**,会看到 5 张示例图表和 11 张泡泡玛特财报图表:
|
访问 **http://localhost:8899**。页面左侧选择公司,中间用 Tab 切换分析维度,右侧可跳转到当前分类的图表。
|
||||||
|
|
||||||
|
当前 manifest 内登记了 5 张示例图表和 14 张泡泡玛特财报图表:
|
||||||
|
|
||||||
**示例图表:**
|
**示例图表:**
|
||||||
1. **存货、营收与存货周转天数** — 双柱 + 折线,双 Y 轴
|
1. **存货、营收与存货周转天数** — 双柱 + 折线,双 Y 轴
|
||||||
@@ -50,7 +52,7 @@ php -S localhost:8899
|
|||||||
|
|
||||||
### 3. 修改数据
|
### 3. 修改数据
|
||||||
|
|
||||||
编辑 `data/*.csv`,保存后点击页面右上角的 **「重新加载数据」** 按钮。
|
编辑对应公司的 CSV,保存后点击页面右上角的 **「重新加载数据」** 按钮。数据目录按公司划分,例如 `data/ppmt/ppmt_profit.csv`、`data/demo/inventory.csv`。
|
||||||
|
|
||||||
CSV 格式:首行是表头,逗号分隔,数字列会自动转换。示例:
|
CSV 格式:首行是表头,逗号分隔,数字列会自动转换。示例:
|
||||||
|
|
||||||
@@ -69,22 +71,20 @@ period,inventory,revenue,turnover_days
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
echart-demo/
|
smart-fin-chart/
|
||||||
├── index.html # 主页面
|
├── index.html # 主页面
|
||||||
├── data/ # 数据文件
|
├── manifest.json # 公司、Tab、图表、CSV 的唯一配置来源
|
||||||
│ ├── inventory.csv # 图 1-5:示例数据
|
├── data/ # 数据文件(按公司划分)
|
||||||
│ ├── margin.csv
|
│ ├── demo/
|
||||||
│ ├── growth.csv
|
│ └── ppmt/
|
||||||
│ ├── fx.csv
|
|
||||||
│ ├── ppmt.xlsx # 泡泡玛特原始财报
|
|
||||||
│ └── ppmt_*.csv # 图 6-16:从 xlsx 提取的各口径数据
|
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── main.js # 入口:CHARTS 清单、渲染、导出 PNG
|
│ ├── main.js # 入口:读取 manifest、渲染、导出 PNG
|
||||||
|
│ ├── ui.js # 三栏 UI 构建
|
||||||
│ ├── csv.js # CSV 解析工具
|
│ ├── csv.js # CSV 解析工具
|
||||||
│ ├── theme.js # 主题:配色、字体、水印、共享系列构造器
|
│ ├── theme.js # 主题:配色、字体、水印、共享系列构造器
|
||||||
│ └── charts/ # 一图一文件,共 16 张
|
│ └── charts/ # 图表构建函数与 registry
|
||||||
├── test/
|
├── test/
|
||||||
│ └── regress.mjs # 回归测试:SSR 渲染 16 张图 + 样式规范断言
|
│ └── regress.mjs # 回归测试:遍历 manifest + 样式规范断言
|
||||||
└── css/
|
└── css/
|
||||||
└── style.css # 页面样式
|
└── style.css # 页面样式
|
||||||
```
|
```
|
||||||
@@ -95,13 +95,13 @@ echart-demo/
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
用 ECharts 的 SSR 模式把 16 张图渲染成 SVG,断言:
|
用 ECharts 的 SSR 模式遍历 manifest 中的图表并渲染成 SVG,断言:
|
||||||
|
|
||||||
- `legend.top` = 85、`grid.top` = 145(统一间距)
|
- `legend.top` = 85、`grid.top` = 145(统一间距)
|
||||||
- 折线 `symbolSize` = 9、`borderWidth` = 2.5(空心圆点规范)
|
- 折线 `symbolSize` = 9、`borderWidth` = 2.5(空心圆点规范)
|
||||||
- SVG 属性未被字体名里的引号提前闭合
|
- SVG 属性未被字体名里的引号提前闭合
|
||||||
|
|
||||||
加新图后往 `regress.mjs` 顶部的 `all` 数组加一行,否则不会被测到。
|
回归测试会遍历 `manifest.json`,不用再手工维护图表清单。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,9 +138,9 @@ A:点击页面右上角的 **「重新加载数据」** 按钮。普通刷新
|
|||||||
A:
|
A:
|
||||||
1. 在 `data/` 下创建新 CSV
|
1. 在 `data/` 下创建新 CSV
|
||||||
2. 在 `js/charts/` 下创建新 `.js`,从 `theme.js` 取共享配置(`lineSeries()`、`barSeries()`、`standardLegend()`、`periodAxis`),不要手写等价值
|
2. 在 `js/charts/` 下创建新 `.js`,从 `theme.js` 取共享配置(`lineSeries()`、`barSeries()`、`standardLegend()`、`periodAxis`),不要手写等价值
|
||||||
3. 在 `js/main.js` 的 `CHARTS` 数组里加一行
|
3. 在 `js/charts/index.js` 注册 chart key
|
||||||
4. 在 `index.html` 加一个 `<div class="card">` 容器
|
4. 在 `manifest.json` 对应公司与 Tab 中添加图表配置
|
||||||
5. 在 `test/regress.mjs` 的 `all` 数组里加一行,然后跑 `npm test`
|
5. 跑 `node test/regress.mjs <chart-id>`;浏览器检查标签、颜色和脚注
|
||||||
|
|
||||||
**Q:能改水印文字吗?**
|
**Q:能改水印文字吗?**
|
||||||
A:编辑 `js/theme.js` 的 `WATERMARK_TEXT` 常量。
|
A:编辑 `js/theme.js` 的 `WATERMARK_TEXT` 常量。
|
||||||
|
|||||||
+166
-48
@@ -1,101 +1,219 @@
|
|||||||
:root {
|
:root {
|
||||||
--ink: #333;
|
--ink: #333;
|
||||||
--grey: #8c8c8c;
|
--muted: #8c8c8c;
|
||||||
--line: #e8e8e8;
|
--line: #e8e8e8;
|
||||||
--bg: #f5f4f1;
|
--bg: #f5f4f1;
|
||||||
|
--surface: #fff;
|
||||||
--accent: #6b4a2b;
|
--accent: #6b4a2b;
|
||||||
|
--accent-soft: #f0e9e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 32px 24px 64px;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
button {
|
||||||
max-width: 1180px;
|
font: inherit;
|
||||||
margin: 0 auto 28px;
|
font-size: 13px;
|
||||||
display: flex;
|
color: var(--ink);
|
||||||
align-items: baseline;
|
cursor: pointer;
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
.app-header {
|
||||||
margin: 0;
|
min-height: 76px;
|
||||||
font-size: 22px;
|
padding: 16px 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand h1,
|
||||||
|
.app-brand p,
|
||||||
|
.card-title,
|
||||||
|
.sidebar-title { margin: 0; }
|
||||||
|
|
||||||
|
.app-brand h1 { font-size: 21px; font-weight: 600; }
|
||||||
|
.app-brand p { margin-top: 4px; color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.reload-button,
|
||||||
|
.export-button {
|
||||||
|
padding: 7px 13px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
transition: border-color .15s, color .15s, background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-button {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reload-button:hover { opacity: .88; }
|
||||||
|
.export-button:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 184px minmax(0, 1fr) 184px;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 24px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
align-self: start;
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
padding: 14px 10px;
|
||||||
|
background: rgba(255, 255, 255, .58);
|
||||||
|
border: 1px solid rgba(232, 232, 232, .9);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
padding: 0 8px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
.company-list,
|
||||||
margin: 0;
|
.anchor-list { display: grid; gap: 3px; }
|
||||||
color: var(--grey);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
.company-button,
|
||||||
font-family: inherit;
|
.anchor-button,
|
||||||
font-size: 13px;
|
.tab-button,
|
||||||
padding: 6px 14px;
|
.view-button {
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
background: transparent;
|
||||||
color: var(--ink);
|
text-align: left;
|
||||||
cursor: pointer;
|
transition: background .15s, color .15s;
|
||||||
transition: border-color .15s, color .15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
.company-button {
|
||||||
border-color: var(--accent);
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-button:hover,
|
||||||
|
.anchor-button:hover,
|
||||||
|
.tab-button:hover { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.company-button[data-active="true"],
|
||||||
|
.tab-button[data-active="true"] {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
#btn-reload {
|
.anchor-button {
|
||||||
margin-left: auto;
|
padding: 6px 8px;
|
||||||
background: var(--accent);
|
color: #5f5f5f;
|
||||||
color: #fff;
|
font-size: 12px;
|
||||||
border-color: var(--accent);
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
#btn-reload:hover {
|
.content { min-width: 0; }
|
||||||
opacity: .88;
|
|
||||||
color: #fff;
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 5px;
|
||||||
|
background: rgba(255, 255, 255, .6);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-button { padding: 7px 11px; }
|
||||||
|
|
||||||
|
.cards { display: grid; gap: 28px; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
max-width: 1180px;
|
overflow: hidden;
|
||||||
margin: 0 auto 28px;
|
background: var(--surface);
|
||||||
background: #fff;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .06);
|
||||||
overflow: hidden;
|
scroll-margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bar {
|
.card-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
min-height: 52px;
|
||||||
|
padding: 9px 16px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bar .src {
|
.card-heading { min-width: 0; margin-right: auto; }
|
||||||
font-size: 12px;
|
.card-title { font-size: 14px; font-weight: 600; }
|
||||||
color: var(--grey);
|
|
||||||
|
.src {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-bar button { margin-left: auto; }
|
.view-switcher {
|
||||||
|
display: flex;
|
||||||
|
padding: 2px;
|
||||||
|
background: #f4f2ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-button { padding: 4px 7px; color: var(--muted); font-size: 12px; }
|
||||||
|
.view-button[data-active="true"] { color: var(--accent); background: var(--surface); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); }
|
||||||
|
|
||||||
.chart { width: 100%; }
|
.chart { width: 100%; }
|
||||||
|
|
||||||
.chart-error {
|
.chart-error,
|
||||||
padding: 40px;
|
.empty-state,
|
||||||
color: #c0392b;
|
.sidebar-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 36px 20px;
|
||||||
|
color: #a63f32;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-empty { padding: 8px; color: var(--muted); text-align: left; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 1040px) {
|
||||||
|
.app-layout { grid-template-columns: 160px minmax(0, 1fr); }
|
||||||
|
.chart-sidebar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-header { padding: 14px 16px; }
|
||||||
|
.app-brand p { display: none; }
|
||||||
|
.app-layout { display: block; padding: 16px 12px 40px; }
|
||||||
|
.sidebar { position: static; margin-bottom: 12px; }
|
||||||
|
.company-sidebar { padding: 7px; }
|
||||||
|
.company-sidebar .sidebar-title { display: none; }
|
||||||
|
.company-list { display: flex; overflow-x: auto; }
|
||||||
|
.company-button { flex: 0 0 auto; width: auto; }
|
||||||
|
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
.tab-button { flex: 0 0 auto; }
|
||||||
|
.card-bar { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.export-button { margin-left: auto; }
|
||||||
|
.chart { min-width: 560px; }
|
||||||
|
.card { overflow-x: auto; }
|
||||||
|
}
|
||||||
|
|||||||
+2
-167
@@ -8,172 +8,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
<header>
|
<script type="module" src="js/main.js"></script>
|
||||||
<h1>财报图表</h1>
|
|
||||||
<p>改 <code>data/*.csv</code> 后点「重新加载」即可更新</p>
|
|
||||||
<button id="btn-reload">重新加载数据</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/inventory.csv</span>
|
|
||||||
<button data-export="chart-inventory" data-filename="存货与周转天数">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-inventory" style="height:620px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/margin.csv</span>
|
|
||||||
<button data-export="chart-margin" data-filename="利润率与费用率拆解">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-margin" style="height:620px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/margin.csv(毛利率 + 净利率两列)</span>
|
|
||||||
<button data-export="chart-twoline" data-filename="毛利率与非国际净利率">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-twoline" style="height:620px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/growth.csv</span>
|
|
||||||
<button data-export="chart-growth" data-filename="营收到利润的增速对比">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-growth" style="height:700px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/fx.csv</span>
|
|
||||||
<button data-export="chart-fx" data-filename="人民币汇率两种口径">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-fx" style="height:780px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr style="margin: 48px auto; max-width: 1180px; border: none; border-top: 2px solid #e0ddd5;">
|
|
||||||
|
|
||||||
<header style="margin-top: 32px;">
|
|
||||||
<h1>📊 泡泡玛特财报分析</h1>
|
|
||||||
<p>基于 <code>data/ppmt.xlsx</code> 自动生成</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_assets.csv</span>
|
|
||||||
<button data-export="chart-ppmt-assets" data-filename="泡泡玛特资产结构">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-assets" style="height:680px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_profit.csv</span>
|
|
||||||
<button data-export="chart-ppmt-profit" data-filename="泡泡玛特盈利能力">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-profit" style="height:680px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_ip.csv</span>
|
|
||||||
<button data-export="chart-ppmt-ip" data-filename="泡泡玛特IP收入趋势">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-ip" style="height:720px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_category.csv</span>
|
|
||||||
<button data-export="chart-ppmt-category" data-filename="泡泡玛特品类结构">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-category" style="height:680px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_sankey_2026h1.csv</span>
|
|
||||||
<button data-export="chart-ppmt-sankey" data-filename="泡泡玛特利润流向2026H1">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-sankey" style="height:800px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_region_channel_yoy.csv</span>
|
|
||||||
<button data-export="chart-ppmt-region-yoy" data-filename="泡泡玛特区域渠道同比增速">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-region-yoy" style="height:620px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_growth.csv</span>
|
|
||||||
<button data-export="chart-ppmt-growth" data-filename="泡泡玛特营收到利润增速对比">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-growth" style="height:700px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_margins.csv</span>
|
|
||||||
<button data-export="chart-ppmt-margins" data-filename="泡泡玛特利润率与费用率拆解">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-margins" style="height:680px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_margin_bridge.csv</span>
|
|
||||||
<button data-export="chart-ppmt-bridge" data-filename="泡泡玛特净利率同比拆解">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-bridge" style="height:700px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_core_profit.csv</span>
|
|
||||||
<button data-export="chart-ppmt-core" data-filename="泡泡玛特核心利润及同比增速">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-core" style="height:720px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_core_margin.csv</span>
|
|
||||||
<button data-export="chart-ppmt-core-margin" data-filename="泡泡玛特核心利润率">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-core-margin" style="height:680px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_ip_share_2026h1.csv</span>
|
|
||||||
<button data-export="chart-ppmt-ip-share" data-filename="泡泡玛特IP收入占比2026H1">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-ip-share" style="height:720px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_ip_share_history.csv</span>
|
|
||||||
<button data-export="chart-ppmt-ip-share-history" data-filename="泡泡玛特各IP半年收入占比">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-ip-share-history" style="height:700px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-bar">
|
|
||||||
<span class="src">data/ppmt_ip_ex_monsters.csv</span>
|
|
||||||
<button data-export="chart-ppmt-ip-ex-monsters" data-filename="泡泡玛特除MONSTERS外IP收入及同比">导出 PNG</button>
|
|
||||||
</div>
|
|
||||||
<div class="chart" id="chart-ppmt-ip-ex-monsters" style="height:700px"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="js/main.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { inventoryChart } from './inventory.js';
|
||||||
|
import { marginChart } from './margin.js';
|
||||||
|
import { twoLineChart } from './twoline.js';
|
||||||
|
import { growthChart } from './growth.js';
|
||||||
|
import { fxChart } from './fx.js';
|
||||||
|
import { assetsChart } from './ppmt_assets.js';
|
||||||
|
import { profitChart } from './ppmt_profit.js';
|
||||||
|
import { ipChart } from './ppmt_ip.js';
|
||||||
|
import { categoryChart } from './ppmt_category.js';
|
||||||
|
import { sankeyChart } from './ppmt_sankey.js';
|
||||||
|
import { regionChannelYoyChart } from './ppmt_region_yoy.js';
|
||||||
|
import { ppmtGrowthChart } from './ppmt_growth.js';
|
||||||
|
import { ppmtMarginsChart } from './ppmt_margins.js';
|
||||||
|
import { ppmtBridgeChart } from './ppmt_bridge.js';
|
||||||
|
import { ppmtCoreProfitChart } from './ppmt_core_profit.js';
|
||||||
|
import { ppmtCoreMarginChart } from './ppmt_core_margin.js';
|
||||||
|
import { ppmtIpShareChart } from './ppmt_ip_share.js';
|
||||||
|
import { ppmtIpShareHistoryChart } from './ppmt_ip_share_history.js';
|
||||||
|
import { ppmtIpExMonstersChart } from './ppmt_ip_ex_monsters.js';
|
||||||
|
|
||||||
|
/** 图表构建函数注册表。manifest 只引用这里的 key,不直接引用 JS 函数。 */
|
||||||
|
export const chartRegistry = {
|
||||||
|
'demo-inventory': inventoryChart,
|
||||||
|
'demo-margin': marginChart,
|
||||||
|
'demo-twoline': twoLineChart,
|
||||||
|
'demo-growth': growthChart,
|
||||||
|
'demo-fx': fxChart,
|
||||||
|
'ppmt-assets': assetsChart,
|
||||||
|
'ppmt-profit': profitChart,
|
||||||
|
'ppmt-ip': ipChart,
|
||||||
|
'ppmt-category': categoryChart,
|
||||||
|
'ppmt-sankey': sankeyChart,
|
||||||
|
'ppmt-region-yoy': regionChannelYoyChart,
|
||||||
|
'ppmt-growth': ppmtGrowthChart,
|
||||||
|
'ppmt-margins': ppmtMarginsChart,
|
||||||
|
'ppmt-bridge': ppmtBridgeChart,
|
||||||
|
'ppmt-core-profit': ppmtCoreProfitChart,
|
||||||
|
'ppmt-core-margin': ppmtCoreMarginChart,
|
||||||
|
'ppmt-ip-share': ppmtIpShareChart,
|
||||||
|
'ppmt-ip-share-history': ppmtIpShareHistoryChart,
|
||||||
|
'ppmt-ip-ex-monsters': ppmtIpExMonstersChart,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getChartBuilder(key) {
|
||||||
|
return chartRegistry[key];
|
||||||
|
}
|
||||||
+171
-78
@@ -1,99 +1,192 @@
|
|||||||
import { loadCSV } from './csv.js';
|
import { loadCSV } from './csv.js';
|
||||||
import { inventoryChart } from './charts/inventory.js';
|
import { getChartBuilder } from './charts/index.js';
|
||||||
import { marginChart } from './charts/margin.js';
|
import {
|
||||||
import { twoLineChart } from './charts/twoline.js';
|
createAppShell,
|
||||||
import { growthChart } from './charts/growth.js';
|
renderAnchors,
|
||||||
import { fxChart } from './charts/fx.js';
|
renderCards,
|
||||||
import { assetsChart } from './charts/ppmt_assets.js';
|
renderCompanies,
|
||||||
import { profitChart } from './charts/ppmt_profit.js';
|
renderTabs,
|
||||||
import { ipChart } from './charts/ppmt_ip.js';
|
showChartError,
|
||||||
import { categoryChart } from './charts/ppmt_category.js';
|
} from './ui.js';
|
||||||
import { sankeyChart } from './charts/ppmt_sankey.js';
|
|
||||||
import { regionChannelYoyChart } from './charts/ppmt_region_yoy.js';
|
|
||||||
import { ppmtGrowthChart } from './charts/ppmt_growth.js';
|
|
||||||
import { ppmtMarginsChart } from './charts/ppmt_margins.js';
|
|
||||||
import { ppmtBridgeChart } from './charts/ppmt_bridge.js';
|
|
||||||
import { ppmtCoreProfitChart } from './charts/ppmt_core_profit.js';
|
|
||||||
import { ppmtCoreMarginChart } from './charts/ppmt_core_margin.js';
|
|
||||||
import { ppmtIpShareChart } from './charts/ppmt_ip_share.js';
|
|
||||||
import { ppmtIpShareHistoryChart } from './charts/ppmt_ip_share_history.js';
|
|
||||||
import { ppmtIpExMonstersChart } from './charts/ppmt_ip_ex_monsters.js';
|
|
||||||
|
|
||||||
/** 图表清单:加新图只需在这里加一行 */
|
|
||||||
const CHARTS = [
|
|
||||||
// 示例图表
|
|
||||||
{ id: 'chart-inventory', csv: 'data/inventory.csv', build: inventoryChart, height: 620 },
|
|
||||||
{ id: 'chart-margin', csv: 'data/margin.csv', build: marginChart, height: 620 },
|
|
||||||
{ id: 'chart-twoline', csv: 'data/margin.csv', build: twoLineChart, height: 620 },
|
|
||||||
{ id: 'chart-growth', csv: 'data/growth.csv', build: growthChart, height: 700 },
|
|
||||||
{ id: 'chart-fx', csv: 'data/fx.csv', build: fxChart, height: 780 },
|
|
||||||
// 泡泡玛特图表
|
|
||||||
{ id: 'chart-ppmt-assets', csv: 'data/ppmt_assets.csv', build: assetsChart, height: 680 },
|
|
||||||
{ id: 'chart-ppmt-profit', csv: 'data/ppmt_profit.csv', build: profitChart, height: 680 },
|
|
||||||
{ id: 'chart-ppmt-ip', csv: 'data/ppmt_ip.csv', build: ipChart, height: 720 },
|
|
||||||
{ id: 'chart-ppmt-category', csv: 'data/ppmt_category.csv', build: categoryChart, height: 680 },
|
|
||||||
{ id: 'chart-ppmt-sankey', csv: 'data/ppmt_sankey_2026h1.csv', build: sankeyChart, height: 800 },
|
|
||||||
{ id: 'chart-ppmt-region-yoy', csv: 'data/ppmt_region_channel_yoy.csv', build: regionChannelYoyChart, height: 620 },
|
|
||||||
{ id: 'chart-ppmt-growth', csv: 'data/ppmt_growth.csv', build: ppmtGrowthChart, height: 700 },
|
|
||||||
{ id: 'chart-ppmt-margins', csv: 'data/ppmt_margins.csv', build: ppmtMarginsChart, height: 680 },
|
|
||||||
{ id: 'chart-ppmt-bridge', csv: 'data/ppmt_margin_bridge.csv', build: ppmtBridgeChart, height: 700 },
|
|
||||||
{ id: 'chart-ppmt-core', csv: 'data/ppmt_core_profit.csv', build: ppmtCoreProfitChart, height: 720 },
|
|
||||||
{ id: 'chart-ppmt-core-margin', csv: 'data/ppmt_core_margin.csv', build: ppmtCoreMarginChart, height: 680 },
|
|
||||||
{ id: 'chart-ppmt-ip-share', csv: 'data/ppmt_ip_share_2026h1.csv', build: ppmtIpShareChart, height: 720 },
|
|
||||||
{ id: 'chart-ppmt-ip-share-history', csv: 'data/ppmt_ip_share_history.csv', build: ppmtIpShareHistoryChart, height: 700 },
|
|
||||||
{ id: 'chart-ppmt-ip-ex-monsters', csv: 'data/ppmt_ip_ex_monsters.csv', build: ppmtIpExMonstersChart, height: 700 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const instances = new Map();
|
const instances = new Map();
|
||||||
|
const activeViews = new Map();
|
||||||
|
let manifest;
|
||||||
|
let shell;
|
||||||
|
let state;
|
||||||
|
let renderVersion = 0;
|
||||||
|
let cacheToken = Date.now();
|
||||||
|
|
||||||
async function render(spec) {
|
function validateManifest(value) {
|
||||||
const el = document.getElementById(spec.id);
|
if (!Array.isArray(value?.companies) || value.companies.length === 0) {
|
||||||
|
throw new Error('manifest 缺少 companies');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartIds = new Set();
|
||||||
|
value.companies.forEach((company) => {
|
||||||
|
if (!company.id || !Array.isArray(company.tabs)) throw new Error('manifest 中存在无效公司配置');
|
||||||
|
const tabIds = new Set();
|
||||||
|
company.tabs.forEach((tab) => {
|
||||||
|
if (!tab.id || tabIds.has(tab.id)) throw new Error(`公司 ${company.id} 的 tab id 重复或为空`);
|
||||||
|
tabIds.add(tab.id);
|
||||||
|
(tab.charts || []).forEach((chart) => {
|
||||||
|
if (!chart.id || chartIds.has(chart.id)) throw new Error(`图表 id 重复或为空:${chart.id || '(空)'}`);
|
||||||
|
if (!getChartBuilder(chart.key)) throw new Error(`图表 ${chart.id} 引用了未注册 key:${chart.key}`);
|
||||||
|
if (Boolean(chart.csv) === Boolean(chart.views)) throw new Error(`图表 ${chart.id} 必须声明 csv 或 views 之一`);
|
||||||
|
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
|
||||||
|
throw new Error(`图表 ${chart.id} 的 defaultView 无效`);
|
||||||
|
}
|
||||||
|
chartIds.add(chart.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompany(companyId) {
|
||||||
|
return manifest.companies.find((company) => company.id === companyId) || manifest.companies[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTab(company, tabId) {
|
||||||
|
return company.tabs.find((tab) => tab.id === tabId) || company.tabs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHash() {
|
||||||
|
const [path = '', query = ''] = window.location.hash.replace(/^#\/?/, '').split('?');
|
||||||
|
const [companyId, tabId, chartId] = path.split('/').filter(Boolean).map(decodeURIComponent);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
return { companyId, tabId, chartId, view: params.get('view') || undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(nextState) {
|
||||||
|
const company = getCompany(nextState.companyId);
|
||||||
|
const tab = getTab(company, nextState.tabId);
|
||||||
|
const chart = tab.charts.find((item) => item.id === nextState.chartId);
|
||||||
|
const view = chart?.views && chart.views[nextState.view]
|
||||||
|
? nextState.view
|
||||||
|
: chart?.defaultView;
|
||||||
|
if (chart?.views && view) activeViews.set(chart.id, view);
|
||||||
|
return { companyId: company.id, tabId: tab.id, chartId: chart?.id, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeHash(nextState) {
|
||||||
|
const path = [nextState.companyId, nextState.tabId, nextState.chartId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join('/');
|
||||||
|
return `#/${path}${nextState.view ? `?view=${encodeURIComponent(nextState.view)}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeAll() {
|
||||||
|
instances.forEach((chart) => chart.dispose());
|
||||||
|
instances.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartSource(chart) {
|
||||||
|
if (!chart.views) return chart.csv;
|
||||||
|
const view = activeViews.get(chart.id) || chart.defaultView;
|
||||||
|
return chart.views[view];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderChart(chart, version) {
|
||||||
|
const el = document.getElementById(`chart-${chart.id}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
const source = chartSource(chart);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加时间戳绕过缓存,改完 CSV 刷新就能看到
|
const rows = await loadCSV(`${source}?t=${cacheToken}`);
|
||||||
const rows = await loadCSV(`${spec.csv}?t=${Date.now()}`);
|
if (version !== renderVersion || !document.body.contains(el)) return;
|
||||||
const chart = instances.get(spec.id) || echarts.init(el, null, { renderer: 'canvas' });
|
|
||||||
instances.set(spec.id, chart);
|
|
||||||
|
|
||||||
|
const instance = echarts.init(el, null, { renderer: 'canvas' });
|
||||||
|
instances.set(chart.id, instance);
|
||||||
const { width, height } = el.getBoundingClientRect();
|
const { width, height } = el.getBoundingClientRect();
|
||||||
chart.setOption(spec.build(rows, { width, height }), true);
|
instance.setOption(getChartBuilder(chart.key)(rows, { width, height }), true);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
el.innerHTML = `<div class="chart-error">${err.message}</div>`;
|
if (version === renderVersion) showChartError(chart.id, error.message);
|
||||||
console.error(spec.id, err);
|
console.error(`渲染图表失败:${chart.id}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAll() {
|
function exportPNG(chartId, filename) {
|
||||||
await Promise.all(CHARTS.map(render));
|
const instance = instances.get(chartId);
|
||||||
|
if (!instance) return;
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
||||||
|
anchor.download = `${filename}.png`;
|
||||||
|
anchor.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出当前图为 PNG,2 倍图,微信公众号里够清晰 */
|
function updateHash(nextState) {
|
||||||
function exportPNG(id, filename) {
|
const hash = serializeHash(nextState);
|
||||||
const chart = instances.get(id);
|
if (window.location.hash === hash) {
|
||||||
if (!chart) return;
|
state = normalizeState(nextState);
|
||||||
const url = chart.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff' });
|
renderCurrent();
|
||||||
const a = document.createElement('a');
|
return;
|
||||||
a.href = url;
|
}
|
||||||
a.download = `${filename}.png`;
|
window.location.hash = hash;
|
||||||
a.click();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function selectAnchor(chartId) {
|
||||||
renderAll();
|
state = { ...state, chartId, view: activeViews.get(chartId) };
|
||||||
|
history.replaceState(null, '', serializeHash(state));
|
||||||
|
document.getElementById(`card-${chartId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('btn-reload').addEventListener('click', renderAll);
|
function renderCurrent() {
|
||||||
|
state = normalizeState(state);
|
||||||
|
const company = getCompany(state.companyId);
|
||||||
|
const tab = getTab(company, state.tabId);
|
||||||
|
const version = ++renderVersion;
|
||||||
|
disposeAll();
|
||||||
|
|
||||||
document.querySelectorAll('[data-export]').forEach((btn) => {
|
renderCompanies(shell.companyNav, manifest.companies, company.id, (companyId) => {
|
||||||
btn.addEventListener('click', () => {
|
updateHash({ companyId });
|
||||||
exportPNG(btn.dataset.export, btn.dataset.filename || btn.dataset.export);
|
});
|
||||||
|
renderTabs(shell.tabs, company.tabs, tab.id, (tabId) => {
|
||||||
|
updateHash({ companyId: company.id, tabId });
|
||||||
|
});
|
||||||
|
renderCards(shell.cards, tab.charts || [], activeViews, {
|
||||||
|
onExport: exportPNG,
|
||||||
|
onSelectView: (chartId, view) => {
|
||||||
|
activeViews.set(chartId, view);
|
||||||
|
updateHash({ companyId: company.id, tabId: tab.id, chartId, view });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderAnchors(shell.chartNav, tab.charts || [], selectAnchor);
|
||||||
|
Promise.all((tab.charts || []).map((chart) => renderChart(chart, version)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('manifest.json');
|
||||||
|
if (!response.ok) throw new Error(`读取 manifest 失败:${response.status}`);
|
||||||
|
manifest = await response.json();
|
||||||
|
validateManifest(manifest);
|
||||||
|
shell = createAppShell(document.getElementById('app'), {
|
||||||
|
onReload: () => {
|
||||||
|
cacheToken = Date.now();
|
||||||
|
renderCurrent();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
state = normalizeState(parseHash());
|
||||||
|
if (!window.location.hash) history.replaceState(null, '', serializeHash(state));
|
||||||
|
renderCurrent();
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('app').textContent = `初始化失败:${error.message}`;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let timer;
|
document.addEventListener('DOMContentLoaded', start);
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
clearTimeout(timer);
|
window.addEventListener('hashchange', () => {
|
||||||
// resize 后水印数量要跟着画布变,所以整体重绘而不是 chart.resize()
|
if (!manifest) return;
|
||||||
timer = setTimeout(renderAll, 200);
|
state = normalizeState(parseHash());
|
||||||
});
|
renderCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!shell) return;
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(renderCurrent, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
function element(tag, className, text) {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (className) el.className = className;
|
||||||
|
if (text !== undefined) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label, { active = false, onClick, className = '' } = {}) {
|
||||||
|
const el = element('button', className, label);
|
||||||
|
el.type = 'button';
|
||||||
|
el.dataset.active = String(active);
|
||||||
|
if (onClick) el.addEventListener('click', onClick);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppShell(root, { onReload }) {
|
||||||
|
root.replaceChildren();
|
||||||
|
|
||||||
|
const header = element('header', 'app-header');
|
||||||
|
const brand = element('div', 'app-brand');
|
||||||
|
brand.append(element('h1', '', '财报图表'), element('p', '', '选择公司与分析维度,数据更新后可重新加载。'));
|
||||||
|
header.append(brand, button('重新加载数据', { onClick: onReload, className: 'reload-button' }));
|
||||||
|
|
||||||
|
const layout = element('div', 'app-layout');
|
||||||
|
const companyNav = element('aside', 'sidebar company-sidebar');
|
||||||
|
const content = element('main', 'content');
|
||||||
|
const tabs = element('nav', 'tabs');
|
||||||
|
const cards = element('div', 'cards');
|
||||||
|
content.append(tabs, cards);
|
||||||
|
const chartNav = element('aside', 'sidebar chart-sidebar');
|
||||||
|
layout.append(companyNav, content, chartNav);
|
||||||
|
root.append(header, layout);
|
||||||
|
|
||||||
|
return { companyNav, tabs, cards, chartNav };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCompanies(container, companies, activeCompanyId, onSelect) {
|
||||||
|
container.replaceChildren(element('h2', 'sidebar-title', '公司'));
|
||||||
|
const list = element('div', 'company-list');
|
||||||
|
companies.forEach((company) => {
|
||||||
|
list.append(button(company.name, {
|
||||||
|
active: company.id === activeCompanyId,
|
||||||
|
onClick: () => onSelect(company.id),
|
||||||
|
className: 'company-button',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
container.append(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTabs(container, tabs, activeTabId, onSelect) {
|
||||||
|
container.replaceChildren();
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
container.append(button(tab.name, {
|
||||||
|
active: tab.id === activeTabId,
|
||||||
|
onClick: () => onSelect(tab.id),
|
||||||
|
className: 'tab-button',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCards(container, charts, activeViews, { onExport, onSelectView }) {
|
||||||
|
container.replaceChildren();
|
||||||
|
if (!charts.length) {
|
||||||
|
container.append(element('p', 'empty-state', '该分类暂时没有图表。'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
const card = element('section', 'card');
|
||||||
|
card.id = `card-${chart.id}`;
|
||||||
|
const bar = element('div', 'card-bar');
|
||||||
|
const heading = element('div', 'card-heading');
|
||||||
|
heading.append(element('h2', 'card-title', chart.title));
|
||||||
|
|
||||||
|
const viewNames = Object.keys(chart.views || {});
|
||||||
|
const activeView = activeViews.get(chart.id) || chart.defaultView || viewNames[0];
|
||||||
|
const source = chart.views ? chart.views[activeView] : chart.csv;
|
||||||
|
heading.append(element('span', 'src', source));
|
||||||
|
bar.append(heading);
|
||||||
|
|
||||||
|
if (viewNames.length > 1) {
|
||||||
|
const switcher = element('div', 'view-switcher');
|
||||||
|
const labels = { annual: '年度', halfyear: '半年', quarter: '季度' };
|
||||||
|
viewNames.forEach((view) => {
|
||||||
|
switcher.append(button(labels[view] || view, {
|
||||||
|
active: view === activeView,
|
||||||
|
onClick: () => onSelectView(chart.id, view),
|
||||||
|
className: 'view-button',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
bar.append(switcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.append(button('导出 PNG', {
|
||||||
|
onClick: () => onExport(chart.id, chart.filename || chart.title),
|
||||||
|
className: 'export-button',
|
||||||
|
}));
|
||||||
|
const chartEl = element('div', 'chart');
|
||||||
|
chartEl.id = `chart-${chart.id}`;
|
||||||
|
chartEl.style.height = `${chart.height || 680}px`;
|
||||||
|
card.append(bar, chartEl);
|
||||||
|
container.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderAnchors(container, charts, onSelect) {
|
||||||
|
container.replaceChildren(element('h2', 'sidebar-title', '当前图表'));
|
||||||
|
const list = element('div', 'anchor-list');
|
||||||
|
charts.forEach((chart) => {
|
||||||
|
container.append(button(chart.title, {
|
||||||
|
onClick: () => onSelect(chart.id),
|
||||||
|
className: 'anchor-button',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
if (!charts.length) container.append(element('p', 'sidebar-empty', '暂无图表'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showChartError(chartId, message) {
|
||||||
|
const chartEl = document.getElementById(`chart-${chartId}`);
|
||||||
|
if (!chartEl) return;
|
||||||
|
chartEl.replaceChildren(element('div', 'chart-error', message));
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"companies": [
|
||||||
|
{
|
||||||
|
"id": "ppmt",
|
||||||
|
"name": "泡泡玛特",
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "overview",
|
||||||
|
"name": "概览",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-profit", "key": "ppmt-profit", "title": "盈利能力", "csv": "data/ppmt/ppmt_profit.csv", "height": 680, "filename": "泡泡玛特盈利能力" },
|
||||||
|
{ "id": "ppmt-sankey", "key": "ppmt-sankey", "title": "利润流向", "csv": "data/ppmt/ppmt_sankey_2026h1.csv", "height": 800, "filename": "泡泡玛特利润流向2026H1" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "growth",
|
||||||
|
"name": "成长性",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-growth", "key": "ppmt-growth", "title": "营收到利润的增速对比", "csv": "data/ppmt/ppmt_growth.csv", "height": 700, "filename": "泡泡玛特营收到利润增速对比" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profitability",
|
||||||
|
"name": "盈利能力",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-core-profit", "key": "ppmt-core-profit", "title": "核心利润及同比增速", "csv": "data/ppmt/ppmt_core_profit.csv", "height": 720, "filename": "泡泡玛特核心利润及同比增速" },
|
||||||
|
{ "id": "ppmt-core-margin", "key": "ppmt-core-margin", "title": "核心利润率", "csv": "data/ppmt/ppmt_core_margin.csv", "height": 680, "filename": "泡泡玛特核心利润率" },
|
||||||
|
{ "id": "ppmt-bridge", "key": "ppmt-bridge", "title": "净利率同比拆解", "csv": "data/ppmt/ppmt_margin_bridge.csv", "height": 700, "filename": "泡泡玛特净利率同比拆解" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "expense",
|
||||||
|
"name": "费用率",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-margins", "key": "ppmt-margins", "title": "利润率与费用率拆解", "csv": "data/ppmt/ppmt_margins.csv", "height": 680, "filename": "泡泡玛特利润率与费用率拆解" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "balance-sheet",
|
||||||
|
"name": "资产负债",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-assets", "key": "ppmt-assets", "title": "资产结构演变", "csv": "data/ppmt/ppmt_assets.csv", "height": 680, "filename": "泡泡玛特资产结构" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ip-category",
|
||||||
|
"name": "IP 与品类",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-ip", "key": "ppmt-ip", "title": "主要 IP 收入趋势", "csv": "data/ppmt/ppmt_ip.csv", "height": 720, "filename": "泡泡玛特IP收入趋势" },
|
||||||
|
{ "id": "ppmt-ip-share", "key": "ppmt-ip-share", "title": "主要 IP 收入占比", "csv": "data/ppmt/ppmt_ip_share_2026h1.csv", "height": 720, "filename": "泡泡玛特IP收入占比2026H1" },
|
||||||
|
{ "id": "ppmt-ip-share-history", "key": "ppmt-ip-share-history", "title": "各 IP 半年收入占比", "csv": "data/ppmt/ppmt_ip_share_history.csv", "height": 700, "filename": "泡泡玛特各IP半年收入占比" },
|
||||||
|
{ "id": "ppmt-ip-ex-monsters", "key": "ppmt-ip-ex-monsters", "title": "除 THE MONSTERS 外的 IP 收入及同比", "csv": "data/ppmt/ppmt_ip_ex_monsters.csv", "height": 700, "filename": "泡泡玛特除MONSTERS外IP收入及同比" },
|
||||||
|
{ "id": "ppmt-category", "key": "ppmt-category", "title": "品类收入结构", "csv": "data/ppmt/ppmt_category.csv", "height": 680, "filename": "泡泡玛特品类结构" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "region-channel",
|
||||||
|
"name": "区域渠道",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "ppmt-region-yoy", "key": "ppmt-region-yoy", "title": "区域渠道同比增速", "csv": "data/ppmt/ppmt_region_channel_yoy.csv", "height": 620, "filename": "泡泡玛特区域渠道同比增速" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "demo",
|
||||||
|
"name": "示例",
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "operations",
|
||||||
|
"name": "营运",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "demo-inventory", "key": "demo-inventory", "title": "存货、营收与存货周转天数", "csv": "data/demo/inventory.csv", "height": 620, "filename": "存货与周转天数" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "profitability",
|
||||||
|
"name": "盈利能力",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "demo-margin", "key": "demo-margin", "title": "利润率与费用率拆解", "csv": "data/demo/margin.csv", "height": 620, "filename": "利润率与费用率拆解" },
|
||||||
|
{ "id": "demo-twoline", "key": "demo-twoline", "title": "毛利率与非国际净利率", "csv": "data/demo/margin.csv", "height": 620, "filename": "毛利率与非国际净利率" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "growth",
|
||||||
|
"name": "成长性",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "demo-growth", "key": "demo-growth", "title": "营收到利润的增速对比", "csv": "data/demo/growth.csv", "height": 700, "filename": "营收到利润的增速对比" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "market",
|
||||||
|
"name": "汇率",
|
||||||
|
"charts": [
|
||||||
|
{ "id": "demo-fx", "key": "demo-fx", "title": "人民币汇率两种口径", "csv": "data/demo/fx.csv", "height": 780, "filename": "人民币汇率两种口径" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+88
-86
@@ -1,98 +1,100 @@
|
|||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
// 项目根目录(从 test/regress.mjs 向上一层,或直接指定绝对路径)
|
import path from 'node:path';
|
||||||
const P = '/Users/joey/sites/vest-tools/echart-demo/';
|
import { fileURLToPath } from 'node:url';
|
||||||
const {parseCSV}=await import(P+'js/csv.js');
|
import { parseCSV } from '../js/csv.js';
|
||||||
const all=[
|
import { getChartBuilder } from '../js/charts/index.js';
|
||||||
['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'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// 可传图表名只跑指定的(改一张图不用全量回归):
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
// node test/regress.mjs ppmt_ip_share ppmt_core_margin
|
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'manifest.json'), 'utf8'));
|
||||||
const only=process.argv.slice(2);
|
const STYLE_RULES = {
|
||||||
const targets=only.length>0 ? all.filter(([f])=>only.includes(f)) : all;
|
legendTop: 85,
|
||||||
if(only.length>0){
|
gridTop: 145,
|
||||||
const missing=only.filter(f=>!all.some(([name])=>name===f));
|
lineSeries: { symbolSize: 9, borderWidth: 2.5 },
|
||||||
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', // 白心
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let ok=0,fail=0,styleWarnings=[];
|
function entriesFromManifest(value) {
|
||||||
for(const [f,fn,csv] of targets){
|
const ids = new Set();
|
||||||
try{
|
const entries = [];
|
||||||
const mod=await import(P+`js/charts/${f}.js`);
|
value.companies.forEach((company) => company.tabs.forEach((tab) => (tab.charts || []).forEach((chart) => {
|
||||||
const rows=parseCSV(fs.readFileSync(P+csv,'utf8'));
|
if (!chart.id || ids.has(chart.id)) throw new Error(`manifest 图表 id 重复或为空:${chart.id || '(空)'}`);
|
||||||
const c=echarts.init(null,null,{renderer:'svg',ssr:true,width:1100,height:720});
|
ids.add(chart.id);
|
||||||
const opt=mod[fn](rows,{width:1100,height:720});
|
if (!getChartBuilder(chart.key)) throw new Error(`manifest 图表 ${chart.id} 引用了未注册 key:${chart.key}`);
|
||||||
c.setOption(opt);
|
if (Boolean(chart.csv) === Boolean(chart.views)) {
|
||||||
const svg=c.renderToSVGString();
|
throw new Error(`manifest 图表 ${chart.id} 必须声明 csv 或 views 之一`);
|
||||||
|
|
||||||
// 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})`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if(opt.grid?.top!==undefined && opt.legend?.top!==undefined){
|
if (chart.views && (!chart.defaultView || !chart.views[chart.defaultView])) {
|
||||||
if(opt.grid.top!==STYLE_RULES.gridTop){
|
throw new Error(`manifest 图表 ${chart.id} 的 defaultView 无效`);
|
||||||
styleWarnings.push(`${f}: grid.top=${opt.grid.top} (期望${STYLE_RULES.gridTop})`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const sources = chart.views
|
||||||
// 3. 折线圆点样式检查(在 SVG 里验证 symbolSize 和 borderWidth)
|
? Object.entries(chart.views).map(([view, csv]) => ({ view, csv }))
|
||||||
const circles=[...svg.matchAll(/<path d="M1 0A1[^"]*" transform="matrix\(([\d.]+),0,0,[\d.]+,[^)]+\)" fill="#fff" stroke="[^"]*" stroke-width="([\d.]+)"/g)];
|
: [{ view: null, csv: chart.csv }];
|
||||||
if(circles.length>0){
|
if (!sources.every(({ csv }) => typeof csv === 'string' && csv.length > 0)) {
|
||||||
const scale=+circles[0][1], sw=+circles[0][2];
|
throw new Error(`manifest 图表 ${chart.id} 缺少 CSV`);
|
||||||
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})`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
sources.forEach(({ view, csv }) => entries.push({ ...chart, company, tab, view, csv }));
|
||||||
console.log(`${badAttr?'⚠':'✓'} ${f.padEnd(17)} ${String(svg.length).padStart(6)}B`);
|
})));
|
||||||
ok++; c.dispose();
|
return entries;
|
||||||
}catch(e){console.log(`✗ ${f.padEnd(17)} ${e.message}`);fail++;}
|
|
||||||
}
|
}
|
||||||
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} 处样式偏离规范:`);
|
console.log(`\n⚠ ${styleWarnings.length} 处样式偏离规范:`);
|
||||||
styleWarnings.forEach(w=>console.log(` ${w}`));
|
styleWarnings.forEach((warning) => console.log(` ${warning}`));
|
||||||
}else{
|
} else {
|
||||||
console.log('✓ 样式规范全部符合');
|
console.log('✓ 样式规范全部符合');
|
||||||
}
|
}
|
||||||
process.exit(fail>0?1:0);
|
|
||||||
|
process.exit(fail > 0 || unknown.length > 0 ? 1 : 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user