feat: 新增收藏功能 - 图/表/合并图一键收藏,按原始大小保存到收藏区,可查看原图/下载/删除,点击编辑在新标签页加载配置再次制作

This commit is contained in:
2026-08-19 17:55:18 +08:00
parent 7a03ecc063
commit 606610f8d3
5 changed files with 704 additions and 2 deletions
+206 -2
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');
@@ -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`);
});