Compare commits

...
2 Commits
Author SHA1 Message Date
hz4th_coder 606610f8d3 feat: 新增收藏功能 - 图/表/合并图一键收藏,按原始大小保存到收藏区,可查看原图/下载/删除,点击编辑在新标签页加载配置再次制作 2026-08-19 17:55:18 +08:00
hz4th_coder 7a03ecc063 feat: 新增饼图/雷达图图表类型
- 单图模式图表类型新增:饼图(环形占比,第一列=名称/第一系列=数值)、雷达图(多维度对比,第一列=维度/每系列=多边形)
- 多图合并的每张图也可选饼图/雷达图
- 后端 /api/chart 与 /api/combine 的 chartType 支持 pie/radar
- 饼图/雷达图自动隐藏坐标轴/网格/双轴等不适用配置
2026-08-19 17:12:18 +08:00
7 changed files with 933 additions and 6 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/
logs/
data/
*.png
*.svg
+2 -2
View File
@@ -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` | 显示图例 |
@@ -181,7 +181,7 @@ Content-Type: application/json
> 兼容旧参数:`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`
### curl 示例
+2
View File
@@ -11,6 +11,8 @@
- **柱状图** - 支持单系列/多系列对比
- **折线图** - 支持平滑曲线、面积填充
- **混合图** - 柱状图+折线图组合展示
- **饼图** - 环形占比展示(第一列=名称,第一系列=数值)
- **雷达图** - 多维度对比(第一列=维度,每个系列=一个多边形)
- **双Y轴** - 一张图左右两个坐标轴,量度可不同,左右轴可独立命名,系列可指定左轴/右轴且每种类型独立设置(柱状/折线)
### 📋 表格功能
+432 -1
View File
@@ -67,6 +67,13 @@ document.addEventListener('DOMContentLoaded', () => {
// 初始化导出设置
renderHistory();
refreshExportHint();
// 收藏:URL 带 id 时,在新标签页加载该收藏配置进行再次制作
const urlParams = new URLSearchParams(location.search);
const favId = urlParams.get('id');
if (favId) {
loadFavoriteForEdit(favId);
}
});
// ===== 数据解析 =====
@@ -157,6 +164,91 @@ function initChart() {
});
}
// ===== 饼图/雷达图 option 构建 =====
function buildPieRadarOption(chartType) {
if (!parsedData || parsedData.seriesNames.length === 0) return null;
const title = document.getElementById('chartTitle').value;
const theme = document.getElementById('themeStyle').value;
const showLegend = document.getElementById('showLegend').checked;
const showLabel = document.getElementById('showLabel').checked;
const bgColor = theme === 'dark' ? '#1a1a2e' : '#ffffff';
const textColor = theme === 'dark' ? '#e0e0e0' : '#333333';
const palette = colorPalettes[theme] || colorPalettes.default;
const colors = seriesOrder.map((origIdx, di) => seriesColors[origIdx] || palette[di % palette.length]);
const baseTitle = title ? {
text: title, left: 'center', top: 10,
textStyle: { color: textColor, 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: bgColor,
title: baseTitle,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? {
orient: 'vertical', left: 'left', top: title ? 50 : 20,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['52%', '55%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}]
};
} 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: bgColor,
title: baseTitle,
tooltip: { trigger: 'item' },
legend: showLegend ? {
orient: 'horizontal', left: 'center', top: title ? 48 : 15,
textStyle: { color: textColor, fontSize: 12 }
} : { show: false },
color: colors,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, 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 }
}))
};
}
}
// ===== 更新图表 =====
function updateChart() {
if (!parsedData || !chartInstance) return;
@@ -174,6 +266,16 @@ function updateChart() {
const leftAxisName = document.getElementById('leftAxisName').value;
const rightAxisName = document.getElementById('rightAxisName').value;
// 饼图/雷达图:走专用构建逻辑(无坐标轴/网格,不支持双轴/堆叠/分割)
if (chartType === 'pie' || chartType === 'radar') {
const option = buildPieRadarOption(chartType);
if (option) {
chartInstance.setOption(option, true);
refreshPreview();
}
return;
}
// 显示/隐藏分割配置
document.getElementById('splitConfig').style.display = enableSplit ? 'block' : 'none';
@@ -1058,6 +1160,66 @@ function buildCombineChartOption(dataText, cfg) {
const axisLineColor = theme === 'dark' ? '#444' : '#ddd';
const palette = colorPalettes[theme] || colorPalettes.default;
// 饼图/雷达图:专用构建逻辑
if (chartType === 'pie' || chartType === 'radar') {
if (chartType === 'pie') {
const sName = parsed.seriesNames[0];
const data = parsed.seriesData[sName];
const pieData = parsed.categories.map((c, i) => ({ name: c, value: data[i] || 0 }));
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: showLegend ? { orient: 'vertical', left: 'left', top: title ? 40 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
series: [{
type: 'pie',
radius: ['38%', '68%'],
center: ['55%', '55%'],
itemStyle: { borderRadius: 6, borderColor: bgColor, borderWidth: 2 },
label: { show: showLabel, formatter: '{b}: {d}%', color: textColor },
labelLine: { show: showLabel },
data: pieData
}],
animation: false
};
} else {
const indicator = parsed.categories.map(c => {
let max = 0;
parsed.seriesNames.forEach(n => { parsed.seriesData[n].forEach(v => { if (v > max) max = v; }); });
return { name: c, max: Math.ceil(max * 1.2) || 100 };
});
return {
backgroundColor: bgColor,
title: title ? { text: title, left: 'center', top: 10, textStyle: { color: textColor, fontSize: 16, fontWeight: 600 } } : undefined,
tooltip: { trigger: 'item' },
legend: showLegend ? { orient: 'horizontal', left: 'center', top: title ? 38 : 10, textStyle: { color: textColor, fontSize: 12 } } : { show: false },
color: palette,
radar: {
indicator,
radius: '62%',
center: ['50%', '55%'],
splitNumber: 5,
axisName: { color: textColor, fontSize: 11 },
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: parsed.seriesNames.map(n => ({
type: 'radar', name: n,
data: [{ value: parsed.seriesData[n], name: n }],
symbolSize: 4,
areaStyle: { opacity: 0.15 }
})),
animation: false
};
}
}
const series = parsed.seriesNames.map((name, idx) => {
const color = palette[idx % palette.length];
let type = chartType === 'bar-line' ? (idx % 2 === 0 ? 'bar' : 'line') : chartType;
@@ -1177,7 +1339,7 @@ function initCombineCharts() {
renderCombineCharts();
}
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合']];
const COMBINE_TYPES = [['bar', '柱状图'], ['line', '折线图'], ['bar-line', '柱状图+折线图混合'], ['pie', '饼图'], ['radar', '雷达图']];
const COMBINE_THEMES = [['default', '默认'], ['dark', '深色'], ['macarons', '马卡龙'], ['gradient', '渐变'], ['retro', '复古'], ['ocean', '海洋'], ['forest', '森林'], ['sunset', '日落'], ['lavender', '薰衣草'], ['minimal', '极简'], ['cherry', '樱花'], ['midnight', '午夜'], ['gold', '金色'], ['coral', '珊瑚'], ['mint', '薄荷'], ['slate', '石板灰'], ['sky', '天空蓝'], ['rose', '玫瑰红'], ['amber', '琥珀黄'], ['emerald', '翡翠绿'], ['indigo', '靛蓝色'], ['stone', '石灰白']];
function escHtml(str) {
@@ -1355,3 +1517,272 @@ function generateCombine() {
function exportCombine() {
exportCurrent('combine');
}
// ===== 收藏功能 =====
function collectChartConfig() {
return {
mode: 'chart',
data: document.getElementById('dataInput').value,
chartType: document.getElementById('chartType').value,
title: document.getElementById('chartTitle').value,
theme: document.getElementById('themeStyle').value,
showLegend: document.getElementById('showLegend').checked,
showGrid: document.getElementById('showGrid').checked,
showLabel: document.getElementById('showLabel').checked,
stackMode: document.getElementById('stackMode').checked,
smoothLine: document.getElementById('smoothLine').checked,
dualAxis: document.getElementById('dualAxis').checked,
leftAxisName: document.getElementById('leftAxisName').value,
rightAxisName: document.getElementById('rightAxisName').value,
enableSplit: document.getElementById('enableSplit').checked,
splitIndex: parseInt(document.getElementById('splitIndex').value) || 3,
leftLabel: document.getElementById('leftLabel').value,
rightLabel: document.getElementById('rightLabel').value,
splitStyle: document.getElementById('splitStyle').value,
seriesColors: [...seriesColors],
seriesOrder: [...seriesOrder],
seriesAxis: [...seriesAxis],
seriesTypeOverrides: window.seriesTypeOverrides || {}
};
}
function collectTableConfig() {
return {
mode: 'table',
data: document.getElementById('dataInput').value,
title: document.getElementById('tableTitle').value,
theme: document.getElementById('tableTheme').value,
fontSize: parseInt(document.getElementById('tableFontSize').value) || 14,
stripeRows: document.getElementById('stripeRows').checked,
borderStyle: document.getElementById('borderStyle').value
};
}
function collectCombineConfig() {
return {
mode: 'combine',
charts: combineCharts.map(c => ({ ...c })),
direction: (document.querySelector('input[name="combineDirection"]:checked') || {}).value || 'horizontal',
gridCols: parseInt(document.getElementById('gridCols').value) || 2
};
}
function collectConfig() {
if (currentMode === 'chart') return collectChartConfig();
if (currentMode === 'table') return collectTableConfig();
return collectCombineConfig();
}
function favoriteTitle(cfg) {
if (cfg.mode === 'chart') return cfg.title || '未命名图表';
if (cfg.mode === 'table') return cfg.title || '未命名表格';
return '多图合并';
}
// 点击「⭐ 收藏」:把当前图/表按原始大小保存到收藏区
function hasFavoriteContent(cfg) {
if (cfg.mode === 'combine') {
return Array.isArray(cfg.charts) && cfg.charts.some(c => c.data && c.data.trim());
}
return !!(cfg.data && cfg.data.trim());
}
function saveFavorite() {
let cfg;
try { cfg = collectConfig(); } catch (e) { alert(e.message); return; }
if (!hasFavoriteContent(cfg)) { alert('没有可收藏的内容,请先生成图或表'); return; }
const btn = document.getElementById('btnFavorite');
if (btn) { btn.disabled = true; btn.textContent = '⏳ 收藏中...'; }
getSourceCanvas()
.then(canvas => {
const image = canvas.toDataURL('image/png');
const title = favoriteTitle(cfg);
return fetch('/api/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: cfg.mode, title, config: cfg, image })
});
})
.then(res => res.json().then(data => ({ ok: res.ok, data })))
.then(({ ok, data }) => {
if (!ok) throw new Error((data && data.error) || '收藏失败');
showToast('⭐ 已收藏:' + (data.title || ''));
refreshFavorites();
})
.catch(err => alert(err.message))
.finally(() => {
if (btn) { btn.disabled = false; btn.textContent = '⭐ 收藏'; }
});
}
// ===== 收藏区(弹窗) =====
function openFavorites() {
const modal = document.getElementById('favoritesModal');
if (modal) modal.style.display = 'flex';
refreshFavorites();
}
function closeFavorites() {
const modal = document.getElementById('favoritesModal');
if (modal) modal.style.display = 'none';
}
function refreshFavorites() {
fetch('/api/favorites')
.then(res => res.json())
.then(data => renderFavorites((data && data.favorites) || []))
.catch(err => {
const list = document.getElementById('favoritesList');
if (list) list.innerHTML = `<div class="fav-empty">加载失败:${escHtml(err.message)}</div>`;
});
}
function renderFavorites(favs) {
const list = document.getElementById('favoritesList');
const count = document.getElementById('favCount');
if (count) count.textContent = String(favs.length);
if (!list) return;
if (!favs.length) {
list.innerHTML = `<div class="fav-empty">🕊️ 暂无收藏,点击「⭐ 收藏」保存当前图表或表格</div>`;
return;
}
const MODE_LABEL = { chart: '📈 图表', table: '📋 表格', combine: '🖼️ 多图' };
list.innerHTML = favs.map(f => `
<div class="fav-card">
<div class="fav-card-img">
<img src="/api/favorites/${f.id}/image" alt="${escHtml(f.title)}" loading="lazy"
onclick="window.open('/api/favorites/${f.id}/image','_blank')" title="点击查看原图">
<span class="fav-badge">${MODE_LABEL[f.mode] || escHtml(f.mode)}</span>
</div>
<div class="fav-card-body">
<div class="fav-card-title" title="${escHtml(f.title)}">${escHtml(f.title)}</div>
<div class="fav-card-meta">${f.width || '?'} × ${f.height || '?'}px · ${formatFavTime(f.createdAt)}</div>
<div class="fav-card-actions">
<button class="btn btn-primary btn-sm" onclick="editFavorite('${f.id}')" title="在新标签页再次制作">✏️ 编辑</button>
<button class="btn btn-secondary btn-sm" onclick="downloadFavorite('${f.id}')" title="下载原图">⬇️ 下载</button>
<button class="btn btn-remove btn-sm" onclick="deleteFavorite('${f.id}')" title="删除">🗑️</button>
</div>
</div>
</div>
`).join('');
}
function formatFavTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// 编辑:新标签页打开主页面并加载该收藏配置
function editFavorite(id) {
window.open('/?id=' + id, '_blank');
}
function downloadFavorite(id) {
const a = document.createElement('a');
a.href = '/api/favorites/' + id + '/image?download=1';
a.download = 'favorite_' + id + '.png';
document.body.appendChild(a);
a.click();
a.remove();
}
function deleteFavorite(id) {
if (!confirm('确定删除这条收藏吗?')) return;
fetch('/api/favorites/' + id, { method: 'DELETE' })
.then(res => res.json())
.then(data => {
if (data && data.ok) { refreshFavorites(); showToast('🗑️ 已删除'); }
else alert((data && data.error) || '删除失败');
})
.catch(err => alert(err.message));
}
// 加载收藏配置(新标签页再次制作)
function loadFavoriteForEdit(id) {
fetch('/api/favorites/' + id)
.then(res => res.json())
.then(data => {
if (!data || !data.ok || !data.favorite) throw new Error('收藏不存在或已删除');
applyFavoriteConfig(data.favorite);
showToast('✏️ 已载入收藏配置,可继续编辑制作');
})
.catch(err => showToast('❌ ' + err.message));
}
function applyFavoriteConfig(fav) {
const cfg = fav.config || {};
const mode = fav.mode || cfg.mode || 'chart';
switchMode(mode);
if (mode === 'chart') {
document.getElementById('dataInput').value = cfg.data || '';
document.getElementById('chartType').value = cfg.chartType || 'bar';
document.getElementById('chartTitle').value = cfg.title || '';
document.getElementById('themeStyle').value = cfg.theme || 'default';
document.getElementById('showLegend').checked = cfg.showLegend !== false;
document.getElementById('showGrid').checked = cfg.showGrid !== false;
document.getElementById('showLabel').checked = !!cfg.showLabel;
document.getElementById('stackMode').checked = !!cfg.stackMode;
document.getElementById('smoothLine').checked = cfg.smoothLine !== false;
document.getElementById('dualAxis').checked = !!cfg.dualAxis;
document.getElementById('leftAxisName').value = cfg.leftAxisName || '';
document.getElementById('rightAxisName').value = cfg.rightAxisName || '';
document.getElementById('enableSplit').checked = !!cfg.enableSplit;
document.getElementById('splitIndex').value = cfg.splitIndex || 3;
document.getElementById('leftLabel').value = cfg.leftLabel || '';
document.getElementById('rightLabel').value = cfg.rightLabel || '';
document.getElementById('splitStyle').value = cfg.splitStyle || 'solid';
generateChart();
// 覆盖系列顺序/颜色/轴/独立类型
if (Array.isArray(cfg.seriesOrder)) seriesOrder = [...cfg.seriesOrder];
if (Array.isArray(cfg.seriesColors)) seriesColors = [...cfg.seriesColors];
if (Array.isArray(cfg.seriesAxis)) seriesAxis = [...cfg.seriesAxis];
window.seriesTypeOverrides = cfg.seriesTypeOverrides || {};
renderSeriesConfig();
updateChart();
onDualAxisChange();
} else if (mode === 'table') {
document.getElementById('dataInput').value = cfg.data || '';
document.getElementById('tableTitle').value = cfg.title || '';
document.getElementById('tableTheme').value = cfg.theme || 'default';
document.getElementById('tableFontSize').value = cfg.fontSize || 14;
updateFontSize();
document.getElementById('stripeRows').checked = cfg.stripeRows !== false;
document.getElementById('borderStyle').value = cfg.borderStyle || 'all';
generateTable();
} else if (mode === 'combine') {
if (Array.isArray(cfg.charts) && cfg.charts.length) {
combineCharts = cfg.charts.map(c => ({ ...defaultCombineChart(), ...c }));
}
const dir = document.querySelector(`input[name="combineDirection"][value="${cfg.direction || 'horizontal'}"]`);
if (dir) dir.checked = true;
document.getElementById('gridCols').value = cfg.gridCols || 2;
renderCombineCharts();
generateCombine();
onCombineGridToggle();
}
}
// ===== Toast =====
let toastTimer = null;
function showToast(msg) {
let t = document.getElementById('appToast');
if (!t) {
t = document.createElement('div');
t.id = 'appToast';
document.body.appendChild(t);
}
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2200);
}
+18
View File
@@ -13,6 +13,9 @@
<header class="app-header">
<h1>📊 数据可视化图表生成器</h1>
<p class="subtitle">粘贴数据,生成精美图表或表格</p>
<div class="header-actions">
<button class="btn btn-fav-outline" onclick="openFavorites()">⭐ 收藏区</button>
</div>
</header>
<div class="main-content">
@@ -56,6 +59,8 @@ 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>
@@ -228,6 +233,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;">
@@ -418,5 +424,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>
+273 -3
View File
@@ -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');
@@ -130,6 +131,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];
@@ -928,12 +995,17 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.13.1',
version: '1.15.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 +1015,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.13.1',
version: '1.15.0',
endpoints: [
{
method: 'POST',
@@ -952,7 +1024,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: '是否显示图例' },
@@ -1076,6 +1148,40 @@ 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: '删除收藏'
}
]
});
@@ -1203,6 +1309,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 +1479,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`);
});
+205
View File
@@ -775,3 +775,208 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.74rem;
white-space: nowrap;
}
/* ===== 收藏功能 ===== */
.header-actions {
margin-top: 10px;
}
.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);
}