Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e15d43ed4 | ||
|
|
59788639cb | ||
|
|
cd7ffab71d | ||
|
|
86669f9cf5 | ||
|
|
2cc6491440 | ||
|
|
94aa37e612 | ||
|
|
606610f8d3 | ||
|
|
7a03ecc063 |
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
logs/
|
||||
data/
|
||||
*.png
|
||||
*.svg
|
||||
@@ -32,7 +32,7 @@ Content-Type: application/json
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|:----:|--------|------|
|
||||
| `data` | string | ✅ | — | CSV 格式数据,`\n` 换行,第一行表头,第一列横坐标 |
|
||||
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` |
|
||||
| `chartType` | string | — | `bar` | 图表类型:`bar` / `line` / `bar-line` / `pie` / `radar` |
|
||||
| `title` | string | — | `""` | 图表标题 |
|
||||
| `theme` | string | — | `default` | 主题风格:`default` / `dark` / `macarons` / `gradient` / `retro` |
|
||||
| `showLegend` | boolean | — | `true` | 显示图例 |
|
||||
@@ -50,6 +50,8 @@ Content-Type: application/json
|
||||
| `leftAxisName` | string | — | `""` | 左轴名称(如 `"销售额(元)"`) |
|
||||
| `rightAxisName` | string | — | `""` | 右轴名称(如 `"增长率(%)"`) |
|
||||
| `seriesTypes` | array | — | `null` | 每系列图表类型,按系列顺序对应,如 `["bar","line"]`(`bar`/`line`/`auto`) |
|
||||
| `seriesAxis` | array | — | `null` | 每系列坐标轴,按系列顺序对应,如 `[0,1]`(`0`=左轴 `1`=右轴,配合 `dualYAxis`;优先于 `rightAxisSeries`) |
|
||||
| `rowsAsSeries` | boolean | — | `false` | 数据方向:`false`=列=系列(第一列是横坐标,每列一个系列);`true`=行=系列(第一行是横坐标,每行一个系列) |
|
||||
| `width` | number | — | `800` | 图片宽度(px) |
|
||||
| `height` | number | — | `500` | 图片高度(px) |
|
||||
| `pixelRatio` | number | — | `2` | 像素倍率(越大越清晰) |
|
||||
@@ -178,10 +180,33 @@ Content-Type: application/json
|
||||
| `gap` | number | 否 | 图间距(默认 24px) |
|
||||
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
||||
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
||||
| `bigTitle` | string | 否 | 整张图片的大标题(可空),居中显示在顶部 |
|
||||
| `legendMode` | string | 否 | 图例方式:`own` 每个小图各自图例(默认)/ `shared-top` 共用一套图例放顶部 / `shared-bottom` 共用一套图例放底部(共用时各子图隐藏自己的图例,系列合并去重) |
|
||||
| `theme` | string | 否 | 大标题/共享图例文字颜色所属主题,默认 `default`(深色用 `dark`) |
|
||||
|
||||
> 兼容旧参数:`chart1` + `chart2` 仍可传(等价于 `charts: [chart1, chart2]`)。
|
||||
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line/pie/radar)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`,以及**数据方向与多类型双轴**:`rowsAsSeries`(行=系列)、`dualYAxis`、`leftAxisName`、`rightAxisName`、`seriesTypes`、`seriesAxis`。
|
||||
|
||||
### 高级示例(大标题 + 底部共享图例 + 行=系列 + 柱状/折线双轴)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:16016/api/combine \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"bigTitle": "经营总览大图",
|
||||
"legendMode": "shared-bottom",
|
||||
"direction": "grid",
|
||||
"cols": 2,
|
||||
"charts": [
|
||||
{"data": "指标, 2023, 2024, 2025\n营收(元), 1200, 1800, 2100\n销量(个), 100, 150, 130",
|
||||
"rowsAsSeries": true, "chartType": "bar", "title": "营收与销量",
|
||||
"dualYAxis": true, "leftAxisName": "元", "rightAxisName": "个",
|
||||
"seriesTypes": ["bar","line"], "seriesAxis": [0,1]},
|
||||
{"data": "产品, Q1, Q2\n手机, 1200, 1800\n平板, 800, 950", "chartType": "bar", "title": "季度销售"}
|
||||
]
|
||||
}' -o combine.png
|
||||
```
|
||||
|
||||
### curl 示例
|
||||
|
||||
@@ -248,7 +273,7 @@ curl http://192.168.0.101:16016/api/health
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "data-chart-tool",
|
||||
"version": "1.1.0",
|
||||
"version": "1.16.0",
|
||||
"endpoints": {
|
||||
"POST /api/chart": "生成图表图片(JSON body)",
|
||||
"GET /api/chart": "生成图表图片(URL 参数)",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
一个简洁强大的数据可视化工具,支持 **Web UI** 和 **API** 两种方式生成精美的对比图表和表格图片。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
- **柱状图** - 支持单系列/多系列对比
|
||||
- **折线图** - 支持平滑曲线、面积填充
|
||||
- **混合图** - 柱状图+折线图组合展示
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
|
||||
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
|
||||
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
|
||||
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线);开启双轴时**图例自动按左右轴分组,放到对应轴边上**(左轴系列图例在左侧、右轴系列图例在右侧,附「左轴/右轴」分组标题),看图一目了然
|
||||
- **X轴设置** - 可填写**横坐标轴名**,**轴名位置可选**(右侧末端 / 轴下方居中),选轴名时自动预留空间不再截字;刻度文字方向**默认水平(0°)**,支持**下拉档位(每10°)** + **手动输入任意度数(0-90°)**,解决长类别名重叠问题
|
||||
- **数据方向(行=系列)** - 支持"行=系列"布局:第一行是横坐标,每行一个系列,每行可独立设置图表类型(柱状/折线)与左右轴量度(如:营收行=柱状左轴,销量行=折线右轴)
|
||||
|
||||
### 📋 表格功能
|
||||
- **表格图片生成** - 根据数据生成精美的表格图片
|
||||
@@ -33,15 +37,17 @@
|
||||
### 🖼️ 多图合并
|
||||
- 将多张图表合并到一张图片中,适合对比/汇总场景,**默认2张,可一键不断添加**(支持删除)
|
||||
- 支持**横排(单行)**、**竖排(单列)**、**多行多列网格**(可设每行列数),一键切换实时预览
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 每张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠,以及**数据方向(行=系列)**、**双Y轴(不同左右量度)**、**X轴设置(轴名+刻度倾斜角)**、**每系列独立图表类型与坐标轴**
|
||||
- **图片大标题** - 可编写整张大图的标题,居中显示在顶部(可留空)
|
||||
- **共用图例** - 图例方式可选:每个小图各自图例 / 共用一套图例放顶部 / 共用一套图例放底部;共用时所有小图的系列合并去重,同名系列只显示一次,各小图隐藏自己的图例
|
||||
- 支持导出 PNG
|
||||
|
||||
### 📡 API 接口
|
||||
- **POST /api/chart** - JSON 请求体生成图表(完整参数支持)
|
||||
- **POST /api/chart** - JSON 请求体生成图表(完整参数支持,含 rowsAsSeries/seriesTypes/seriesAxis/dualYAxis)
|
||||
- **GET /api/chart** - URL 参数生成图表(简单场景)
|
||||
- **POST /api/table** - JSON 请求体生成表格图片
|
||||
- **GET /api/table** - URL 参数生成表格图片
|
||||
- **POST /api/combine** - 多图合并(横排/竖排)生成一张图片
|
||||
- **POST /api/combine** - 多图合并(横排/竖排/网格)生成一张图片,支持 bigTitle(大标题)与 legendMode(own/shared-top/shared-bottom 共享图例)
|
||||
- 返回 PNG 图片,支持自定义分辨率和像素倍率
|
||||
|
||||
### 📥 导出功能
|
||||
|
||||
+81
-2
@@ -11,8 +11,13 @@
|
||||
<div class="app-container">
|
||||
<!-- 顶部标题 -->
|
||||
<header class="app-header">
|
||||
<h1>📊 数据可视化图表生成器</h1>
|
||||
<p class="subtitle">粘贴数据,生成精美图表或表格</p>
|
||||
<div class="header-title">
|
||||
<h1>📊 数据可视化图表生成器</h1>
|
||||
<p class="subtitle">粘贴数据,生成精美图表或表格</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-fav-outline" onclick="openFavorites()">⭐ 收藏区</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
@@ -56,14 +61,60 @@ C, 20, 30, 40</pre>
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
<option value="pie">饼图</option>
|
||||
<option value="radar">雷达图</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>数据方向</label>
|
||||
<select id="dataOrientation" onchange="onDataOrientationChange()">
|
||||
<option value="columns">列=系列(第一列是横坐标,每列一个系列)</option>
|
||||
<option value="rows">行=系列(第一行是横坐标,每行一个系列)</option>
|
||||
</select>
|
||||
<p class="hint-text">「行=系列」示例:<br>指标, 2023, 2024, 2025<br>营收(元), 1200, 1800, 2100<br>销量(个), 100, 150, 130<br>—— 每行可单独设柱状/折线 + 左右轴</p>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="chartTitle" placeholder="图表标题" oninput="updateChart()">
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>X轴设置</label>
|
||||
<div class="config-group">
|
||||
<label>横坐标轴名(可留空)</label>
|
||||
<input type="text" id="xAxisName" placeholder="如:月份 / 年份" oninput="updateChart()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>轴名位置</label>
|
||||
<select id="xAxisNameLoc" onchange="updateChart()">
|
||||
<option value="end">横坐标右侧末端</option>
|
||||
<option value="middle">横坐标轴下方居中</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>刻度文字方向(0°=水平 · 倾斜可防长名重叠)</label>
|
||||
<div class="res-input-row">
|
||||
<select id="xLabelRotate" onchange="onXLabelRotateChange()" style="flex:1">
|
||||
<option value="0">0° 水平</option>
|
||||
<option value="10">10°</option>
|
||||
<option value="20">20°</option>
|
||||
<option value="30">30°</option>
|
||||
<option value="40">40°</option>
|
||||
<option value="50">50°</option>
|
||||
<option value="60">60°</option>
|
||||
<option value="70">70°</option>
|
||||
<option value="80">80°</option>
|
||||
<option value="90">90° 竖直</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
<input type="number" id="xLabelRotateCustom" min="0" max="90" value="0" style="width:70px;display:none" title="手动输入0-90°" oninput="onXLabelRotateCustomInput()">
|
||||
<span class="res-times">°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select id="themeStyle" onchange="syncThemeButtons(this.value); updateChart()">
|
||||
@@ -228,6 +279,7 @@ C, 20, 30, 40</pre>
|
||||
<span class="export-toggle" id="exportToggle">▸</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" onclick="exportImage('png')">📥 下载</button>
|
||||
<button class="btn btn-fav btn-sm" id="btnFavorite" onclick="saveFavorite()">⭐ 收藏</button>
|
||||
</div>
|
||||
|
||||
<div id="exportSettingsBody" class="export-settings-body" style="display:none;">
|
||||
@@ -276,6 +328,21 @@ C, 20, 30, 40</pre>
|
||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||
<h2>🖼️ 多图合并配置</h2>
|
||||
|
||||
<div class="config-group">
|
||||
<label>图片大标题(可选)</label>
|
||||
<input type="text" id="combineBigTitle" placeholder="整张大图的大标题,留空则不显示" oninput="generateCombine()">
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>图例方式</label>
|
||||
<select id="combineLegendMode" onchange="generateCombine()">
|
||||
<option value="own">每个小图各自图例</option>
|
||||
<option value="shared-top">共用一套图例(顶部)</option>
|
||||
<option value="shared-bottom">共用一套图例(底部)</option>
|
||||
</select>
|
||||
<p class="hint-text">共用图例时:所有小图的系列合并去重,放在整张图片顶部/底部,各小图隐藏自己的图例;同名系列只显示一次</p>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>排列方式</label>
|
||||
<div class="combine-direction">
|
||||
@@ -418,5 +485,17 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
<!-- 收藏区弹窗 -->
|
||||
<div class="fav-modal" id="favoritesModal" onclick="if(event.target===this)closeFavorites()">
|
||||
<div class="fav-modal-box">
|
||||
<div class="fav-modal-head">
|
||||
<h2>⭐ 收藏区</h2>
|
||||
<button class="btn btn-remove" onclick="closeFavorites()" title="关闭">✕</button>
|
||||
</div>
|
||||
<div class="fav-modal-tip">共 <span id="favCount">0</span> 条 · 点击图片看原图 · 「编辑」在新标签页再次制作</div>
|
||||
<div class="fav-list" id="favoritesList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
|
||||
const echarts = require('echarts');
|
||||
const path = require('path');
|
||||
@@ -44,8 +45,8 @@ const colorPalettes = {
|
||||
mint: ['#00bfa5', '#26a69a', '#4db6ac', '#80cbc4', '#b2dfdb', '#00897b', '#00796b', '#004d40', '#009688']
|
||||
};
|
||||
|
||||
// ===== 数据解析(与前端一致) =====
|
||||
function parseData(rawText) {
|
||||
// ===== 数据解析(rowsAsSeries=true 时“行=系列”:第一行是横坐标,每行一个系列) =====
|
||||
function parseData(rawText, rowsAsSeries) {
|
||||
const lines = rawText.trim().split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) {
|
||||
throw new Error('数据至少需要包含表头和一行数据');
|
||||
@@ -67,6 +68,32 @@ function parseData(rawText) {
|
||||
const categories = [];
|
||||
const seriesData = {};
|
||||
|
||||
if (rowsAsSeries) {
|
||||
// 行=系列:第一列=系列名,表头除第一列外=横坐标
|
||||
const names = [];
|
||||
const nameSet = {};
|
||||
rows.slice(1).forEach(r => {
|
||||
const n = ((r && r[0]) || '').trim();
|
||||
if (n && !nameSet[n]) { nameSet[n] = true; names.push(n); }
|
||||
});
|
||||
names.forEach(n => { seriesData[n] = []; });
|
||||
for (let j = 1; j < headers.length; j++) {
|
||||
categories.push(headers[j]);
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const name = ((r && r[0]) || '').trim();
|
||||
if (!seriesData[name]) continue;
|
||||
const val = parseFloat(r[j]);
|
||||
seriesData[name].push(isNaN(val) ? 0 : val);
|
||||
}
|
||||
}
|
||||
return {
|
||||
categories,
|
||||
seriesNames: names,
|
||||
seriesData
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 1; i < headers.length; i++) {
|
||||
seriesData[headers[i]] = [];
|
||||
}
|
||||
@@ -118,10 +145,11 @@ function buildChartOption(params) {
|
||||
rightAxisSeries = null,
|
||||
leftAxisName = '',
|
||||
rightAxisName = '',
|
||||
seriesTypes = null
|
||||
seriesTypes = null,
|
||||
seriesAxis = null
|
||||
} = params;
|
||||
|
||||
const parsedData = parseData(data);
|
||||
const parsedData = parseData(data, params.rowsAsSeries);
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
const seriesColorsArr = customColors || parsedData.seriesNames.map((_, i) => palette[i % palette.length]);
|
||||
|
||||
@@ -130,6 +158,72 @@ function buildChartOption(params) {
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
|
||||
|
||||
// 饼图/雷达图:专用构建逻辑(无坐标轴/网格)
|
||||
if (chartType === 'pie' || chartType === 'radar') {
|
||||
const bgC = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
const txtC = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const tTitle = title ? { text: title, left: 'center', top: 15, textStyle: { color: txtC, fontSize: 18, fontWeight: 600 } } : undefined;
|
||||
if (chartType === 'pie') {
|
||||
// 饼图:第一列=名称,第一个系列=数值
|
||||
const sName = parsedData.seriesNames[0];
|
||||
const data = parsedData.seriesData[sName];
|
||||
const pieData = parsedData.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
|
||||
return {
|
||||
backgroundColor: bgC,
|
||||
title: tTitle,
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 50 : 20, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
|
||||
color: seriesColorsArr,
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['38%', '68%'],
|
||||
center: ['52%', '55%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: bgC, borderWidth: 2 },
|
||||
label: { show: showLabel, formatter: '{b}: {d}%', color: txtC },
|
||||
labelLine: { show: showLabel },
|
||||
data: pieData
|
||||
}],
|
||||
animation: false
|
||||
};
|
||||
} else {
|
||||
// 雷达图:第一列=维度名,每个系列=一个雷达多边形
|
||||
const indicator = parsedData.categories.map(c => {
|
||||
let max = 0;
|
||||
parsedData.seriesNames.forEach(n => { parsedData.seriesData[n].forEach(v => { if (v > max) max = v; }); });
|
||||
return { name: c, max: Math.ceil(max * 1.2) || 100 };
|
||||
});
|
||||
return {
|
||||
backgroundColor: bgC,
|
||||
title: tTitle,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 48 : 15, textStyle: { color: txtC, fontSize: 12 } } : { show: false },
|
||||
color: seriesColorsArr,
|
||||
radar: {
|
||||
indicator,
|
||||
radius: '62%',
|
||||
center: ['50%', '55%'],
|
||||
splitNumber: 5,
|
||||
axisName: { color: txtC, fontSize: 12 },
|
||||
splitLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } },
|
||||
splitArea: {
|
||||
areaStyle: {
|
||||
color: theme === 'dark' ? ['rgba(79,195,247,0.03)', 'rgba(79,195,247,0.06)'] : ['rgba(79,70,229,0.03)', 'rgba(79,70,229,0.06)']
|
||||
}
|
||||
},
|
||||
axisLine: { lineStyle: { color: theme === 'dark' ? '#444' : '#ddd' } }
|
||||
},
|
||||
series: parsedData.seriesNames.map(n => ({
|
||||
type: 'radar',
|
||||
name: n,
|
||||
data: [{ value: parsedData.seriesData[n], name: n }],
|
||||
symbolSize: 4,
|
||||
areaStyle: { opacity: 0.15 }
|
||||
})),
|
||||
animation: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系列
|
||||
const series = parsedData.seriesNames.map((name, idx) => {
|
||||
const dataArr = parsedData.seriesData[name];
|
||||
@@ -158,10 +252,16 @@ function buildChartOption(params) {
|
||||
}
|
||||
};
|
||||
|
||||
// 双Y轴:rightAxisSeries 指定右轴系列名
|
||||
// 双Y轴:rightAxisSeries 按名称指定右轴,seriesAxis 按系列下标(0左 1右)
|
||||
if (dualYAxis) {
|
||||
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
|
||||
seriesItem.yAxisIndex = rightNames.includes(name) ? 1 : 0;
|
||||
let axisIdx = 0;
|
||||
if (Array.isArray(seriesAxis) && seriesAxis[idx] !== undefined && seriesAxis[idx] !== null && seriesAxis[idx] !== '') {
|
||||
axisIdx = Number(seriesAxis[idx]) === 1 ? 1 : 0;
|
||||
} else {
|
||||
const rightNames = Array.isArray(rightAxisSeries) ? rightAxisSeries : (rightAxisSeries ? [rightAxisSeries] : []);
|
||||
axisIdx = rightNames.includes(name) ? 1 : 0;
|
||||
}
|
||||
seriesItem.yAxisIndex = axisIdx;
|
||||
}
|
||||
|
||||
if (stackMode) {
|
||||
@@ -412,6 +512,8 @@ app.get('/api/chart', (req, res) => {
|
||||
leftAxisName: req.query.leftAxisName || '',
|
||||
rightAxisName: req.query.rightAxisName || '',
|
||||
seriesTypes: req.query.seriesTypes ? req.query.seriesTypes.split(',') : null,
|
||||
seriesAxis: req.query.seriesAxis ? req.query.seriesAxis.split(',').map(Number) : null,
|
||||
rowsAsSeries: req.query.rowsAsSeries === 'true',
|
||||
width: parseInt(req.query.width) || 800,
|
||||
height: parseInt(req.query.height) || 500,
|
||||
format: req.query.format || 'png',
|
||||
@@ -928,12 +1030,17 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.13.1',
|
||||
version: '1.17.0',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
'POST /api/table': '生成表格图片(JSON body)',
|
||||
'GET /api/table': '生成表格图片(URL 参数)',
|
||||
'POST /api/favorites': '保存收藏(原图+配置)',
|
||||
'GET /api/favorites': '收藏列表',
|
||||
'GET /api/favorites/:id': '单个收藏详情(含配置)',
|
||||
'GET /api/favorites/:id/image': '收藏原图',
|
||||
'DELETE /api/favorites/:id': '删除收藏',
|
||||
'GET /api/health': '健康检查'
|
||||
}
|
||||
});
|
||||
@@ -943,7 +1050,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.13.1',
|
||||
version: '1.17.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -952,7 +1059,7 @@ app.get('/api/docs', (req, res) => {
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头,第一列横坐标)' },
|
||||
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line'], description: '图表类型' },
|
||||
chartType: { type: 'string', default: 'bar', options: ['bar', 'line', 'bar-line', 'pie', 'radar'], description: '图表类型' },
|
||||
title: { type: 'string', default: '', description: '图表标题' },
|
||||
theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro', 'ocean', 'forest', 'sunset', 'lavender', 'minimal', 'cherry', 'midnight', 'gold', 'coral', 'mint'], description: '主题风格' },
|
||||
showLegend: { type: 'boolean', default: true, description: '是否显示图例' },
|
||||
@@ -973,7 +1080,9 @@ app.get('/api/docs', (req, res) => {
|
||||
rightAxisSeries: { type: 'array', default: null, description: '右轴系列名列表(如 ["利润"],指定哪些系列用右轴)' },
|
||||
leftAxisName: { type: 'string', default: '', description: '左轴名称' },
|
||||
rightAxisName: { type: 'string', default: '', description: '右轴名称' },
|
||||
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto)' }
|
||||
seriesTypes: { type: 'array', default: null, description: '每系列图表类型(如 ["bar","line"],按系列顺序对应,bar/line/auto)' },
|
||||
seriesAxis: { type: 'array', default: null, description: '每系列坐标轴(如 [0,1],0=左轴 1=右轴,配合 dualYAxis 使用;优先于 rightAxisSeries)' },
|
||||
rowsAsSeries: { type: 'boolean', default: false, description: '数据方向:false=列=系列(第一列是横坐标,每列一个系列);true=行=系列(第一行是横坐标,每行一个系列)' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
@@ -1055,15 +1164,18 @@ app.get('/api/docs', (req, res) => {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/combine',
|
||||
description: '将多张图表合并到一张图片中(支持横排/竖排)',
|
||||
description: '将多张图表合并到一张图片中(支持横排/竖排/网格,支持整图大标题与共享图例)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数;兼容 chart1 + chart2' },
|
||||
charts: { type: 'array', required: true, description: '图表配置数组(N 张),每项同 /api/chart 参数(含 seriesTypes/seriesAxis/dualYAxis 等);兼容 chart1 + chart2' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical', 'grid'], description: '排布方式:horizontal 横排(单行)/ vertical 竖排(单列)/ grid 多行多列' },
|
||||
cols: { type: 'number', default: 2, description: '多行多列时的每行列数(仅 grid 生效)' },
|
||||
gap: { type: 'number', default: 24, description: '图间距(px)' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' }
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' },
|
||||
bigTitle: { type: 'string', default: '', description: '整张图片的大标题(可空)' },
|
||||
legendMode: { type: 'string', default: 'own', options: ['own', 'shared-top', 'shared-bottom'], description: '图例方式:own 每个小图各自图例 / shared-top 共用图例放顶部 / shared-bottom 共用图例放底部(共用时各子图隐藏自己的图例)' },
|
||||
theme: { type: 'string', default: 'default', description: '大标题/共享图例文字颜色所属主题(深色用 dark)' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
@@ -1076,11 +1188,66 @@ app.get('/api/docs', (req, res) => {
|
||||
}' -o combine.png`,
|
||||
response: 'PNG 图片二进制流'
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/favorites',
|
||||
description: '保存收藏(原始大小原图 + 完整配置,用于重新编辑)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
mode: { type: 'string', required: true, options: ['chart', 'table', 'combine'], description: '收藏类型' },
|
||||
title: { type: 'string', default: '', description: '收藏标题' },
|
||||
config: { type: 'object', required: true, description: '完整配置(数据/图表类型/主题/系列颜色顺序/轴/分割等)' },
|
||||
image: { type: 'string', description: '原始大小图片 dataURL(可选,未传则服务端按配置生成)' }
|
||||
},
|
||||
returns: 'json { ok, id, title, width, height }'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites',
|
||||
description: '收藏列表(按时间倒序)',
|
||||
returns: 'json { ok, favorites: [{id, mode, title, createdAt, width, height}] }'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites/:id',
|
||||
description: '单个收藏详情(含完整 config,供编辑页加载)'
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/favorites/:id/image',
|
||||
description: '收藏原图 PNG(?download=1 强制下载)'
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/api/favorites/:id',
|
||||
description: '删除收藏'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 多图合并:收集共享图例条目(按名称去重) =====
|
||||
function combineLegendItems(charts) {
|
||||
const items = [];
|
||||
const seen = {};
|
||||
charts.forEach(c => {
|
||||
if (!c || !c.data) return;
|
||||
let p;
|
||||
try { p = parseData(c.data, c.rowsAsSeries); } catch (e) { return; }
|
||||
if (!p || !p.seriesNames) return;
|
||||
const palette = colorPalettes[c.theme] || colorPalettes.default;
|
||||
const custom = Array.isArray(c.seriesColors) ? c.seriesColors : null;
|
||||
p.seriesNames.forEach((name, idx) => {
|
||||
if (!seen[name]) {
|
||||
seen[name] = true;
|
||||
items.push({ name, color: custom ? (custom[idx] || palette[idx % palette.length]) : palette[idx % palette.length] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// ===== API: 多图合并生成图片 =====
|
||||
app.post('/api/combine', async (req, res) => {
|
||||
try {
|
||||
@@ -1104,11 +1271,18 @@ app.post('/api/combine', async (req, res) => {
|
||||
const gap = parseInt(body.gap) || 24;
|
||||
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
||||
const background = body.background || '#ffffff';
|
||||
const bigTitle = String(body.bigTitle || '');
|
||||
const legendMode = ['shared-top', 'shared-bottom'].includes(body.legendMode) ? body.legendMode : 'own';
|
||||
const sharedLegend = legendMode !== 'own';
|
||||
const theme = body.theme || 'default';
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
|
||||
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
|
||||
// 渲染单个子图(共享图例时隐藏各子图自己的图例;直接按目标尺寸渲染,避免二次缩放损失)
|
||||
// 注意:必须先 toBuffer 再 chart.dispose(),dispose 会清空 canvas 内容!
|
||||
const renderSub = async (params, w, h) => {
|
||||
const option = buildChartOption(params);
|
||||
const p = { ...params };
|
||||
if (sharedLegend) p.showLegend = false;
|
||||
const option = buildChartOption(p);
|
||||
const c = createCanvas(w * pixelRatio, h * pixelRatio);
|
||||
const chart = echarts.init(c, null, {
|
||||
renderer: 'canvas',
|
||||
@@ -1127,10 +1301,8 @@ app.post('/api/combine', async (req, res) => {
|
||||
renderSub(c, parseInt(c.width) || 640, parseInt(c.height) || 420)
|
||||
));
|
||||
|
||||
let W, H;
|
||||
const canvas = createCanvas(1, 1);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 基础布局(不含大标题/共享图例):计算子图单元格位置
|
||||
let W, H, cells = [];
|
||||
if (direction === 'grid') {
|
||||
// 多行多列网格:所有子图统一 contain 到最大单元格,按列填充
|
||||
const rows = Math.ceil(subs.length / cols);
|
||||
@@ -1138,53 +1310,122 @@ app.post('/api/combine', async (req, res) => {
|
||||
const cellH = Math.max(...subs.map(s => s.h));
|
||||
W = cols * cellW + (cols - 1) * gap;
|
||||
H = rows * cellH + (rows - 1) * gap;
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
subs.forEach((s, i) => {
|
||||
const r = Math.floor(i / cols), c = i % cols;
|
||||
const scale = Math.min(cellW / s.w, cellH / s.h);
|
||||
const dw = Math.round(s.w * scale), dh = Math.round(s.h * scale);
|
||||
const x = c * (cellW + gap) + Math.round((cellW - dw) / 2);
|
||||
const y = r * (cellH + gap) + Math.round((cellH - dh) / 2);
|
||||
ctx.drawImage(s.img, x * pixelRatio, y * pixelRatio, dw * pixelRatio, dh * pixelRatio);
|
||||
cells.push({
|
||||
img: s.img,
|
||||
x: c * (cellW + gap) + Math.round((cellW - dw) / 2),
|
||||
y: r * (cellH + gap) + Math.round((cellH - dh) / 2),
|
||||
w: dw, h: dh
|
||||
});
|
||||
});
|
||||
} else if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
let y = 0;
|
||||
cells = subs.map(s => {
|
||||
const h = Math.round(s.h * (tw / s.w));
|
||||
const cell = { img: s.img, x: 0, y, w: tw, h };
|
||||
y += h + gap;
|
||||
return cell;
|
||||
});
|
||||
W = tw;
|
||||
H = y - gap;
|
||||
} else {
|
||||
let scaled;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(...subs.map(s => s.w));
|
||||
scaled = subs.map(s => ({ img: s.img, w: tw, h: Math.round(s.h * (tw / s.w)) }));
|
||||
W = tw;
|
||||
H = scaled.reduce((sum, s) => sum + s.h, 0) + gap * (scaled.length - 1);
|
||||
} else {
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
scaled = subs.map(s => ({ img: s.img, w: Math.round(s.w * (th / s.h)), h: th }));
|
||||
W = scaled.reduce((sum, s) => sum + s.w, 0) + gap * (scaled.length - 1);
|
||||
H = th;
|
||||
}
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(...subs.map(s => s.h));
|
||||
let x = 0;
|
||||
cells = subs.map(s => {
|
||||
const w = Math.round(s.w * (th / s.h));
|
||||
const cell = { img: s.img, x, y: 0, w, h: th };
|
||||
x += w + gap;
|
||||
return cell;
|
||||
});
|
||||
W = x - gap;
|
||||
H = th;
|
||||
}
|
||||
|
||||
canvas.width = W * pixelRatio;
|
||||
canvas.height = H * pixelRatio;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
// 共享图例:按宽度换行计算
|
||||
const legendFont = 13, swatch = 14, sw = 5, ig = 18, lineH = legendFont + 10;
|
||||
let legendRows = [], legendH = 0;
|
||||
if (sharedLegend) {
|
||||
const items = combineLegendItems(charts);
|
||||
const tmp = createCanvas(10, 10);
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
const maxW = W - 20;
|
||||
let cur = [], curW = 0;
|
||||
items.forEach(it => {
|
||||
const itemW = swatch + sw + tctx.measureText(it.name).width;
|
||||
if (cur.length && curW + ig + itemW > maxW) { legendRows.push(cur); cur = []; curW = 0; }
|
||||
cur.push(it);
|
||||
curW += itemW + (cur.length > 1 ? ig : 0);
|
||||
});
|
||||
if (cur.length) legendRows.push(cur);
|
||||
legendH = legendRows.length * lineH + 8;
|
||||
}
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
||||
let y = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, 0, y * pixelRatio, s.w * pixelRatio, s.h * pixelRatio);
|
||||
y += s.h + gap;
|
||||
// 大标题区高度
|
||||
const titleFont = 34;
|
||||
const titleH = bigTitle ? titleFont + 26 : 0;
|
||||
const topOffset = titleH + (legendMode === 'shared-top' ? legendH + 12 : 0);
|
||||
const bottomOffset = legendMode === 'shared-bottom' ? legendH + 12 : 0;
|
||||
const totalH = H + topOffset + bottomOffset;
|
||||
|
||||
// 创建最终画布(统一 scale,全部用逻辑坐标绘制)
|
||||
const canvas = createCanvas(W * pixelRatio, totalH * pixelRatio);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W, totalH);
|
||||
|
||||
// 大标题
|
||||
if (bigTitle) {
|
||||
ctx.font = `bold ${titleFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(bigTitle, W / 2, (titleH - 26) / 2 + 2);
|
||||
}
|
||||
|
||||
// 绘制图例行(topY 为图例区顶部逻辑坐标)
|
||||
const drawLegendBlock = (topY) => {
|
||||
legendRows.forEach((row, ri) => {
|
||||
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
let totalW = 0;
|
||||
row.forEach(it => { totalW += swatch + sw + ctx.measureText(it.name).width + ig; });
|
||||
totalW -= ig;
|
||||
let cx = (W - totalW) / 2;
|
||||
row.forEach(it => {
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(cx, topY + ri * lineH + (legendFont - swatch) / 2 + 2, swatch, swatch);
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${legendFont}px "Noto Sans CJK SC", sans-serif`;
|
||||
ctx.fillText(it.name, cx + swatch + sw, topY + ri * lineH + legendFont / 2 + 2);
|
||||
cx += swatch + sw + ctx.measureText(it.name).width + ig;
|
||||
});
|
||||
} else {
|
||||
let x = 0;
|
||||
scaled.forEach(s => {
|
||||
ctx.drawImage(s.img, x * pixelRatio, 0, s.w * pixelRatio, s.h * pixelRatio);
|
||||
x += s.w + gap;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 顶部共享图例
|
||||
if (sharedLegend && legendMode === 'shared-top') {
|
||||
drawLegendBlock(titleH + 4);
|
||||
}
|
||||
|
||||
// 子图(整体下移 topOffset)
|
||||
cells.forEach(cell => {
|
||||
ctx.drawImage(cell.img, cell.x, cell.y + topOffset, cell.w, cell.h);
|
||||
});
|
||||
|
||||
// 底部共享图例
|
||||
if (sharedLegend && legendMode === 'shared-bottom') {
|
||||
drawLegendBlock(topOffset + H + 8);
|
||||
}
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
@@ -1203,6 +1444,169 @@ app.post('/api/combine', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 收藏功能 =====
|
||||
const FAV_DIR = path.join(__dirname, 'data', 'favorites');
|
||||
const FAV_INDEX = path.join(FAV_DIR, 'index.json');
|
||||
|
||||
function ensureFavDir() {
|
||||
if (!fs.existsSync(FAV_DIR)) fs.mkdirSync(FAV_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function loadFavIndex() {
|
||||
ensureFavDir();
|
||||
try {
|
||||
if (fs.existsSync(FAV_INDEX)) {
|
||||
const arr = JSON.parse(fs.readFileSync(FAV_INDEX, 'utf8'));
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('收藏索引读取失败:', e.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveFavIndex(index) {
|
||||
ensureFavDir();
|
||||
fs.writeFileSync(FAV_INDEX, JSON.stringify(index, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function genFavId() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function dataUrlToBuffer(dataUrl) {
|
||||
if (!dataUrl) return null;
|
||||
const m = /^data:image\/(png|jpeg|webp);base64,(.+)$/.exec(dataUrl);
|
||||
if (!m) return null;
|
||||
return Buffer.from(m[2], 'base64');
|
||||
}
|
||||
|
||||
function getPngSize(buf) {
|
||||
// PNG 签名 8 字节后 IHDR 块:length(4) + type(4) + width(4) + height(4)
|
||||
if (buf && buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50) {
|
||||
return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
||||
}
|
||||
return { w: 0, h: 0 };
|
||||
}
|
||||
|
||||
// 服务端兜底渲染(前端未传图时按原始大小生成)
|
||||
function renderFavoriteImage(mode, config) {
|
||||
if (mode === 'chart') {
|
||||
const width = parseInt(config.width) || 800;
|
||||
const height = parseInt(config.height) || 500;
|
||||
const pixelRatio = parseInt(config.pixelRatio) || 2;
|
||||
const option = buildChartOption(config);
|
||||
const canvas = createCanvas(width * pixelRatio, height * pixelRatio);
|
||||
const chart = echarts.init(canvas, null, {
|
||||
renderer: 'canvas', width, height, devicePixelRatio: pixelRatio
|
||||
});
|
||||
chart.setOption(option);
|
||||
return canvas;
|
||||
}
|
||||
if (mode === 'table') {
|
||||
return generateTableImage(config);
|
||||
}
|
||||
throw new Error('combine 模式需由前端提供原图');
|
||||
}
|
||||
|
||||
// 保存收藏(前端传原始大小原图 + 完整配置,便于重新编辑)
|
||||
app.post('/api/favorites', (req, res) => {
|
||||
try {
|
||||
const { mode = 'chart', title = '', config = {}, image } = req.body || {};
|
||||
if (!['chart', 'table', 'combine'].includes(mode)) {
|
||||
return res.status(400).json({ error: 'mode 必须是 chart / table / combine' });
|
||||
}
|
||||
ensureFavDir();
|
||||
const id = genFavId();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
let imagePath = null;
|
||||
let width = 0, height = 0;
|
||||
|
||||
// 1) 优先保存前端回传的原图(原始大小)
|
||||
const buf = dataUrlToBuffer(image);
|
||||
if (buf) {
|
||||
imagePath = path.join(FAV_DIR, id + '.png');
|
||||
fs.writeFileSync(imagePath, buf);
|
||||
} else {
|
||||
// 2) 兜底:服务端按原始大小渲染
|
||||
try {
|
||||
const canvas = renderFavoriteImage(mode, config);
|
||||
imagePath = path.join(FAV_DIR, id + '.png');
|
||||
fs.writeFileSync(imagePath, canvas.toBuffer('image/png'));
|
||||
} catch (e) {
|
||||
console.warn('服务端渲染收藏图失败:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath && fs.existsSync(imagePath)) {
|
||||
try {
|
||||
const size = getPngSize(fs.readFileSync(imagePath));
|
||||
width = size.w; height = size.h;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const fav = { id, mode, title, createdAt, width, height, config };
|
||||
const index = loadFavIndex();
|
||||
index.unshift(fav);
|
||||
saveFavIndex(index);
|
||||
|
||||
res.json({ ok: true, id, title, width, height, message: '收藏成功' });
|
||||
} catch (err) {
|
||||
console.error('Save favorite error:', err);
|
||||
res.status(500).json({ error: '收藏失败: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 收藏列表
|
||||
app.get('/api/favorites', (req, res) => {
|
||||
res.json({ ok: true, favorites: loadFavIndex() });
|
||||
});
|
||||
|
||||
// 单个收藏(含配置,用于编辑)
|
||||
app.get('/api/favorites/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const fav = loadFavIndex().find(f => f.id === id);
|
||||
if (!fav) return res.status(404).json({ error: '收藏不存在' });
|
||||
res.json({ ok: true, favorite: fav });
|
||||
});
|
||||
|
||||
// 收藏原图
|
||||
app.get('/api/favorites/:id/image', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const imgPath = path.join(FAV_DIR, id + '.png');
|
||||
if (!fs.existsSync(imgPath)) return res.status(404).json({ error: '图片不存在' });
|
||||
const buf = fs.readFileSync(imgPath);
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buf.length,
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
});
|
||||
if (req.query.download === '1') {
|
||||
res.set('Content-Disposition', `attachment; filename="favorite_${id}.png"`);
|
||||
}
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
// 删除收藏
|
||||
app.delete('/api/favorites/:id', (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!/^[A-Za-z0-9_]+$/.test(id)) return res.status(400).json({ error: '非法 id' });
|
||||
const index = loadFavIndex();
|
||||
const next = index.filter(f => f.id !== id);
|
||||
if (next.length === index.length) return res.status(404).json({ error: '收藏不存在' });
|
||||
saveFavIndex(next);
|
||||
try {
|
||||
const p = path.join(FAV_DIR, id + '.png');
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
} catch (e) {}
|
||||
res.json({ ok: true, message: '已删除' });
|
||||
});
|
||||
|
||||
// ===== 启动服务 =====
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 数据可视化图表生成器已启动`);
|
||||
@@ -1210,6 +1614,7 @@ app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`📡 图表API: http://0.0.0.0:${PORT}/api/chart`);
|
||||
console.log(`📋 表格API: http://0.0.0.0:${PORT}/api/table`);
|
||||
console.log(`🖼️ 合并API: http://0.0.0.0:${PORT}/api/combine`);
|
||||
console.log(`⭐ 收藏API: http://0.0.0.0:${PORT}/api/favorites`);
|
||||
console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
|
||||
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
|
||||
});
|
||||
@@ -35,12 +35,22 @@ body {
|
||||
}
|
||||
|
||||
.app-header {
|
||||
text-align: center;
|
||||
padding: 20px 0 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
padding: 6px 0 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.35rem;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, var(--primary), #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -49,8 +59,8 @@ body {
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-light);
|
||||
margin-top: 5px;
|
||||
font-size: 0.95rem;
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -65,7 +75,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: calc(100vh - 140px);
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
@@ -484,6 +494,42 @@ body {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* 多图合并 - 每系列配置(类型/坐标轴) */
|
||||
.combine-series-cfg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.combine-series-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.combine-series-name {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.combine-series-row select {
|
||||
max-width: 96px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.combine-chart-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
@@ -775,3 +821,209 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== 收藏功能 ===== */
|
||||
.header-actions {
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-fav-outline {
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn-fav-outline:hover {
|
||||
background: #ffedd5;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-fav {
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-fav:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-fav:disabled {
|
||||
background: #fcd34d;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 收藏区弹窗 */
|
||||
.fav-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.fav-modal-box {
|
||||
width: min(1080px, 96vw);
|
||||
max-height: 88vh;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fav-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.fav-modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.fav-modal-tip {
|
||||
padding: 8px 22px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fav-modal-tip #favCount {
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.fav-list {
|
||||
padding: 18px 22px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.fav-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fav-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.fav-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.fav-card-img {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.fav-card-img img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fav-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(79, 70, 229, 0.9);
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fav-card-body {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.fav-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fav-card-meta {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fav-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fav-card-actions .btn-sm {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fav-card-actions .btn-remove {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Toast 提示 */
|
||||
#appToast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: rgba(17, 24, 39, 0.92);
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.88rem;
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
max-width: 80vw;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#appToast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
Reference in New Issue
Block a user