+ +
+
+ + +
+
+

📝 数据输入

格式说明:

-

第一行为表头(系列名称),第一列为横坐标值

+

第一行为表头,后续行为数据

类别, 系列1, 系列2, 系列3
 A, 10, 20, 30
 B, 15, 25, 35
@@ -31,14 +39,14 @@ C, 20, 30, 40
- +
-
+

⚙️ 图表配置

@@ -78,8 +86,41 @@ C, 20, 30, 40
+ + + -
+

🎨 系列配置

生成图表后可在此调整各系列的顺序和颜色

@@ -87,7 +128,7 @@ C, 20, 30, 40
-
+

📐 区域分割

@@ -119,8 +160,8 @@ C, 20, 30, 40
- - + +
diff --git a/server.js b/server.js index 3180fc8..621370a 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,17 @@ const express = require('express'); const cors = require('cors'); -const { createCanvas } = require('@napi-rs/canvas'); +const { createCanvas, registerFont } = 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' }); +} catch (e) { + console.warn('中文字体注册失败,将使用系统默认字体:', e.message); +} + const app = express(); const PORT = process.env.PORT || 16016; @@ -391,15 +399,311 @@ app.get('/api/chart', (req, res) => { } }); +// ===== 表格数据解析(保留原始文本) ===== +function parseTableData(rawText) { + const lines = rawText.trim().split('\n').filter(l => l.trim()); + if (lines.length < 2) { + throw new Error('数据至少需要包含表头和一行数据'); + } + + // 自动检测分隔符 + let delimiter = ','; + if (lines[0].includes('\t')) { + delimiter = '\t'; + } else if (lines[0].split('|').length > lines[0].split(',').length) { + delimiter = '|'; + } + + const rows = lines.map(line => { + return line.split(delimiter).map(cell => cell.trim()); + }); + + return { + headers: rows[0], + rows: rows.slice(1) + }; +} + +// ===== 表格主题配置 ===== +const tableThemes = { + default: { + bgColor: '#ffffff', + headerBg: '#5470c6', + headerColor: '#ffffff', + cellColor: '#333333', + borderColor: '#e0e0e0', + stripeColor: '#f8f9fa', + titleColor: '#333333' + }, + dark: { + bgColor: '#1a1a2e', + headerBg: '#4fc3f7', + headerColor: '#1a1a2e', + cellColor: '#e0e0e0', + borderColor: '#444444', + stripeColor: '#252540', + titleColor: '#e0e0e0' + }, + macarons: { + bgColor: '#ffffff', + headerBg: '#2ec7c9', + headerColor: '#ffffff', + cellColor: '#333333', + borderColor: '#e8e8e8', + stripeColor: '#f0fafb', + titleColor: '#333333' + }, + gradient: { + bgColor: '#ffffff', + headerBg: '#7f7fd5', + headerColor: '#ffffff', + cellColor: '#333333', + borderColor: '#e8e8e8', + stripeColor: '#f5f5ff', + titleColor: '#333333' + }, + retro: { + bgColor: '#fefefe', + headerBg: '#95b9c7', + headerColor: '#ffffff', + cellColor: '#444444', + borderColor: '#d4a5a5', + stripeColor: '#f6e8c3', + titleColor: '#444444' + } +}; + +// ===== 测量文字宽度 ===== +function measureTextWidth(ctx, text, fontSize) { + ctx.font = `${fontSize}px "Noto Sans CJK SC", sans-serif`; + const metrics = ctx.measureText(text); + return metrics.width; +} + +// ===== 生成表格图片 ===== +function generateTableImage(params) { + const { + data, + title = '', + theme = 'default', + fontSize = 14, + cellPadding = 12, + borderWidth = 1, + stripeRows = true, + pixelRatio = 2, + maxWidth = 1200 + } = params; + + const tableData = parseTableData(data); + const { headers, rows } = tableData; + const themeConfig = tableThemes[theme] || tableThemes.default; + + // 创建临时 canvas 用于测量文字 + const tempCanvas = createCanvas(100, 100); + const tempCtx = tempCanvas.getContext('2d'); + + // 计算每列最大宽度 + const colWidths = headers.map((header, colIdx) => { + let maxW = measureTextWidth(tempCtx, header, fontSize) + cellPadding * 2; + + rows.forEach(row => { + if (row[colIdx]) { + const cellW = measureTextWidth(tempCtx, row[colIdx], fontSize) + cellPadding * 2; + if (cellW > maxW) maxW = cellW; + } + }); + + return Math.min(maxW, 300); // 单列最大宽度 300px + }); + + // 计算表格总宽度 + const tableWidth = colWidths.reduce((sum, w) => sum + w, 0) + borderWidth * 2; + const finalWidth = Math.min(tableWidth, maxWidth); + + // 如果超出最大宽度,按比例缩放列宽 + if (tableWidth > maxWidth) { + const scale = maxWidth / tableWidth; + colWidths.forEach((_, i) => colWidths[i] *= scale); + } + + // 计算行高 + 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 finalHeight = tableHeight + titleHeight; + + // 创建正式 canvas + const canvas = createCanvas(finalWidth * pixelRatio, finalHeight * pixelRatio); + const ctx = canvas.getContext('2d'); + ctx.scale(pixelRatio, pixelRatio); + + // 绘制背景 + ctx.fillStyle = themeConfig.bgColor; + ctx.fillRect(0, 0, finalWidth, finalHeight); + + let startY = 0; + + // 绘制标题 + if (title) { + ctx.fillStyle = themeConfig.titleColor; + ctx.font = `bold ${fontSize * 1.4}px "Noto Sans CJK SC", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(title, finalWidth / 2, titleHeight / 2); + startY = titleHeight; + } + + // 绘制表头背景 + ctx.fillStyle = themeConfig.headerBg; + ctx.fillRect(0, startY, finalWidth, headerHeight); + + // 绘制表头文字 + ctx.fillStyle = themeConfig.headerColor; + ctx.font = `bold ${fontSize}px "Noto Sans CJK SC", sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + let x = 0; + headers.forEach((header, i) => { + ctx.fillText(header, x + cellPadding, startY + headerHeight / 2, colWidths[i] - cellPadding * 2); + x += colWidths[i]; + }); + + // 绘制数据行 + rows.forEach((row, rowIdx) => { + const y = startY + headerHeight + rowIdx * rowHeight; + + // 斑马纹背景 + if (stripeRows && rowIdx % 2 === 1) { + ctx.fillStyle = themeConfig.stripeColor; + ctx.fillRect(0, y, finalWidth, rowHeight); + } + + // 绘制单元格文字 + ctx.fillStyle = themeConfig.cellColor; + ctx.font = `${fontSize}px "Noto Sans CJK SC", sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + let cellX = 0; + row.forEach((cell, colIdx) => { + if (cell && colWidths[colIdx]) { + ctx.fillText(cell, cellX + cellPadding, y + rowHeight / 2, colWidths[colIdx] - cellPadding * 2); + } + cellX += colWidths[colIdx]; + }); + }); + + // 绘制边框 + ctx.strokeStyle = themeConfig.borderColor; + ctx.lineWidth = borderWidth; + + // 外边框 + ctx.strokeRect(0, startY, finalWidth, tableHeight); + + // 横线 + ctx.beginPath(); + ctx.moveTo(0, startY + headerHeight); + ctx.lineTo(finalWidth, startY + headerHeight); + ctx.stroke(); + + 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; + }); + + return canvas; +} + +// ===== API: 生成表格图片 (POST) ===== +app.post('/api/table', (req, res) => { + try { + const params = req.body; + + if (!params.data) { + return res.status(400).json({ error: '缺少 data 参数(CSV 格式数据)' }); + } + + const pixelRatio = parseInt(params.pixelRatio) || 2; + const canvas = generateTableImage(params); + + const buffer = canvas.toBuffer('image/png'); + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length, + 'X-Table-Format': 'png' + }); + res.send(buffer); + } catch (err) { + console.error('Table generation error:', err); + res.status(500).json({ error: '表格生成失败: ' + err.message }); + } +}); + +// ===== API: 生成表格图片 (GET) ===== +app.get('/api/table', (req, res) => { + try { + const params = { + data: req.query.data, + title: req.query.title || '', + theme: req.query.theme || 'default', + fontSize: parseInt(req.query.fontSize) || 14, + cellPadding: parseInt(req.query.cellPadding) || 12, + borderWidth: parseInt(req.query.borderWidth) || 1, + stripeRows: req.query.stripeRows !== 'false', + pixelRatio: parseInt(req.query.pixelRatio) || 2, + maxWidth: parseInt(req.query.maxWidth) || 1200 + }; + + if (!params.data) { + return res.status(400).json({ error: '缺少 data 参数' }); + } + + // 解码 data(支持 URL 编码的换行符) + params.data = params.data.replace(/\\n/g, '\n'); + + const canvas = generateTableImage(params); + const buffer = canvas.toBuffer('image/png'); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': buffer.length, + 'X-Table-Format': 'png' + }); + res.send(buffer); + } catch (err) { + console.error('Table generation error:', err); + res.status(500).json({ error: '表格生成失败: ' + err.message }); + } +}); + // ===== API: 健康检查 ===== app.get('/api/health', (req, res) => { res.json({ status: 'ok', service: 'data-chart-tool', - version: '1.1.0', + version: '1.2.0', endpoints: { 'POST /api/chart': '生成图表图片(JSON body)', 'GET /api/chart': '生成图表图片(URL 参数)', + 'POST /api/table': '生成表格图片(JSON body)', + 'GET /api/table': '生成表格图片(URL 参数)', 'GET /api/health': '健康检查' } }); @@ -409,7 +713,7 @@ app.get('/api/health', (req, res) => { app.get('/api/docs', (req, res) => { res.json({ name: '数据可视化图表生成器 API', - version: '1.1.0', + version: '1.2.0', endpoints: [ { method: 'POST', @@ -467,6 +771,50 @@ app.get('/api/docs', (req, res) => { example: { request: `curl "http://localhost:16016/api/chart?data=产品,Q1,Q2\\n手机,100,200\\n平板,150,250&type=bar&title=测试" -o chart.png` } + }, + { + method: 'POST', + path: '/api/table', + description: '通过 JSON 请求体生成表格图片', + 'Content-Type': 'application/json', + params: { + data: { type: 'string', required: true, description: 'CSV 格式数据(第一行表头)' }, + title: { type: 'string', default: '', description: '表格标题' }, + theme: { type: 'string', default: 'default', options: ['default', 'dark', 'macarons', 'gradient', 'retro'], description: '主题风格' }, + fontSize: { type: 'number', default: 14, description: '字体大小' }, + cellPadding: { type: 'number', default: 12, description: '单元格内边距' }, + 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)' } + }, + returns: 'image/png', + example: { + request: `curl -X POST http://localhost:16016/api/table \\ + -H "Content-Type: application/json" \\ + -d '{ + "data": "姓名, 部门, 职位, 薪资\\n张三, 技术部, 工程师, 15000\\n李四, 产品部, 产品经理, 18000\\n王五, 设计部, UI设计师, 16000", + "title": "员工信息表", + "theme": "default" + }' -o table.png`, + response: 'PNG 图片二进制流' + } + }, + { + method: 'GET', + path: '/api/table', + description: '通过 URL 参数生成表格图片', + params: { + data: { type: 'string', required: true, description: 'CSV 数据(换行用 \\n 分隔)' }, + title: { type: 'string', description: '表格标题' }, + theme: { type: 'string', default: 'default', description: '主题风格' }, + fontSize: { type: 'number', default: 14 }, + stripeRows: { type: 'boolean', default: true } + }, + returns: 'image/png', + example: { + request: `curl "http://localhost:16016/api/table?data=产品,价格,库存\\n手机,2999,100\\n平板,1999,50&title=产品列表" -o table.png` + } } ] }); @@ -476,7 +824,8 @@ app.get('/api/docs', (req, res) => { 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/chart`); + console.log(`📋 表格API: http://0.0.0.0:${PORT}/api/table`); console.log(`📖 文档: http://0.0.0.0:${PORT}/api/docs`); console.log(`❤️ 健康: http://0.0.0.0:${PORT}/api/health`); }); diff --git a/style.css b/style.css index 91bf2c1..49ff1e0 100644 --- a/style.css +++ b/style.css @@ -378,3 +378,82 @@ body { .panel-section { animation: fadeIn 0.3s ease-out; } + +/* ===== 模式切换 ===== */ +.mode-switch { + display: flex; + gap: 8px; +} + +.mode-btn { + flex: 1; + padding: 10px 16px; + border: 2px solid var(--border); + border-radius: 8px; + background: white; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + color: var(--text-light); +} + +.mode-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +.mode-btn.active { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +/* ===== 表格预览 ===== */ +.table-preview { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; +} + +.table-preview img { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + box-shadow: var(--shadow); +} + +/* ===== 滑块样式 ===== */ +input[type="range"] { + width: 100%; + height: 6px; + border-radius: 3px; + background: #e2e8f0; + outline: none; + -webkit-appearance: none; + appearance: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--primary); + cursor: pointer; + border: none; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +}