Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b3253fa0 | ||
|
|
51d3e09378 | ||
|
|
9a8cbb125c | ||
|
|
52a245e044 | ||
|
|
479d95cb2c | ||
|
|
e62aac539b | ||
|
|
3107d56764 | ||
|
|
79a1ca36c5 | ||
|
|
a6c8de5820 | ||
|
|
5f9357a182 | ||
|
|
c6b930b6d6 |
@@ -1,6 +1,6 @@
|
||||
# 📡 数据可视化图表生成器 - API 文档
|
||||
|
||||
> 版本:v1.1.0 | 基础地址:`http://192.168.0.101:16016`
|
||||
> 版本:v1.9.0 | 基础地址:`http://192.168.0.101:16016`
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|------|------|------|
|
||||
| POST | `/api/chart` | 生成图表图片(JSON body,推荐) |
|
||||
| GET | `/api/chart` | 生成图表图片(URL 参数) |
|
||||
| POST | `/api/combine` | 双图合并(横排/竖排)生成一张图 |
|
||||
| GET | `/api/health` | 健康检查 |
|
||||
| GET | `/api/docs` | 返回本文档(JSON) |
|
||||
|
||||
@@ -151,7 +152,75 @@ curl "http://192.168.0.101:16016/api/chart?data=%E4%BA%A7%E5%93%81,Q1,Q2%0A%E6%8
|
||||
|
||||
---
|
||||
|
||||
## 3. GET /api/health
|
||||
## 3. POST /api/combine(双图合并)
|
||||
|
||||
将两张图表合并到一张图片中,支持**横排(左右并排)** 或 **竖排(上下堆叠)** 两种方向,返回 PNG 图片。
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
POST /api/combine
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `chart1` | object | ✅ | 第一张图配置(同 `/api/chart` 参数:data/chartType/title/theme 等) |
|
||||
| `chart2` | object | ✅ | 第二张图配置(同 `/api/chart` 参数) |
|
||||
| `direction` | string | 否 | `horizontal` 横排(默认)/ `vertical` 竖排 |
|
||||
| `gap` | number | 否 | 两图间距(默认 24px) |
|
||||
| `pixelRatio` | number | 否 | 像素倍率,默认 2(越清晰文件越大) |
|
||||
| `background` | string | 否 | 背景色,默认 `#ffffff` |
|
||||
|
||||
每个子图配置支持:`data`(CSV)、`chartType`(bar/line/bar-line)、`title`、`theme`、`showLegend`、`showGrid`、`showLabel`、`stackMode`、`smoothLine`、`width`、`height`。
|
||||
|
||||
### curl 示例
|
||||
|
||||
**横排(左右并排):**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:16016/api/combine \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"chart1": {
|
||||
"data": "产品, Q1, Q2\n手机, 1200, 1800\n平板, 800, 950",
|
||||
"title": "2024年销售",
|
||||
"chartType": "bar"
|
||||
},
|
||||
"chart2": {
|
||||
"data": "月份, 营收\n1月, 500\n2月, 680\n3月, 820",
|
||||
"title": "营收趋势",
|
||||
"chartType": "line"
|
||||
},
|
||||
"direction": "horizontal"
|
||||
}' -o combine.png
|
||||
```
|
||||
|
||||
**竖排(上下堆叠):**
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:16016/api/combine \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"chart1": {"data": "指标, 2023年\n营收, 500\n利润, 80", "title": "营收对比", "chartType": "bar"},
|
||||
"chart2": {"data": "月份, 效率\n1月, 85\n2月, 88", "title": "效率趋势", "chartType": "line"},
|
||||
"direction": "vertical"
|
||||
}' -o combine.png
|
||||
```
|
||||
|
||||
### 返回
|
||||
|
||||
PNG 图片二进制流,响应头包含:
|
||||
|
||||
| 响应头 | 说明 |
|
||||
|--------|------|
|
||||
| `Content-Type` | `image/png` |
|
||||
| `X-Combine-Direction` | `horizontal` / `vertical` |
|
||||
| `X-Chart-Width` / `X-Chart-Height` | 合并图逻辑尺寸 |
|
||||
|
||||
---
|
||||
|
||||
## 4. GET /api/health
|
||||
|
||||
健康检查。
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
一个简洁强大的数据可视化工具,支持 **Web UI** 和 **API** 两种方式生成精美的对比图表和表格图片。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
@@ -29,15 +29,26 @@
|
||||
- 可自定义分割线样式(实线/虚线/点线)
|
||||
- 自动标注左右区域标签
|
||||
|
||||
### 🖼️ 双图合并
|
||||
- 将两张图表合并到一张图片中,适合对比/汇总场景
|
||||
- 支持**横排(左右并排)** 和 **竖排(上下堆叠)**,一键切换实时预览
|
||||
- 两张图可独立配置:数据、标题、图表类型、主题、图例/网格/标签/堆叠
|
||||
- 支持导出 PNG
|
||||
|
||||
### 📡 API 接口
|
||||
- **POST /api/chart** - JSON 请求体生成图表(完整参数支持)
|
||||
- **GET /api/chart** - URL 参数生成图表(简单场景)
|
||||
- **POST /api/table** - JSON 请求体生成表格图片
|
||||
- **GET /api/table** - URL 参数生成表格图片
|
||||
- **POST /api/combine** - 双图合并(横排/竖排)生成一张图片
|
||||
- 返回 PNG 图片,支持自定义分辨率和像素倍率
|
||||
|
||||
### 📥 导出功能
|
||||
- Web UI 导出 PNG(2倍分辨率)
|
||||
- 导出设置:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义)
|
||||
- 自定义分辨率手动输入,页面实时居中预览保存后的图
|
||||
- 分辨率历史记忆(最近5个,localStorage 持久化,一键复用)
|
||||
- 文件名默认按日期时间自动命名(每次不同),支持手动命名
|
||||
- Web UI 导出 PNG / 图表模式支持 SVG
|
||||
- API 直接返回 PNG 图片流
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -59,6 +59,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// 默认加载第一个示例
|
||||
document.getElementById('dataInput').value = sampleDataSets[0].data;
|
||||
generateChart();
|
||||
|
||||
// 预填双图合并示例(不提前生成,切到双图模式时由 switchMode 触发)
|
||||
document.getElementById('combineData1').value = sampleDataSets[0].data;
|
||||
document.getElementById('combineData2').value = sampleDataSets[1].data;
|
||||
document.getElementById('combineTitle1').value = '季度销售对比';
|
||||
document.getElementById('combineType2').value = 'line';
|
||||
document.getElementById('combineTitle2').value = '年度增长趋势';
|
||||
|
||||
// 初始化导出设置
|
||||
renderHistory();
|
||||
refreshExportHint();
|
||||
});
|
||||
|
||||
// ===== 数据解析 =====
|
||||
@@ -127,6 +138,8 @@ function generateChart() {
|
||||
// 初始化图表
|
||||
initChart();
|
||||
updateChart();
|
||||
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// ===== 初始化图表实例 =====
|
||||
@@ -388,6 +401,8 @@ function updateChart() {
|
||||
|
||||
// 绘制分割线(在 category 间隙中)
|
||||
drawSplitLine();
|
||||
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// ===== 在柱子间隙中精确绘制分割线 =====
|
||||
@@ -585,37 +600,229 @@ function clearData() {
|
||||
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
|
||||
}
|
||||
|
||||
// ===== 导出图表 =====
|
||||
function exportChart(format) {
|
||||
// ===== 导出图表(SVG 专用) =====
|
||||
function exportChartSvg() {
|
||||
if (!chartInstance) {
|
||||
alert('请先生成图表');
|
||||
return;
|
||||
}
|
||||
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
|
||||
svgChart.setOption(chartInstance.getOption());
|
||||
const url = svgChart.getDataURL({ type: 'svg', pixelRatio: 2 });
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = buildExportFilename('chart', 'svg');
|
||||
a.click();
|
||||
svgChart.dispose();
|
||||
}
|
||||
|
||||
if (format === 'png') {
|
||||
const url = chartInstance.getDataURL({
|
||||
type: 'png',
|
||||
pixelRatio: 2,
|
||||
backgroundColor: document.getElementById('themeStyle').value === 'dark' ? '#1a1a2e' : '#fff'
|
||||
});
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'chart.png';
|
||||
a.click();
|
||||
} else if (format === 'svg') {
|
||||
// 重新用 SVG 渲染
|
||||
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
|
||||
svgChart.setOption(chartInstance.getOption());
|
||||
const url = svgChart.getDataURL({
|
||||
type: 'svg',
|
||||
pixelRatio: 2
|
||||
});
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'chart.svg';
|
||||
a.click();
|
||||
svgChart.dispose();
|
||||
// ===== 导出设置(分辨率/历史/命名) =====
|
||||
const EXPORT_PRESETS = {
|
||||
'original': { label: '原始大小', w: 0, h: 0 },
|
||||
'1920x1080': { label: '1920 × 1080', w: 1920, h: 1080 },
|
||||
'1280x720': { label: '1280 × 720', w: 1280, h: 720 },
|
||||
'1024x768': { label: '1024 × 768', w: 1024, h: 768 },
|
||||
'800x600': { label: '800 × 600', w: 800, h: 600 },
|
||||
'custom': { label: '自定义', w: null, h: null }
|
||||
};
|
||||
const HISTORY_KEY = 'dct_export_history';
|
||||
const HISTORY_MAX = 5;
|
||||
|
||||
// 当前生效的分辨率 {w, h}(0 表示原始大小)
|
||||
function getResolution() {
|
||||
const preset = document.getElementById('exportPreset').value;
|
||||
if (preset === 'original') return { w: 0, h: 0 };
|
||||
if (preset === 'custom') {
|
||||
const w = parseInt(document.getElementById('customWidth').value) || 0;
|
||||
const h = parseInt(document.getElementById('customHeight').value) || 0;
|
||||
return { w, h };
|
||||
}
|
||||
const p = EXPORT_PRESETS[preset];
|
||||
return { w: p.w, h: p.h };
|
||||
}
|
||||
|
||||
function onExportPresetChange() {
|
||||
const preset = document.getElementById('exportPreset').value;
|
||||
document.getElementById('customResGroup').style.display = preset === 'custom' ? 'block' : 'none';
|
||||
refreshExportHint();
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// 导出设置面板展开/折叠(默认折叠)
|
||||
let exportSettingsOpen = false;
|
||||
function toggleExportSettings() {
|
||||
exportSettingsOpen = !exportSettingsOpen;
|
||||
const body = document.getElementById('exportSettingsBody');
|
||||
const t = document.getElementById('exportToggle');
|
||||
body.style.display = exportSettingsOpen ? 'block' : 'none';
|
||||
t.textContent = exportSettingsOpen ? '▾' : '▸';
|
||||
}
|
||||
|
||||
// 一键恢复默认(重置全部设置,历史记录保留)
|
||||
function resetExportSettings() {
|
||||
document.getElementById('exportPreset').value = 'original';
|
||||
document.getElementById('customResGroup').style.display = 'none';
|
||||
document.getElementById('customWidth').value = '';
|
||||
document.getElementById('customHeight').value = '';
|
||||
document.getElementById('exportFilename').value = '';
|
||||
document.getElementById('exportHistory').value = '';
|
||||
refreshExportHint();
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
function onCustomResInput() {
|
||||
refreshExportHint();
|
||||
refreshPreview();
|
||||
const { w, h } = getResolution();
|
||||
if (w > 0 && h > 0) saveHistory(w, h);
|
||||
}
|
||||
|
||||
// 历史分辨率管理(最多 5 个,存 localStorage)
|
||||
function loadHistory() {
|
||||
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; }
|
||||
}
|
||||
|
||||
function saveHistory(w, h) {
|
||||
if (!w || !h) return;
|
||||
let hist = loadHistory();
|
||||
const key = w + 'x' + h;
|
||||
hist = hist.filter(x => x.key !== key);
|
||||
hist.unshift({ key, w, h });
|
||||
hist = hist.slice(0, HISTORY_MAX);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(hist));
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const sel = document.getElementById('exportHistory');
|
||||
const hist = loadHistory();
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = hist.length
|
||||
? hist.map(h => `<option value="${h.key}">${h.w} × ${h.h}</option>`).join('')
|
||||
: '<option value="">— 暂无历史 —</option>';
|
||||
if (cur && hist.some(h => h.key === cur)) sel.value = cur;
|
||||
}
|
||||
|
||||
function onHistorySelect() {
|
||||
const v = document.getElementById('exportHistory').value;
|
||||
if (!v) return;
|
||||
const [w, h] = v.split('x').map(Number);
|
||||
const preset = Object.entries(EXPORT_PRESETS).find(([k, p]) => p.w === w && p.h === h);
|
||||
document.getElementById('exportPreset').value = preset ? preset[0] : 'custom';
|
||||
if (preset) {
|
||||
document.getElementById('customResGroup').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('customResGroup').style.display = 'block';
|
||||
document.getElementById('customWidth').value = w;
|
||||
document.getElementById('customHeight').value = h;
|
||||
}
|
||||
refreshExportHint();
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
// 文件名:手动填写优先,否则按日期时间自动命名(每次不同)
|
||||
function buildExportFilename(prefix, ext) {
|
||||
ext = ext || 'png';
|
||||
const manual = document.getElementById('exportFilename').value.trim();
|
||||
if (manual) {
|
||||
return /\.[a-zA-Z0-9]+$/.test(manual) ? manual : manual + '.' + ext;
|
||||
}
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${prefix}_${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${ext}`;
|
||||
}
|
||||
|
||||
// 把源图 contain 居中缩放到目标分辨率画布(自动适配深色背景)
|
||||
function applyResolution(srcCanvas, tw, th) {
|
||||
if (!tw || !th) return srcCanvas;
|
||||
let bg = '#ffffff';
|
||||
try {
|
||||
const d = srcCanvas.getContext('2d').getImageData(0, 0, 1, 1).data;
|
||||
if (d[3] > 0 && (d[0] + d[1] + d[2]) / 3 < 100) bg = '#1a1a2e';
|
||||
} catch (e) {}
|
||||
const c = document.createElement('canvas');
|
||||
c.width = tw; c.height = th;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, tw, th);
|
||||
const scale = Math.min(tw / srcCanvas.width, th / srcCanvas.height);
|
||||
const dw = Math.round(srcCanvas.width * scale);
|
||||
const dh = Math.round(srcCanvas.height * scale);
|
||||
ctx.drawImage(srcCanvas, Math.round((tw - dw) / 2), Math.round((th - dh) / 2), dw, dh);
|
||||
return c;
|
||||
}
|
||||
|
||||
// 获取当前模式源 canvas(异步)
|
||||
function getSourceCanvas() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (currentMode === 'chart') {
|
||||
if (!chartInstance) return reject(new Error('请先生成图表'));
|
||||
const cvs = chartInstance.getDom().querySelector('canvas');
|
||||
if (!cvs) return reject(new Error('图表未渲染'));
|
||||
resolve(cvs);
|
||||
} else if (currentMode === 'table') {
|
||||
if (!tableImageBlob) return reject(new Error('请先生成表格'));
|
||||
const url = URL.createObjectURL(tableImageBlob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.width; c.height = img.height;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(c);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('表格图片加载失败')); };
|
||||
img.src = url;
|
||||
} else {
|
||||
if (!combinedCanvas) return reject(new Error('请先生成合并图'));
|
||||
resolve(combinedCanvas);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 生成最终导出 canvas(应用当前分辨率)
|
||||
function getExportCanvas() {
|
||||
return getSourceCanvas().then(src => {
|
||||
const { w, h } = getResolution();
|
||||
return applyResolution(src, w, h);
|
||||
});
|
||||
}
|
||||
|
||||
// 统一下载图片
|
||||
function exportCurrent(prefix) {
|
||||
getExportCanvas().then(canvas => {
|
||||
const a = document.createElement('a');
|
||||
a.href = canvas.toDataURL('image/png');
|
||||
a.download = buildExportFilename(prefix);
|
||||
a.click();
|
||||
const { w, h } = getResolution();
|
||||
if (w > 0 && h > 0) saveHistory(w, h);
|
||||
}).catch(err => alert(err.message));
|
||||
}
|
||||
|
||||
// 导出分辨率提示 + 折叠栏摘要
|
||||
function refreshExportHint() {
|
||||
const { w, h } = getResolution();
|
||||
const hint = document.getElementById('exportResHint');
|
||||
if (hint) hint.textContent = w > 0 && h > 0 ? `导出分辨率:${w} × ${h}(原图居中缩放)` : '导出分辨率:原始大小';
|
||||
const sum = document.getElementById('exportSummary');
|
||||
if (sum) sum.textContent = w > 0 && h > 0 ? `${w} × ${h}` : '原始大小';
|
||||
}
|
||||
|
||||
// 刷新导出预览:以原图为中心,居中展示保存后的图
|
||||
function refreshPreview() {
|
||||
const box = document.getElementById('exportPreviewBox');
|
||||
const inner = document.getElementById('exportPreviewInner');
|
||||
const title = document.getElementById('exportPreviewTitle');
|
||||
if (!box || !inner) return;
|
||||
getExportCanvas().then(canvas => {
|
||||
const { w, h } = getResolution();
|
||||
const url = canvas.toDataURL('image/png');
|
||||
const sizeTag = w > 0 && h > 0 ? ` <span class="export-preview-size">${w} × ${h}</span>` : '';
|
||||
title.innerHTML = `📐 导出预览${sizeTag}`;
|
||||
inner.innerHTML = `<img src="${url}" alt="导出预览">`;
|
||||
box.style.display = 'block';
|
||||
}).catch(() => { box.style.display = 'none'; });
|
||||
}
|
||||
|
||||
// ===== 模式切换 =====
|
||||
@@ -625,16 +832,24 @@ function switchMode(mode) {
|
||||
// 更新按钮状态
|
||||
document.getElementById('btnChartMode').classList.toggle('active', mode === 'chart');
|
||||
document.getElementById('btnTableMode').classList.toggle('active', mode === 'table');
|
||||
document.getElementById('btnCombineMode').classList.toggle('active', mode === 'combine');
|
||||
|
||||
// 显示/隐藏配置区
|
||||
document.getElementById('dataInputSection').style.display = mode === 'combine' ? 'none' : 'block';
|
||||
document.getElementById('chartConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('tableConfigSection').style.display = mode === 'table' ? 'block' : 'none';
|
||||
document.getElementById('seriesConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('splitConfigSection').style.display = mode === 'chart' ? 'block' : 'none';
|
||||
document.getElementById('combineConfigSection').style.display = mode === 'combine' ? 'block' : 'none';
|
||||
|
||||
// 更新生成按钮
|
||||
const btnGenerate = document.getElementById('btnGenerate');
|
||||
btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格';
|
||||
if (mode === 'combine') {
|
||||
btnGenerate.style.display = 'none'; // 双图模式用自己的按钮
|
||||
} else {
|
||||
btnGenerate.style.display = '';
|
||||
btnGenerate.textContent = mode === 'chart' ? '🎨 生成图表' : '📋 生成表格';
|
||||
}
|
||||
|
||||
// 更新导出按钮
|
||||
const btnExportSvg = document.getElementById('btnExportSvg');
|
||||
@@ -648,8 +863,10 @@ function switchMode(mode) {
|
||||
function generate() {
|
||||
if (currentMode === 'chart') {
|
||||
generateChart();
|
||||
} else {
|
||||
} else if (currentMode === 'table') {
|
||||
generateTable();
|
||||
} else {
|
||||
generateCombine();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +890,7 @@ function generateTable() {
|
||||
theme: document.getElementById('tableTheme').value,
|
||||
fontSize: parseInt(document.getElementById('tableFontSize').value) || 14,
|
||||
stripeRows: document.getElementById('stripeRows').checked,
|
||||
borderStyle: document.getElementById('borderStyle').value,
|
||||
pixelRatio: 2
|
||||
};
|
||||
|
||||
@@ -692,6 +910,7 @@ function generateTable() {
|
||||
tableImageBlob = blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="表格预览"></div>`;
|
||||
refreshPreview();
|
||||
})
|
||||
.catch(err => {
|
||||
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
|
||||
@@ -707,26 +926,21 @@ function updateTable() {
|
||||
|
||||
// ===== 统一导出图片 =====
|
||||
function exportImage(format) {
|
||||
if (currentMode === 'chart') {
|
||||
exportChart(format);
|
||||
} else {
|
||||
exportTable(format);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导出表格 =====
|
||||
function exportTable(format) {
|
||||
if (!tableImageBlob) {
|
||||
alert('请先生成表格');
|
||||
if (format === 'svg') {
|
||||
if (currentMode !== 'chart') {
|
||||
alert('SVG 仅图表模式支持');
|
||||
return;
|
||||
}
|
||||
exportChartSvg();
|
||||
return;
|
||||
}
|
||||
const prefix = currentMode === 'chart' ? 'chart' : currentMode === 'table' ? 'table' : 'combine';
|
||||
exportCurrent(prefix);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(tableImageBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'table.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
// ===== 导出表格(走统一导出) =====
|
||||
function exportTable(format) {
|
||||
exportCurrent('table');
|
||||
}
|
||||
|
||||
// ===== 快速切换风格 =====
|
||||
@@ -740,11 +954,12 @@ function quickSwitchTheme(theme) {
|
||||
// 图表模式:更新主题选择器并重新渲染
|
||||
document.getElementById('themeStyle').value = theme;
|
||||
updateChart();
|
||||
} else {
|
||||
} else if (currentMode === 'table') {
|
||||
// 表格模式:更新主题选择器并重新请求
|
||||
document.getElementById('tableTheme').value = theme;
|
||||
updateTable();
|
||||
}
|
||||
// 双图合并模式:仅切换按钮高亮,不联动(两图各自独立主题)
|
||||
}
|
||||
|
||||
// ===== 同步风格按钮状态(下拉框变更时) =====
|
||||
@@ -766,3 +981,221 @@ function initThemeButtons() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeButtons();
|
||||
});
|
||||
|
||||
// ===== 双图合并 =====
|
||||
let combinedCanvas = null;
|
||||
|
||||
// 构建单个子图的 echarts option(不依赖全局状态,双图模式专用)
|
||||
function buildCombineChartOption(dataText, cfg) {
|
||||
const parsed = parseData(dataText);
|
||||
if (!parsed) return null;
|
||||
|
||||
const {
|
||||
title = '', chartType = 'bar', theme = 'default',
|
||||
showLegend = true, showGrid = true, showLabel = false,
|
||||
stackMode = false, smoothLine = true
|
||||
} = cfg;
|
||||
|
||||
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
|
||||
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
|
||||
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
|
||||
const palette = colorPalettes[theme] || colorPalettes.default;
|
||||
|
||||
const series = parsed.seriesNames.map((name, idx) => {
|
||||
const color = palette[idx % palette.length];
|
||||
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
|
||||
|
||||
const s = {
|
||||
name,
|
||||
type,
|
||||
data: [...parsed.seriesData[name]],
|
||||
itemStyle: { color },
|
||||
emphasis: { focus: 'series' }
|
||||
};
|
||||
|
||||
if (stackMode) s.stack = 'total';
|
||||
|
||||
if (type === 'line') {
|
||||
s.smooth = smoothLine;
|
||||
s.lineStyle = { width: 3 };
|
||||
s.symbolSize = 8;
|
||||
s.areaStyle = theme === 'gradient' ? { opacity: 0.15 } : undefined;
|
||||
}
|
||||
|
||||
if (type === 'bar') {
|
||||
s.barMaxWidth = 40;
|
||||
s.itemStyle.borderRadius = stackMode ? [0, 0, 0, 0] : [4, 4, 0, 0];
|
||||
if (theme === 'gradient') {
|
||||
s.itemStyle.color = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color },
|
||||
{ offset: 1, color: adjustColor(color, 40) }
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabel) {
|
||||
s.label = {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 11,
|
||||
color: textColor,
|
||||
formatter: (p) => {
|
||||
if (p.value >= 10000) return (p.value / 10000).toFixed(1) + 'w';
|
||||
if (p.value >= 1000) return (p.value / 1000).toFixed(1) + 'k';
|
||||
return p.value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
|
||||
return {
|
||||
backgroundColor: bgColor,
|
||||
title: title ? {
|
||||
text: title,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: { color: textColor, fontSize: 16, fontWeight: 600 }
|
||||
} : undefined,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(30,30,50,0.95)' : 'rgba(255,255,255,0.95)',
|
||||
borderColor: theme === 'dark' ? '#555' : '#eee',
|
||||
textStyle: { color: textColor }
|
||||
},
|
||||
legend: showLegend ? {
|
||||
show: true,
|
||||
top: title ? 40 : 10,
|
||||
textStyle: { color: textColor, fontSize: 12 },
|
||||
itemGap: 20,
|
||||
icon: 'roundRect'
|
||||
} : { show: false },
|
||||
grid: {
|
||||
left: '3%', right: '4%', bottom: '3%',
|
||||
top: showLegend ? (title ? 70 : 45) : (title ? 50 : 30),
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: parsed.categories,
|
||||
axisLine: { lineStyle: { color: axisLineColor } },
|
||||
axisLabel: { color: textColor, fontSize: 11, interval: 0, rotate: parsed.categories.length > 10 ? 30 : 0 },
|
||||
axisTick: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: { show: showGrid, lineStyle: { color: theme === 'dark' ? '#333' : '#f0f0f0', type: 'dashed' } }
|
||||
},
|
||||
series,
|
||||
animation: false
|
||||
};
|
||||
}
|
||||
|
||||
// 根据数据量估算子图尺寸
|
||||
function measureCombineChart(dataText) {
|
||||
const p = parseData(dataText);
|
||||
if (!p) return { w: 600, h: 400 };
|
||||
const w = Math.min(900, Math.max(500, p.categories.length * 70 + 140));
|
||||
return { w, h: 400 };
|
||||
}
|
||||
|
||||
// 生成合并图
|
||||
function generateCombine() {
|
||||
const d1 = document.getElementById('combineData1').value.trim();
|
||||
const d2 = document.getElementById('combineData2').value.trim();
|
||||
if (!d1 || !d2) return;
|
||||
|
||||
const direction = document.querySelector('input[name="combineDirection"]:checked').value;
|
||||
const gap = 24;
|
||||
|
||||
const readCfg = (n) => ({
|
||||
title: document.getElementById('combineTitle' + n).value,
|
||||
chartType: document.getElementById('combineType' + n).value,
|
||||
theme: document.getElementById('combineTheme' + n).value,
|
||||
showLegend: document.getElementById('combineLegend' + n).checked,
|
||||
showGrid: document.getElementById('combineGrid' + n).checked,
|
||||
showLabel: document.getElementById('combineLabel' + n).checked,
|
||||
stackMode: document.getElementById('combineStack' + n).checked
|
||||
});
|
||||
|
||||
const chartArea = document.getElementById('chartArea');
|
||||
chartArea.innerHTML = '<div class="placeholder"><p>⏳</p><p>正在合并...</p></div>';
|
||||
|
||||
const renderOne = (dataText, cfg, w, h) => new Promise((resolve, reject) => {
|
||||
const option = buildCombineChartOption(dataText, cfg);
|
||||
if (!option) { reject(new Error('数据格式错误')); return; }
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = `position:absolute;left:-9999px;top:0;width:${w}px;height:${h}px;`;
|
||||
document.body.appendChild(holder);
|
||||
const chart = echarts.init(holder, null, { renderer: 'canvas' });
|
||||
chart.setOption(option);
|
||||
setTimeout(() => {
|
||||
const srcCvs = holder.querySelector('canvas');
|
||||
if (!srcCvs) { reject(new Error('子图渲染失败')); return; }
|
||||
// 关键:先把内容复制到独立 canvas 再 dispose。
|
||||
// 直接 resolve 原 canvas 的话,Promise 微任务在 chart.dispose() 之后才执行,
|
||||
// 此时 canvas 内容已被 echarts 清空,合并出来就是白图。
|
||||
const copy = document.createElement('canvas');
|
||||
copy.width = srcCvs.width;
|
||||
copy.height = srcCvs.height;
|
||||
copy.getContext('2d').drawImage(srcCvs, 0, 0);
|
||||
chart.dispose();
|
||||
holder.remove();
|
||||
resolve(copy);
|
||||
}, 80);
|
||||
});
|
||||
|
||||
const m1 = measureCombineChart(d1);
|
||||
const m2 = measureCombineChart(d2);
|
||||
|
||||
Promise.all([
|
||||
renderOne(d1, readCfg(1), m1.w, m1.h),
|
||||
renderOne(d2, readCfg(2), m2.w, m2.h)
|
||||
]).then(([c1, c2]) => {
|
||||
let W, H, r1, r2;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽
|
||||
const tw = Math.max(c1.width, c2.width);
|
||||
r1 = { w: tw, h: Math.round(c1.height * tw / c1.width) };
|
||||
r2 = { w: tw, h: Math.round(c2.height * tw / c2.width) };
|
||||
W = tw; H = r1.h + r2.h + gap;
|
||||
} else {
|
||||
// 横排(左右):等高
|
||||
const th = Math.max(c1.height, c2.height);
|
||||
r1 = { w: Math.round(c1.width * th / c1.height), h: th };
|
||||
r2 = { w: Math.round(c2.width * th / c2.height), h: th };
|
||||
W = r1.w + r2.w + gap; H = th;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, 0, r1.h + gap, r2.w, r2.h);
|
||||
} else {
|
||||
ctx.drawImage(c1, 0, 0, r1.w, r1.h);
|
||||
ctx.drawImage(c2, r1.w + gap, 0, r2.w, r2.h);
|
||||
}
|
||||
|
||||
combinedCanvas = canvas;
|
||||
const url = canvas.toDataURL('image/png');
|
||||
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="合并图预览" style="max-width:100%;"></div>`;
|
||||
refreshPreview();
|
||||
}).catch(err => {
|
||||
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 导出合并图(走统一导出)
|
||||
function exportCombine() {
|
||||
exportCurrent('combine');
|
||||
}
|
||||
+203
-6
@@ -23,11 +23,12 @@
|
||||
<div class="mode-switch">
|
||||
<button class="mode-btn active" id="btnChartMode" onclick="switchMode('chart')">📈 图表模式</button>
|
||||
<button class="mode-btn" id="btnTableMode" onclick="switchMode('table')">📋 表格模式</button>
|
||||
<button class="mode-btn" id="btnCombineMode" onclick="switchMode('combine')">🖼️ 双图合并</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据输入区 -->
|
||||
<div class="panel-section">
|
||||
<div class="panel-section" id="dataInputSection">
|
||||
<h2>📝 数据输入</h2>
|
||||
<div class="input-format-hint">
|
||||
<p><strong>格式说明:</strong></p>
|
||||
@@ -151,6 +152,22 @@ C, 20, 30, 40</pre>
|
||||
<label><input type="checkbox" id="stripeRows" checked onchange="updateTable()"> 斑马纹</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>边框样式</label>
|
||||
<select id="borderStyle" onchange="updateTable()">
|
||||
<option value="all">全部边框</option>
|
||||
<option value="none">无边框</option>
|
||||
<option value="no-outer">无外边线</option>
|
||||
<option value="no-horizontal">无横线</option>
|
||||
<option value="no-vertical">无竖线</option>
|
||||
<option value="no-inner">无内部线</option>
|
||||
<option value="no-inner-horizontal">无内部横线</option>
|
||||
<option value="no-inner-vertical">无内部竖线</option>
|
||||
<option value="outer-only">仅外边框</option>
|
||||
<option value="header-only">仅表头分隔线</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系列配置区 -->
|
||||
@@ -191,16 +208,191 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出按钮 -->
|
||||
<div class="panel-section">
|
||||
<!-- 导出设置 + 按钮(默认折叠) -->
|
||||
<div class="panel-section" id="exportSection">
|
||||
<div class="export-header">
|
||||
<div class="export-header-main" onclick="toggleExportSettings()" title="点击展开/折叠">
|
||||
<h2>📤 导出设置</h2>
|
||||
<span class="export-summary" id="exportSummary">原始大小</span>
|
||||
<span class="export-toggle" id="exportToggle">▸</span>
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" onclick="exportImage('png')">📥 下载</button>
|
||||
</div>
|
||||
|
||||
<div id="exportSettingsBody" class="export-settings-body" style="display:none;">
|
||||
<div class="config-group">
|
||||
<label>分辨率</label>
|
||||
<select id="exportPreset" onchange="onExportPresetChange()">
|
||||
<option value="original">原始大小</option>
|
||||
<option value="1920x1080">1920 × 1080</option>
|
||||
<option value="1280x720">1280 × 720</option>
|
||||
<option value="1024x768">1024 × 768</option>
|
||||
<option value="800x600">800 × 600</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-group" id="customResGroup" style="display:none;">
|
||||
<label>自定义分辨率(宽 × 高)</label>
|
||||
<div class="res-input-row">
|
||||
<input type="number" id="customWidth" min="1" max="8192" placeholder="宽" oninput="onCustomResInput()">
|
||||
<span class="res-times">×</span>
|
||||
<input type="number" id="customHeight" min="1" max="8192" placeholder="高" oninput="onCustomResInput()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>历史分辨率(最近5个)</label>
|
||||
<select id="exportHistory" onchange="onHistorySelect()">
|
||||
<option value="">— 暂无历史 —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="config-group">
|
||||
<label>文件名</label>
|
||||
<input type="text" id="exportFilename" placeholder="留空则按日期时间自动命名">
|
||||
<p class="hint-text" id="exportResHint"></p>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="resetExportSettings()">🔄 恢复默认</button>
|
||||
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双图合并配置区 -->
|
||||
<div class="panel-section" id="combineConfigSection" style="display:none;">
|
||||
<h2>🖼️ 双图合并配置</h2>
|
||||
|
||||
<div class="config-group">
|
||||
<label>排列方向</label>
|
||||
<div class="combine-direction">
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="horizontal" checked onchange="generateCombine()"> ⇄ 横排(左右)</label>
|
||||
<label class="dir-option"><input type="radio" name="combineDirection" value="vertical" onchange="generateCombine()"> ⇅ 竖排(上下)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combine-chart-card">
|
||||
<h3>📈 图1</h3>
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="combineTitle1" placeholder="图1标题" oninput="generateCombine()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>图表类型</label>
|
||||
<select id="combineType1" onchange="generateCombine()">
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select id="combineTheme1" onchange="generateCombine()">
|
||||
<option value="default">默认</option>
|
||||
<option value="dark">深色</option>
|
||||
<option value="macarons">马卡龙</option>
|
||||
<option value="gradient">渐变</option>
|
||||
<option value="retro">复古</option>
|
||||
<option value="ocean">海洋</option>
|
||||
<option value="forest">森林</option>
|
||||
<option value="sunset">日落</option>
|
||||
<option value="lavender">薰衣草</option>
|
||||
<option value="minimal">极简</option>
|
||||
<option value="cherry">樱花</option>
|
||||
<option value="midnight">午夜</option>
|
||||
<option value="gold">金色</option>
|
||||
<option value="coral">珊瑚</option>
|
||||
<option value="mint">薄荷</option>
|
||||
<option value="slate">石板灰</option>
|
||||
<option value="sky">天空蓝</option>
|
||||
<option value="rose">玫瑰红</option>
|
||||
<option value="amber">琥珀黄</option>
|
||||
<option value="emerald">翡翠绿</option>
|
||||
<option value="indigo">靛蓝色</option>
|
||||
<option value="stone">石灰白</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>显示选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="combineLegend1" checked onchange="generateCombine()"> 图例</label>
|
||||
<label><input type="checkbox" id="combineGrid1" checked onchange="generateCombine()"> 网格线</label>
|
||||
<label><input type="checkbox" id="combineLabel1" onchange="generateCombine()"> 数据标签</label>
|
||||
<label><input type="checkbox" id="combineStack1" onchange="generateCombine()"> 堆叠</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>数据</label>
|
||||
<textarea id="combineData1" rows="5" placeholder="图1的CSV数据..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combine-chart-card">
|
||||
<h3>📈 图2</h3>
|
||||
<div class="config-group">
|
||||
<label>标题</label>
|
||||
<input type="text" id="combineTitle2" placeholder="图2标题" oninput="generateCombine()">
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>图表类型</label>
|
||||
<select id="combineType2" onchange="generateCombine()">
|
||||
<option value="bar">柱状图</option>
|
||||
<option value="line">折线图</option>
|
||||
<option value="bar-line">柱状图+折线图混合</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>主题风格</label>
|
||||
<select id="combineTheme2" onchange="generateCombine()">
|
||||
<option value="default">默认</option>
|
||||
<option value="dark">深色</option>
|
||||
<option value="macarons">马卡龙</option>
|
||||
<option value="gradient">渐变</option>
|
||||
<option value="retro">复古</option>
|
||||
<option value="ocean">海洋</option>
|
||||
<option value="forest">森林</option>
|
||||
<option value="sunset">日落</option>
|
||||
<option value="lavender">薰衣草</option>
|
||||
<option value="minimal">极简</option>
|
||||
<option value="cherry">樱花</option>
|
||||
<option value="midnight">午夜</option>
|
||||
<option value="gold">金色</option>
|
||||
<option value="coral">珊瑚</option>
|
||||
<option value="mint">薄荷</option>
|
||||
<option value="slate">石板灰</option>
|
||||
<option value="sky">天空蓝</option>
|
||||
<option value="rose">玫瑰红</option>
|
||||
<option value="amber">琥珀黄</option>
|
||||
<option value="emerald">翡翠绿</option>
|
||||
<option value="indigo">靛蓝色</option>
|
||||
<option value="stone">石灰白</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>显示选项</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" id="combineLegend2" checked onchange="generateCombine()"> 图例</label>
|
||||
<label><input type="checkbox" id="combineGrid2" checked onchange="generateCombine()"> 网格线</label>
|
||||
<label><input type="checkbox" id="combineLabel2" onchange="generateCombine()"> 数据标签</label>
|
||||
<label><input type="checkbox" id="combineStack2" onchange="generateCombine()"> 堆叠</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-group">
|
||||
<label>数据</label>
|
||||
<textarea id="combineData2" rows="5" placeholder="图2的CSV数据..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" onclick="exportImage('png')">📥 导出PNG</button>
|
||||
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
|
||||
<button class="btn btn-primary" onclick="generateCombine()">🖼️ 生成合并图</button>
|
||||
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板:图表预览 + 风格切换 -->
|
||||
<!-- 右侧面板:图表预览 + 导出预览 + 风格切换 -->
|
||||
<div class="right-panel">
|
||||
<div class="chart-container">
|
||||
<div id="chartArea" class="chart-area">
|
||||
@@ -210,6 +402,11 @@ C, 20, 30, 40</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 导出效果预览(按分辨率居中展示保存后的图) -->
|
||||
<div class="export-preview-box" id="exportPreviewBox" style="display:none;">
|
||||
<div class="export-preview-title" id="exportPreviewTitle">📐 导出预览</div>
|
||||
<div class="export-preview-inner" id="exportPreviewInner"></div>
|
||||
</div>
|
||||
<!-- 风格快速切换按钮(放在图表右侧) -->
|
||||
<div class="theme-buttons-section">
|
||||
<h3>🎨 风格</h3>
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bash
|
||||
# data-chart-tool 保活脚本
|
||||
# 检查服务是否运行,如果没有则启动
|
||||
|
||||
PORT=16016
|
||||
APP_DIR="/home/openclaw/.openclaw/workspace-hz4th_coder/works/data-chart-tool"
|
||||
LOG_FILE="$APP_DIR/logs/server.log"
|
||||
|
||||
# 检查端口是否被占用
|
||||
if ss -tlnp | grep -q ":$PORT "; then
|
||||
# 检查是不是 node 进程
|
||||
PID=$(ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | head -1)
|
||||
if [ -n "$PID" ]; then
|
||||
CMD=$(ps -p "$PID" -o comm= 2>/dev/null)
|
||||
if [ "$CMD" = "node" ] || [ "$CMD" = "MainThread" ]; then
|
||||
# node 服务在运行,检查是否正常响应
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/api/health 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "[$(date)] Service OK (PID=$PID, HTTP=$HTTP_CODE)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
# 端口被非 node 进程占用(如 Python http.server),杀掉它
|
||||
echo "[$(date)] Port $PORT occupied by $CMD (PID=$PID), killing..."
|
||||
kill -9 "$PID" 2>/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动 node 服务
|
||||
echo "[$(date)] Starting data-chart-tool..."
|
||||
cd "$APP_DIR"
|
||||
nohup node server.js >> "$LOG_FILE" 2>&1 &
|
||||
echo "[$(date)] Started with PID $!"
|
||||
|
||||
# 等待启动
|
||||
sleep 2
|
||||
|
||||
# 验证
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:$PORT/api/health 2>/dev/null)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "[$(date)] Service started successfully (HTTP=$HTTP_CODE)"
|
||||
else
|
||||
echo "[$(date)] WARNING: Service may not have started correctly (HTTP=$HTTP_CODE)"
|
||||
fi
|
||||
@@ -1,13 +1,15 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { createCanvas, registerFont } = require('@napi-rs/canvas');
|
||||
const { createCanvas, registerFont, loadImage } = require('@napi-rs/canvas');
|
||||
const echarts = require('echarts');
|
||||
const path = require('path');
|
||||
|
||||
// 注册中文字体
|
||||
try {
|
||||
registerFont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', { family: 'Noto Sans CJK SC' });
|
||||
registerFont('/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', { family: 'Noto Sans CJK SC', weight: 'bold' });
|
||||
const { GlobalFonts } = require('@napi-rs/canvas');
|
||||
GlobalFonts.registerFromPath('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', 'Noto Sans CJK SC');
|
||||
GlobalFonts.registerFromPath('/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', 'Noto Sans CJK SC');
|
||||
console.log('中文字体注册成功');
|
||||
} catch (e) {
|
||||
console.warn('中文字体注册失败,将使用系统默认字体:', e.message);
|
||||
}
|
||||
@@ -655,7 +657,8 @@ function generateTableImage(params) {
|
||||
borderWidth = 1,
|
||||
stripeRows = true,
|
||||
pixelRatio = 2,
|
||||
maxWidth = 1200
|
||||
maxWidth = 1200,
|
||||
borderStyle = 'all' // all, none, no-outer, no-horizontal, no-vertical, no-inner, no-inner-horizontal, no-inner-vertical, outer-only, header-only
|
||||
} = params;
|
||||
|
||||
const tableData = parseTableData(data);
|
||||
@@ -694,7 +697,7 @@ function generateTableImage(params) {
|
||||
const headerHeight = fontSize * 2.5;
|
||||
const rowHeight = fontSize * 2.2;
|
||||
const titleHeight = title ? fontSize * 3 : 0;
|
||||
const tableHeight = headerHeight + rows.length * rowHeight + borderWidth * 2;
|
||||
const tableHeight = headerHeight + rows.length * rowHeight;
|
||||
const finalHeight = tableHeight + titleHeight;
|
||||
|
||||
// 创建正式 canvas
|
||||
@@ -763,34 +766,50 @@ function generateTableImage(params) {
|
||||
ctx.strokeStyle = themeConfig.borderColor;
|
||||
ctx.lineWidth = borderWidth;
|
||||
|
||||
// 边框样式控制
|
||||
const drawOuter = !['none', 'no-outer', 'no-inner', 'no-inner-horizontal', 'no-inner-vertical', 'header-only'].includes(borderStyle);
|
||||
const drawHorizontal = !['none', 'no-horizontal', 'no-inner', 'no-inner-horizontal', 'outer-only', 'header-only'].includes(borderStyle);
|
||||
const drawVertical = !['none', 'no-vertical', 'no-inner', 'no-inner-vertical', 'outer-only'].includes(borderStyle);
|
||||
const drawHeaderLine = !['none', 'no-horizontal', 'no-inner', 'no-inner-horizontal'].includes(borderStyle);
|
||||
|
||||
// 外边框
|
||||
ctx.strokeRect(0, startY, finalWidth, tableHeight);
|
||||
if (drawOuter) {
|
||||
const offset = borderWidth / 2;
|
||||
ctx.strokeRect(offset, startY + offset, finalWidth - borderWidth, tableHeight - borderWidth);
|
||||
}
|
||||
|
||||
// 横线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, startY + headerHeight);
|
||||
ctx.lineTo(finalWidth, startY + headerHeight);
|
||||
ctx.stroke();
|
||||
|
||||
rows.forEach((_, rowIdx) => {
|
||||
const y = startY + headerHeight + (rowIdx + 1) * rowHeight;
|
||||
// 表头分隔线(横线)
|
||||
if (drawHeaderLine) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(finalWidth, y);
|
||||
ctx.moveTo(0, startY + headerHeight);
|
||||
ctx.lineTo(finalWidth, startY + headerHeight);
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// 数据行横线
|
||||
if (drawHorizontal) {
|
||||
rows.forEach((_, rowIdx) => {
|
||||
const y = startY + headerHeight + (rowIdx + 1) * rowHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(finalWidth, y);
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// 竖线
|
||||
x = 0;
|
||||
colWidths.forEach((width, i) => {
|
||||
if (i > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, startY);
|
||||
ctx.lineTo(x, startY + tableHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
x += width;
|
||||
});
|
||||
if (drawVertical) {
|
||||
x = 0;
|
||||
colWidths.forEach((width, i) => {
|
||||
if (i > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, startY);
|
||||
ctx.lineTo(x, startY + tableHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
x += width;
|
||||
});
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
@@ -832,7 +851,8 @@ app.get('/api/table', (req, res) => {
|
||||
borderWidth: parseInt(req.query.borderWidth) || 1,
|
||||
stripeRows: req.query.stripeRows !== 'false',
|
||||
pixelRatio: parseInt(req.query.pixelRatio) || 2,
|
||||
maxWidth: parseInt(req.query.maxWidth) || 1200
|
||||
maxWidth: parseInt(req.query.maxWidth) || 1200,
|
||||
borderStyle: req.query.borderStyle || 'all'
|
||||
};
|
||||
|
||||
if (!params.data) {
|
||||
@@ -862,7 +882,7 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.2.0',
|
||||
version: '1.10.1',
|
||||
endpoints: {
|
||||
'POST /api/chart': '生成图表图片(JSON body)',
|
||||
'GET /api/chart': '生成图表图片(URL 参数)',
|
||||
@@ -877,7 +897,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.2.0',
|
||||
version: '1.10.1',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -950,7 +970,8 @@ app.get('/api/docs', (req, res) => {
|
||||
borderWidth: { type: 'number', default: 1, description: '边框宽度' },
|
||||
stripeRows: { type: 'boolean', default: true, description: '是否斑马纹' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
maxWidth: { type: 'number', default: 1200, description: '最大宽度(px)' }
|
||||
maxWidth: { type: 'number', default: 1200, description: '最大宽度(px)' },
|
||||
borderStyle: { type: 'string', default: 'all', options: ['all', 'none', 'no-outer', 'no-horizontal', 'no-vertical', 'no-inner', 'no-inner-horizontal', 'no-inner-vertical', 'outer-only', 'header-only'], description: '边框样式' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
@@ -979,17 +1000,126 @@ app.get('/api/docs', (req, res) => {
|
||||
example: {
|
||||
request: `curl "http://localhost:16016/api/table?data=产品,价格,库存\\n手机,2999,100\\n平板,1999,50&title=产品列表" -o table.png`
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/combine',
|
||||
description: '将两张图表合并到一张图片中(支持横排/竖排)',
|
||||
'Content-Type': 'application/json',
|
||||
params: {
|
||||
chart1: { type: 'object', required: true, description: '第一张图配置(同 /api/chart 参数)' },
|
||||
chart2: { type: 'object', required: true, description: '第二张图配置(同 /api/chart 参数)' },
|
||||
direction: { type: 'string', default: 'horizontal', options: ['horizontal', 'vertical'], description: '排列方向:horizontal 横排(左右)/ vertical 竖排(上下)' },
|
||||
gap: { type: 'number', default: 24, description: '两图间距(px)' },
|
||||
pixelRatio: { type: 'number', default: 2, description: '像素倍率(清晰度)' },
|
||||
background: { type: 'string', default: '#ffffff', description: '背景色' }
|
||||
},
|
||||
returns: 'image/png',
|
||||
example: {
|
||||
request: `curl -X POST http://localhost:16016/api/combine \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"chart1": {"data": "产品, Q1, Q2\\n手机, 1200, 1800\\n平板, 800, 950", "title": "2024年销售", "chartType": "bar"},
|
||||
"chart2": {"data": "月份, 营收\\n1月, 500\\n2月, 680\\n3月, 820", "title": "营收趋势", "chartType": "line"},
|
||||
"direction": "horizontal"
|
||||
}' -o combine.png`,
|
||||
response: 'PNG 图片二进制流'
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
// ===== API: 双图合并生成图片 =====
|
||||
// 将两张图(图表/表格均可)合并到一张图片中,支持横排(左右)或竖排(上下)
|
||||
app.post('/api/combine', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const chart1 = body.chart1 || {};
|
||||
const chart2 = body.chart2 || {};
|
||||
const direction = body.direction === 'vertical' ? 'vertical' : 'horizontal';
|
||||
const gap = parseInt(body.gap) || 24;
|
||||
const pixelRatio = parseInt(body.pixelRatio) || 2;
|
||||
const background = body.background || '#ffffff';
|
||||
|
||||
if (!chart1.data || !chart2.data) {
|
||||
return res.status(400).json({ error: 'chart1 和 chart2 都需要提供 data 参数(CSV 格式)' });
|
||||
}
|
||||
|
||||
// 渲染单个子图(直接按目标尺寸渲染,避免二次缩放损失)
|
||||
// 注意:必须先 toBuffer 再 chart.dispose(),dispose 会清空 canvas 内容!
|
||||
const renderSub = async (params, w, h) => {
|
||||
const option = buildChartOption(params);
|
||||
const c = createCanvas(w * pixelRatio, h * pixelRatio);
|
||||
const chart = echarts.init(c, null, {
|
||||
renderer: 'canvas',
|
||||
width: w,
|
||||
height: h,
|
||||
devicePixelRatio: pixelRatio
|
||||
});
|
||||
chart.setOption(option);
|
||||
const buf = c.toBuffer('image/png');
|
||||
chart.dispose();
|
||||
const img = await loadImage(buf);
|
||||
return { img, w, h };
|
||||
};
|
||||
|
||||
const a = await renderSub(chart1, parseInt(chart1.width) || 640, parseInt(chart1.height) || 420);
|
||||
const b = await renderSub(chart2, parseInt(chart2.width) || 640, parseInt(chart2.height) || 420);
|
||||
|
||||
let W, H, aRect, bRect;
|
||||
if (direction === 'vertical') {
|
||||
// 竖排(上下):等宽对齐
|
||||
const tw = Math.max(a.w, b.w);
|
||||
aRect = { w: tw, h: Math.round(a.h * (tw / a.w)) };
|
||||
bRect = { w: tw, h: Math.round(b.h * (tw / b.w)) };
|
||||
W = tw;
|
||||
H = aRect.h + bRect.h + gap;
|
||||
} else {
|
||||
// 横排(左右):等高对齐
|
||||
const th = Math.max(a.h, b.h);
|
||||
aRect = { w: Math.round(a.w * (th / a.h)), h: th };
|
||||
bRect = { w: Math.round(b.w * (th / b.h)), h: th };
|
||||
W = aRect.w + bRect.w + gap;
|
||||
H = th;
|
||||
}
|
||||
|
||||
const canvas = createCanvas(W * pixelRatio, H * pixelRatio);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, W * pixelRatio, H * pixelRatio);
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// 5 参数形式:drawImage(img, dx, dy, dw, dh),源整图缩放到目标矩形
|
||||
ctx.drawImage(a.img, 0, 0, aRect.w * pixelRatio, aRect.h * pixelRatio);
|
||||
ctx.drawImage(b.img, 0, (aRect.h + gap) * pixelRatio, bRect.w * pixelRatio, bRect.h * pixelRatio);
|
||||
} else {
|
||||
ctx.drawImage(a.img, 0, 0, aRect.w * pixelRatio, aRect.h * pixelRatio);
|
||||
ctx.drawImage(b.img, (aRect.w + gap) * pixelRatio, 0, bRect.w * pixelRatio, bRect.h * pixelRatio);
|
||||
}
|
||||
|
||||
const buffer = canvas.toBuffer('image/png');
|
||||
res.set({
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length,
|
||||
'X-Combine-Direction': direction,
|
||||
'X-Chart-Width': W,
|
||||
'X-Chart-Height': H
|
||||
});
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('Combine error:', err);
|
||||
res.status(500).json({ error: '双图合并失败: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 启动服务 =====
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 数据可视化图表生成器已启动`);
|
||||
console.log(`📊 Web UI: http://0.0.0.0:${PORT}`);
|
||||
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(`📖 文档: http://0.0.0.0:${PORT}/api/docs`);
|
||||
console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`);
|
||||
});
|
||||
@@ -428,6 +428,53 @@ body {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ===== 双图合并 ===== */
|
||||
.combine-direction {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dir-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dir-option:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.dir-option:has(input:checked) {
|
||||
border-color: var(--primary);
|
||||
background: #eef2ff;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.combine-chart-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.combine-chart-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ===== 表格预览 ===== */
|
||||
.table-preview {
|
||||
width: 100%;
|
||||
@@ -560,3 +607,118 @@ input[type="range"]::-moz-range-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ===== 导出设置 ===== */
|
||||
.res-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.res-input-row input[type="number"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.res-times {
|
||||
color: var(--text-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 导出预览(按分辨率居中展示保存后的图) ===== */
|
||||
.export-preview-box {
|
||||
margin-top: 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.export-preview-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.export-preview-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.export-preview-inner img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.export-preview-size {
|
||||
font-size: 0.78rem;
|
||||
color: var(--primary);
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 导出设置折叠栏 ===== */
|
||||
.export-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.export-header h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.export-summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary);
|
||||
background: #eef2ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-toggle {
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.export-settings-body {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
Reference in New Issue
Block a user