feat: 新增收藏功能 - 图/表/合并图一键收藏,按原始大小保存到收藏区,可查看原图/下载/删除,点击编辑在新标签页加载配置再次制作
This commit is contained in:
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user