Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94aa37e612 | ||
|
|
606610f8d3 |
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
logs/
|
||||
data/
|
||||
*.png
|
||||
*.svg
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 数据解析 =====
|
||||
@@ -1510,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);
|
||||
}
|
||||
+20
-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">
|
||||
@@ -230,6 +235,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;">
|
||||
@@ -420,5 +426,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');
|
||||
@@ -994,12 +995,17 @@ app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'data-chart-tool',
|
||||
version: '1.14.0',
|
||||
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': '健康检查'
|
||||
}
|
||||
});
|
||||
@@ -1009,7 +1015,7 @@ app.get('/api/health', (req, res) => {
|
||||
app.get('/api/docs', (req, res) => {
|
||||
res.json({
|
||||
name: '数据可视化图表生成器 API',
|
||||
version: '1.14.0',
|
||||
version: '1.15.0',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1142,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: '删除收藏'
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1269,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(`🚀 数据可视化图表生成器已启动`);
|
||||
@@ -1276,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`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -775,3 +785,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