feat: 导出设置增强 —— 分辨率预设/自定义/历史记忆/日期命名

- 导出设置面板:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义)
- 自定义宽高手动输入,页面实时居中预览导出效果(原图 contain 居中,自动适配深色背景)
- 分辨率历史记忆:localStorage 存最近5个,下拉一键复用
- 文件名:默认按日期时间自动命名(每次不同),支持手动命名(自动补扩展名)
- 三个模式(图表/表格/双图合并)统一走带分辨率的导出
- 修复:DOMContentLoaded 提前调用 generateCombine 覆盖 chartArea,导致图表模式 echarts canvas 被破坏
This commit is contained in:
2026-08-19 12:40:22 +08:00
parent 52a245e044
commit 9a8cbb125c
5 changed files with 335 additions and 62 deletions
+5 -1
View File
@@ -44,7 +44,11 @@
- 返回 PNG 图片,支持自定义分辨率和像素倍率
### 📥 导出功能
- Web UI 导出 PNG2倍分辨率
- 导出设置:分辨率预设(原始/1920×1080/1280×720/1024×768/800×600/自定义
- 自定义分辨率手动输入,页面实时居中预览保存后的图
- 分辨率历史记忆(最近5个,localStorage 持久化,一键复用)
- 文件名默认按日期时间自动命名(每次不同),支持手动命名
- Web UI 导出 PNG / 图表模式支持 SVG
- API 直接返回 PNG 图片流
## 🚀 快速开始
+219 -55
View File
@@ -60,13 +60,16 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('dataInput').value = sampleDataSets[0].data;
generateChart();
// 预填双图合并示例
// 预填双图合并示例(不提前生成,切到双图模式时由 switchMode 触发)
document.getElementById('combineData1').value = sampleDataSets[0].data;
document.getElementById('combineData2').value = sampleDataSets[1].data;
document.getElementById('combineTitle1').value = '季度销售对比';
document.getElementById('combineType2').value = 'line';
document.getElementById('combineTitle2').value = '年度增长趋势';
generateCombine();
// 初始化导出设置
renderHistory();
refreshExportHint();
});
// ===== 数据解析 =====
@@ -135,6 +138,8 @@ function generateChart() {
// 初始化图表
initChart();
updateChart();
refreshPreview();
}
// ===== 初始化图表实例 =====
@@ -396,6 +401,8 @@ function updateChart() {
// 绘制分割线(在 category 间隙中)
drawSplitLine();
refreshPreview();
}
// ===== 在柱子间隙中精确绘制分割线 =====
@@ -593,37 +600,206 @@ function clearData() {
document.getElementById('seriesConfig').innerHTML = '<p class="hint-text">生成图表后可在此调整各系列的顺序和颜色</p>';
}
// ===== 导出图表 =====
function exportChart(format) {
// ===== 导出图表SVG 专用) =====
function exportChartSvg() {
if (!chartInstance) {
alert('请先生成图表');
return;
}
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
svgChart.setOption(chartInstance.getOption());
const url = svgChart.getDataURL({ type: 'svg', pixelRatio: 2 });
const a = document.createElement('a');
a.href = url;
a.download = buildExportFilename('chart', 'svg');
a.click();
svgChart.dispose();
}
if (format === 'png') {
const url = chartInstance.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: document.getElementById('themeStyle').value === 'dark' ? '#1a1a2e' : '#fff'
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.png';
a.click();
} else if (format === 'svg') {
// 重新用 SVG 渲染
const svgChart = echarts.init(document.createElement('div'), null, { renderer: 'svg' });
svgChart.setOption(chartInstance.getOption());
const url = svgChart.getDataURL({
type: 'svg',
pixelRatio: 2
});
const a = document.createElement('a');
a.href = url;
a.download = 'chart.svg';
a.click();
svgChart.dispose();
// ===== 导出设置(分辨率/历史/命名) =====
const EXPORT_PRESETS = {
'original': { label: '原始大小', w: 0, h: 0 },
'1920x1080': { label: '1920 × 1080', w: 1920, h: 1080 },
'1280x720': { label: '1280 × 720', w: 1280, h: 720 },
'1024x768': { label: '1024 × 768', w: 1024, h: 768 },
'800x600': { label: '800 × 600', w: 800, h: 600 },
'custom': { label: '自定义', w: null, h: null }
};
const HISTORY_KEY = 'dct_export_history';
const HISTORY_MAX = 5;
// 当前生效的分辨率 {w, h}(0 表示原始大小)
function getResolution() {
const preset = document.getElementById('exportPreset').value;
if (preset === 'original') return { w: 0, h: 0 };
if (preset === 'custom') {
const w = parseInt(document.getElementById('customWidth').value) || 0;
const h = parseInt(document.getElementById('customHeight').value) || 0;
return { w, h };
}
const p = EXPORT_PRESETS[preset];
return { w: p.w, h: p.h };
}
function onExportPresetChange() {
const preset = document.getElementById('exportPreset').value;
document.getElementById('customResGroup').style.display = preset === 'custom' ? 'block' : 'none';
refreshExportHint();
refreshPreview();
}
function onCustomResInput() {
refreshExportHint();
refreshPreview();
const { w, h } = getResolution();
if (w > 0 && h > 0) saveHistory(w, h);
}
// 历史分辨率管理(最多 5 个,存 localStorage
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; }
}
function saveHistory(w, h) {
if (!w || !h) return;
let hist = loadHistory();
const key = w + 'x' + h;
hist = hist.filter(x => x.key !== key);
hist.unshift({ key, w, h });
hist = hist.slice(0, HISTORY_MAX);
localStorage.setItem(HISTORY_KEY, JSON.stringify(hist));
renderHistory();
}
function renderHistory() {
const sel = document.getElementById('exportHistory');
const hist = loadHistory();
const cur = sel.value;
sel.innerHTML = hist.length
? hist.map(h => `<option value="${h.key}">${h.w} × ${h.h}</option>`).join('')
: '<option value="">— 暂无历史 —</option>';
if (cur && hist.some(h => h.key === cur)) sel.value = cur;
}
function onHistorySelect() {
const v = document.getElementById('exportHistory').value;
if (!v) return;
const [w, h] = v.split('x').map(Number);
const preset = Object.entries(EXPORT_PRESETS).find(([k, p]) => p.w === w && p.h === h);
document.getElementById('exportPreset').value = preset ? preset[0] : 'custom';
if (preset) {
document.getElementById('customResGroup').style.display = 'none';
} else {
document.getElementById('customResGroup').style.display = 'block';
document.getElementById('customWidth').value = w;
document.getElementById('customHeight').value = h;
}
refreshExportHint();
refreshPreview();
}
// 文件名:手动填写优先,否则按日期时间自动命名(每次不同)
function buildExportFilename(prefix, ext) {
ext = ext || 'png';
const manual = document.getElementById('exportFilename').value.trim();
if (manual) {
return /\.[a-zA-Z0-9]+$/.test(manual) ? manual : manual + '.' + ext;
}
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${prefix}_${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}.${ext}`;
}
// 把源图 contain 居中缩放到目标分辨率画布(自动适配深色背景)
function applyResolution(srcCanvas, tw, th) {
if (!tw || !th) return srcCanvas;
let bg = '#ffffff';
try {
const d = srcCanvas.getContext('2d').getImageData(0, 0, 1, 1).data;
if (d[3] > 0 && (d[0] + d[1] + d[2]) / 3 < 100) bg = '#1a1a2e';
} catch (e) {}
const c = document.createElement('canvas');
c.width = tw; c.height = th;
const ctx = c.getContext('2d');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, tw, th);
const scale = Math.min(tw / srcCanvas.width, th / srcCanvas.height);
const dw = Math.round(srcCanvas.width * scale);
const dh = Math.round(srcCanvas.height * scale);
ctx.drawImage(srcCanvas, Math.round((tw - dw) / 2), Math.round((th - dh) / 2), dw, dh);
return c;
}
// 获取当前模式源 canvas(异步)
function getSourceCanvas() {
return new Promise((resolve, reject) => {
if (currentMode === 'chart') {
if (!chartInstance) return reject(new Error('请先生成图表'));
const cvs = chartInstance.getDom().querySelector('canvas');
if (!cvs) return reject(new Error('图表未渲染'));
resolve(cvs);
} else if (currentMode === 'table') {
if (!tableImageBlob) return reject(new Error('请先生成表格'));
const url = URL.createObjectURL(tableImageBlob);
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = img.width; c.height = img.height;
c.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(c);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('表格图片加载失败')); };
img.src = url;
} else {
if (!combinedCanvas) return reject(new Error('请先生成合并图'));
resolve(combinedCanvas);
}
});
}
// 生成最终导出 canvas(应用当前分辨率)
function getExportCanvas() {
return getSourceCanvas().then(src => {
const { w, h } = getResolution();
return applyResolution(src, w, h);
});
}
// 统一下载图片
function exportCurrent(prefix) {
getExportCanvas().then(canvas => {
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = buildExportFilename(prefix);
a.click();
const { w, h } = getResolution();
if (w > 0 && h > 0) saveHistory(w, h);
}).catch(err => alert(err.message));
}
// 导出分辨率提示
function refreshExportHint() {
const { w, h } = getResolution();
const hint = document.getElementById('exportResHint');
if (!hint) return;
hint.textContent = w > 0 && h > 0 ? `导出分辨率:${w} × ${h}(原图居中缩放)` : '导出分辨率:原始大小';
}
// 刷新导出预览:以原图为中心,居中展示保存后的图
function refreshPreview() {
const box = document.getElementById('exportPreviewBox');
const inner = document.getElementById('exportPreviewInner');
const title = document.getElementById('exportPreviewTitle');
if (!box || !inner) return;
getExportCanvas().then(canvas => {
const { w, h } = getResolution();
const url = canvas.toDataURL('image/png');
const sizeTag = w > 0 && h > 0 ? ` <span class="export-preview-size">${w} × ${h}</span>` : '';
title.innerHTML = `📐 导出预览${sizeTag}`;
inner.innerHTML = `<img src="${url}" alt="导出预览">`;
box.style.display = 'block';
}).catch(() => { box.style.display = 'none'; });
}
// ===== 模式切换 =====
@@ -711,6 +887,7 @@ function generateTable() {
tableImageBlob = blob;
const url = URL.createObjectURL(blob);
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="表格预览"></div>`;
refreshPreview();
})
.catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
@@ -726,28 +903,21 @@ function updateTable() {
// ===== 统一导出图片 =====
function exportImage(format) {
if (currentMode === 'chart') {
exportChart(format);
} else if (currentMode === 'table') {
exportTable(format);
} else {
exportCombine();
}
}
// ===== 导出表格 =====
function exportTable(format) {
if (!tableImageBlob) {
alert('请先生成表格');
if (format === 'svg') {
if (currentMode !== 'chart') {
alert('SVG 仅图表模式支持');
return;
}
exportChartSvg();
return;
}
const prefix = currentMode === 'chart' ? 'chart' : currentMode === 'table' ? 'table' : 'combine';
exportCurrent(prefix);
}
const url = URL.createObjectURL(tableImageBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'table.png';
a.click();
URL.revokeObjectURL(url);
// ===== 导出表格(走统一导出) =====
function exportTable(format) {
exportCurrent('table');
}
// ===== 快速切换风格 =====
@@ -996,19 +1166,13 @@ function generateCombine() {
combinedCanvas = canvas;
const url = canvas.toDataURL('image/png');
chartArea.innerHTML = `<div class="table-preview"><img src="${url}" alt="合并图预览" style="max-width:100%;"></div>`;
refreshPreview();
}).catch(err => {
chartArea.innerHTML = `<div class="placeholder"><p>❌</p><p>${err.message}</p></div>`;
});
}
// 导出合并图
// 导出合并图(走统一导出)
function exportCombine() {
if (!combinedCanvas) {
alert('请先生成合并图');
return;
}
const a = document.createElement('a');
a.href = combinedCanvas.toDataURL('image/png');
a.download = 'combine.png';
a.click();
exportCurrent('combine');
}
+45 -4
View File
@@ -208,10 +208,46 @@ C, 20, 30, 40</pre>
</div>
</div>
<!-- 导出按钮 -->
<!-- 导出设置 + 按钮 -->
<div class="panel-section" id="exportSection">
<h2>📤 导出设置</h2>
<div class="config-group">
<label>分辨率</label>
<select id="exportPreset" onchange="onExportPresetChange()">
<option value="original">原始大小</option>
<option value="1920x1080">1920 × 1080</option>
<option value="1280x720">1280 × 720</option>
<option value="1024x768">1024 × 768</option>
<option value="800x600">800 × 600</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="config-group" id="customResGroup" style="display:none;">
<label>自定义分辨率(宽 × 高)</label>
<div class="res-input-row">
<input type="number" id="customWidth" min="1" max="8192" placeholder="宽" oninput="onCustomResInput()">
<span class="res-times">×</span>
<input type="number" id="customHeight" min="1" max="8192" placeholder="高" oninput="onCustomResInput()">
</div>
</div>
<div class="config-group">
<label>历史分辨率(最近5个)</label>
<select id="exportHistory" onchange="onHistorySelect()">
<option value="">— 暂无历史 —</option>
</select>
</div>
<div class="config-group">
<label>文件名</label>
<input type="text" id="exportFilename" placeholder="留空则按日期时间自动命名">
<p class="hint-text" id="exportResHint"></p>
</div>
<div class="btn-group">
<button class="btn btn-success" onclick="exportImage('png')">📥 导出PNG</button>
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
<button class="btn btn-success" id="btnExportSvg" onclick="exportImage('svg')">📥 导出SVG</button>
</div>
</div>
@@ -342,12 +378,12 @@ C, 20, 30, 40</pre>
<div class="btn-group">
<button class="btn btn-primary" onclick="generateCombine()">🖼️ 生成合并图</button>
<button class="btn btn-success" onclick="exportCombine()">📥 导出PNG</button>
<button class="btn btn-success" onclick="exportImage('png')">📥 下载图片</button>
</div>
</div>
</div>
<!-- 右侧面板:图表预览 + 风格切换 -->
<!-- 右侧面板:图表预览 + 导出预览 + 风格切换 -->
<div class="right-panel">
<div class="chart-container">
<div id="chartArea" class="chart-area">
@@ -357,6 +393,11 @@ C, 20, 30, 40</pre>
</div>
</div>
</div>
<!-- 导出效果预览(按分辨率居中展示保存后的图) -->
<div class="export-preview-box" id="exportPreviewBox" style="display:none;">
<div class="export-preview-title" id="exportPreviewTitle">📐 导出预览</div>
<div class="export-preview-inner" id="exportPreviewInner"></div>
</div>
<!-- 风格快速切换按钮(放在图表右侧) -->
<div class="theme-buttons-section">
<h3>🎨 风格</h3>
+2 -2
View File
@@ -882,7 +882,7 @@ app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'data-chart-tool',
version: '1.9.0',
version: '1.10.0',
endpoints: {
'POST /api/chart': '生成图表图片(JSON body',
'GET /api/chart': '生成图表图片(URL 参数)',
@@ -897,7 +897,7 @@ app.get('/api/health', (req, res) => {
app.get('/api/docs', (req, res) => {
res.json({
name: '数据可视化图表生成器 API',
version: '1.9.0',
version: '1.10.0',
endpoints: [
{
method: 'POST',
+64
View File
@@ -607,3 +607,67 @@ input[type="range"]::-moz-range-thumb {
background: #cbd5e1;
border-radius: 2px;
}
/* ===== 导出设置 ===== */
.res-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.res-input-row input[type="number"] {
flex: 1;
min-width: 0;
}
.res-times {
color: var(--text-light);
font-weight: 600;
}
/* ===== 导出预览(按分辨率居中展示保存后的图) ===== */
.export-preview-box {
margin-top: 14px;
border: 1px dashed var(--border);
border-radius: 10px;
background: #ffffff;
padding: 10px;
}
.export-preview-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-light);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.export-preview-inner {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 0 0 / 20px 20px;
border-radius: 8px;
padding: 12px;
overflow: auto;
}
.export-preview-inner img {
max-width: 100%;
max-height: 300px;
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
border-radius: 4px;
background: #fff;
}
.export-preview-size {
font-size: 0.78rem;
color: var(--primary);
background: #eef2ff;
border-radius: 4px;
padding: 1px 8px;
font-weight: 600;
}