feat: client-side chart export (PNG/JPG/SVG/PDF/Excel/PPT/HTML)

Replaced backend export polling with direct browser-side export:
- PNG/JPG: ECharts getDataURL() with configurable pixelRatio
- SVG: ECharts SVG renderer output
- PDF: jsPDF with chart images (dynamic import)
- Excel: SheetJS json_to_sheet (dynamic import)
- PPT: PptxGenJS with chart slides (dynamic import)
- HTML: Standalone page with ECharts CDN + inline options
- Template: JSON config export

Added global ECharts instance registry for export access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 04:57:03 -07:00
parent b3098eefbd
commit dc6ac54c86
5 changed files with 319 additions and 29 deletions

View File

@ -10,9 +10,11 @@ import { DataTable } from './DataTable';
export interface ChartRendererProps { export interface ChartRendererProps {
chart: ChartInstance; chart: ChartInstance;
data: Record<string, any>[]; data: Record<string, any>[];
/** Forwarded to EChartsBase for global instance registry. */
chartId?: string;
} }
export const ChartRenderer: React.FC<ChartRendererProps> = ({ chart, data }) => { export const ChartRenderer: React.FC<ChartRendererProps> = ({ chart, data, chartId }) => {
// Use JSON key to force recalculation when any chart property changes // Use JSON key to force recalculation when any chart property changes
const chartKey = JSON.stringify({ type: chart.type, bindings: chart.bindings, style: chart.style }); const chartKey = JSON.stringify({ type: chart.type, bindings: chart.bindings, style: chart.style });
@ -37,7 +39,7 @@ export const ChartRenderer: React.FC<ChartRendererProps> = ({ chart, data }) =>
const borderRadius = chart.style?.background?.borderRadius ?? 0; const borderRadius = chart.style?.background?.borderRadius ?? 0;
const overflow = borderRadius > 0 ? 'hidden' as const : undefined; const overflow = borderRadius > 0 ? 'hidden' as const : undefined;
return echartsOption return echartsOption
? <EChartsBase option={echartsOption} style={{ width: '100%', height: '100%', minHeight: 250, borderRadius, overflow }} /> ? <EChartsBase chartId={chartId} option={echartsOption} style={{ width: '100%', height: '100%', minHeight: 250, borderRadius, overflow }} />
: <div style={{ padding: 16, color: '#999', textAlign: 'center' }}></div>; : <div style={{ padding: 16, color: '#999', textAlign: 'center' }}></div>;
} }
}; };

View File

@ -150,7 +150,7 @@ export const ChartWrapper: React.FC<ChartWrapperProps> = ({ chartId }) => {
{/* Chart content */} {/* Chart content */}
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<ChartRenderer chart={chart} data={rows} /> <ChartRenderer chart={chart} data={rows} chartId={chartId} />
</div> </div>
{/* Hover styles via inline <style> to show toolbar */} {/* Hover styles via inline <style> to show toolbar */}

View File

@ -4,12 +4,29 @@ import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { useThemeStore } from '@/adapters/state/zustand/themeStore'; import { useThemeStore } from '@/adapters/state/zustand/themeStore';
// ---------------------------------------------------------------------------
// Global ECharts instance registry keyed by chartId
// ---------------------------------------------------------------------------
const instanceMap = new Map<string, echarts.ECharts>();
/** Retrieve a live ECharts instance by its chartId. */
export function getEChartsInstance(chartId: string): echarts.ECharts | null {
return instanceMap.get(chartId) ?? null;
}
/** Return all currently registered chart IDs. */
export function getRegisteredChartIds(): string[] {
return Array.from(instanceMap.keys());
}
export interface EChartsBaseProps { export interface EChartsBaseProps {
option: Record<string, any>; option: Record<string, any>;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
onEvents?: Record<string, (params: any) => void>; onEvents?: Record<string, (params: any) => void>;
loading?: boolean; loading?: boolean;
/** Optional id used to register the instance in the global map for export. */
chartId?: string;
} }
export const EChartsBase: React.FC<EChartsBaseProps> = ({ export const EChartsBase: React.FC<EChartsBaseProps> = ({
@ -18,6 +35,7 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
className, className,
onEvents, onEvents,
loading = false, loading = false,
chartId,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(null); const chartRef = useRef<echarts.ECharts | null>(null);
@ -29,6 +47,8 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
// Dispose previous instance if theme changed // Dispose previous instance if theme changed
if (chartRef.current) { if (chartRef.current) {
// Unregister from global map before disposing
if (chartId) instanceMap.delete(chartId);
chartRef.current.dispose(); chartRef.current.dispose();
} }
@ -38,6 +58,9 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
); );
chartRef.current = instance; chartRef.current = instance;
// Register in global map
if (chartId) instanceMap.set(chartId, instance);
// ResizeObserver for responsive sizing // ResizeObserver for responsive sizing
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
instance.resize(); instance.resize();
@ -46,10 +69,11 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
return () => { return () => {
observer.disconnect(); observer.disconnect();
if (chartId) instanceMap.delete(chartId);
instance.dispose(); instance.dispose();
chartRef.current = null; chartRef.current = null;
}; };
}, [currentTheme]); }, [currentTheme, chartId]);
// Update option // Update option
useEffect(() => { useEffect(() => {

View File

@ -94,29 +94,53 @@ export const ExportDialog: React.FC = () => {
setSelectedFormat(null); setSelectedFormat(null);
}, [setVisible]); }, [setVisible]);
// Resolve the active dataset rows / columns for Excel export
const activeDataSet = useAppSelector((s) =>
s.data.dataSets.find((ds) => ds.id === activeDataSetId),
);
const layouts = useAppSelector((s) => s.layout.layouts);
const handleDoExport = useCallback(async () => { const handleDoExport = useCallback(async () => {
if (!selectedFormat) { if (!selectedFormat) {
message.warning('请先选择导出格式'); message.warning('请先选择导出格式');
return; return;
} }
if (chartIds.length === 0) { if (chartIds.length === 0 && selectedFormat !== 'excel') {
message.warning('当前看板没有图表可供导出'); message.warning('当前看板没有图表可供导出');
return; return;
} }
const ext = selectedFormat === 'template' ? 'json' : selectedFormat === 'excel' ? 'xlsx' : selectedFormat;
try { try {
await handleExport({ await handleExport({
format: selectedFormat, format: selectedFormat,
chartIds, chartIds,
datasetId: activeDataSetId ?? '', datasetId: activeDataSetId ?? '',
fileName: `dataviz-export.${selectedFormat === 'template' ? 'json' : selectedFormat}`, fileName: `dataviz-export.${ext}`,
options: {
quality,
pixelRatio: resolution,
pageSize,
orientation,
sheetName,
htmlTitle,
rows: activeDataSet?.rows,
columns: activeDataSet?.columns.map((c) => c.name),
charts: charts as Record<string, any>[],
layouts: layouts as Record<string, any>[],
},
}); });
message.success('导出成功'); message.success('导出成功');
handleClose(); handleClose();
} catch { } catch {
message.error(error ?? '导出失败,请重试'); message.error(error ?? '导出失败,请重试');
} }
}, [selectedFormat, chartIds, activeDataSetId, handleExport, handleClose, error]); }, [
selectedFormat, chartIds, activeDataSetId, handleExport, handleClose, error,
quality, resolution, pageSize, orientation, sheetName, htmlTitle,
activeDataSet, charts, layouts,
]);
const renderFormatOptions = () => { const renderFormatOptions = () => {
if (!selectedFormat) return null; if (!selectedFormat) return null;

View File

@ -1,45 +1,285 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { exportServiceClient } from '@/frameworks/di/container'; import { getEChartsInstance } from '@/frameworks/components/charts/EChartsBase';
import { type ExportFormat } from '@/domain/valueObjects/ExportFormat';
export interface ExportRequest { export interface ExportRequest {
format: string; format: ExportFormat;
chartIds: string[]; chartIds: string[];
datasetId: string; datasetId: string;
fileName?: string; fileName?: string;
/** Extra options forwarded from the dialog UI. */
options?: ExportOptions;
} }
export interface ExportOptions {
/** 10100, used for JPG quality. */
quality?: number;
/** Pixel ratio for raster exports. */
pixelRatio?: number;
/** PDF page size. */
pageSize?: string;
/** PDF orientation. */
orientation?: 'portrait' | 'landscape';
/** Excel sheet name. */
sheetName?: string;
/** HTML page title. */
htmlTitle?: string;
/** Rows for excel export (passed from Redux). */
rows?: Record<string, any>[];
/** Column names for excel export. */
columns?: string[];
/** Full chart configs for template JSON export. */
charts?: Record<string, any>[];
/** Layout items for template JSON export. */
layouts?: Record<string, any>[];
}
// ---------------------------------------------------------------------------
// Blob builders per format
// ---------------------------------------------------------------------------
async function exportPNG(chartId: string, pixelRatio = 2): Promise<Blob> {
const instance = getEChartsInstance(chartId);
if (!instance) throw new Error(`Chart instance "${chartId}" not found`);
const dataURL = instance.getDataURL({ type: 'png', pixelRatio, backgroundColor: '#fff' });
const res = await fetch(dataURL);
return res.blob();
}
async function exportJPG(chartId: string, pixelRatio = 2, quality = 0.9): Promise<Blob> {
const instance = getEChartsInstance(chartId);
if (!instance) throw new Error(`Chart instance "${chartId}" not found`);
// ECharts getDataURL doesn't support quality for jpeg, so we render to canvas manually.
const dataURL = instance.getDataURL({ type: 'png', pixelRatio, backgroundColor: '#fff' });
// Convert PNG dataURL -> canvas -> JPG blob with quality
const img = new Image();
const loaded = new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
});
img.src = dataURL;
await loaded;
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
'image/jpeg',
quality,
);
});
}
async function exportSVG(chartId: string): Promise<Blob> {
const instance = getEChartsInstance(chartId);
if (!instance) throw new Error(`Chart instance "${chartId}" not found`);
// getDataURL with svg type returns a data:image/svg+xml;... string
const dataURL = instance.getDataURL({ type: 'svg' });
if (dataURL.startsWith('data:image/svg+xml;charset=utf-8,')) {
const svgContent = decodeURIComponent(dataURL.split(',')[1] || '');
return new Blob([svgContent], { type: 'image/svg+xml' });
}
// base64 variant
if (dataURL.startsWith('data:image/svg+xml;base64,')) {
const b64 = dataURL.split(',')[1] || '';
const svgContent = atob(b64);
return new Blob([svgContent], { type: 'image/svg+xml' });
}
// Fallback return as-is
const res = await fetch(dataURL);
return res.blob();
}
async function exportPDF(
chartIds: string[],
pixelRatio = 2,
orientation: 'portrait' | 'landscape' = 'landscape',
pageSize = 'a4',
): Promise<Blob> {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF({ orientation, format: pageSize.toLowerCase(), unit: 'mm' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 10;
const imgW = pageW - margin * 2;
const imgH = pageH - margin * 2;
let added = 0;
for (const id of chartIds) {
const instance = getEChartsInstance(id);
if (!instance) continue;
if (added > 0) doc.addPage();
const dataURL = instance.getDataURL({ type: 'png', pixelRatio, backgroundColor: '#fff' });
doc.addImage(dataURL, 'PNG', margin, margin, imgW, imgH);
added++;
}
if (added === 0) throw new Error('No chart instances found for PDF export');
return doc.output('blob');
}
async function exportExcel(
rows: Record<string, any>[],
columns?: string[],
sheetName = 'Sheet1',
): Promise<Blob> {
const XLSX = await import('xlsx');
const ws = columns
? XLSX.utils.json_to_sheet(rows, { header: columns })
: XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
return new Blob([buf], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
}
async function exportPPT(chartIds: string[], pixelRatio = 2): Promise<Blob> {
const PptxGenJS = (await import('pptxgenjs')).default;
const pptx = new PptxGenJS();
let added = 0;
for (const id of chartIds) {
const instance = getEChartsInstance(id);
if (!instance) continue;
const dataURL = instance.getDataURL({ type: 'png', pixelRatio, backgroundColor: '#fff' });
const slide = pptx.addSlide();
slide.addImage({ data: dataURL, x: 0.5, y: 0.5, w: 9, h: 6 });
added++;
}
if (added === 0) throw new Error('No chart instances found for PPT export');
const arrayBuf = await pptx.write({ outputType: 'arraybuffer' });
return new Blob([arrayBuf as ArrayBuffer], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
});
}
function exportHTML(chartIds: string[], title = 'DataViz Pro Export'): Blob {
let chartsHtml = '';
for (const id of chartIds) {
const instance = getEChartsInstance(id);
if (!instance) continue;
const option = instance.getOption();
chartsHtml += `
<div id="chart-${id}" style="width:800px;height:500px;margin:20px auto;background:#fff;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.08);"></div>
<script>echarts.init(document.getElementById('chart-${id}')).setOption(${JSON.stringify(option)});<\/script>`;
}
const html = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>${title}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"><\/script>
<style>body{background:#f5f5f5;padding:20px;font-family:sans-serif;}h1{text-align:center;color:#333;}</style>
</head>
<body>
<h1>${title}</h1>
${chartsHtml}
</body>
</html>`;
return new Blob([html], { type: 'text/html' });
}
function exportTemplate(
charts: Record<string, any>[],
layouts: Record<string, any>[],
): Blob {
const template = {
version: '1.0',
exportedAt: new Date().toISOString(),
charts,
layouts,
};
return new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
}
// ---------------------------------------------------------------------------
// Trigger a browser download from a Blob
// ---------------------------------------------------------------------------
function downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useExport() { export function useExport() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleExport = useCallback(async (options: ExportRequest) => { const handleExport = useCallback(async (request: ExportRequest) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
// Create the export job on the backend const { format, chartIds, fileName, options = {} } = request;
const { id } = await exportServiceClient.createExport({ const pixelRatio = options.pixelRatio ?? 2;
format: options.format, let blob: Blob;
chart_ids: options.chartIds,
dataset_id: options.datasetId,
file_name: options.fileName,
});
// Poll until the export is ready, then download the blob switch (format) {
const blob = await exportServiceClient.waitForExport(id); case 'png': {
// Export first chart as single PNG
blob = await exportPNG(chartIds[0], pixelRatio);
break;
}
case 'jpg': {
const quality = (options.quality ?? 90) / 100;
blob = await exportJPG(chartIds[0], pixelRatio, quality);
break;
}
case 'svg': {
blob = await exportSVG(chartIds[0]);
break;
}
case 'pdf': {
blob = await exportPDF(
chartIds,
pixelRatio,
options.orientation ?? 'landscape',
options.pageSize ?? 'a4',
);
break;
}
case 'excel': {
const rows = options.rows ?? [];
if (rows.length === 0) throw new Error('没有可导出的数据');
blob = await exportExcel(rows, options.columns, options.sheetName ?? 'Sheet1');
break;
}
case 'ppt': {
blob = await exportPPT(chartIds, pixelRatio);
break;
}
case 'html': {
blob = exportHTML(chartIds, options.htmlTitle ?? 'DataViz Pro Export');
break;
}
case 'template': {
blob = exportTemplate(options.charts ?? [], options.layouts ?? []);
break;
}
default:
throw new Error(`Unsupported format: ${format}`);
}
// Trigger browser download const ext = format === 'template' ? 'json' : format === 'excel' ? 'xlsx' : format;
const url = URL.createObjectURL(blob); downloadBlob(blob, fileName ?? `dataviz-export.${ext}`);
const link = document.createElement('a');
link.href = url;
link.download = options.fileName ?? `export.${options.format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (e: any) { } catch (e: any) {
setError(e.message ?? 'Export failed'); const msg = e?.message ?? 'Export failed';
setError(msg);
throw e; throw e;
} finally { } finally {
setLoading(false); setLoading(false);