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:
parent
b3098eefbd
commit
dc6ac54c86
|
|
@ -10,9 +10,11 @@ import { DataTable } from './DataTable';
|
|||
export interface ChartRendererProps {
|
||||
chart: ChartInstance;
|
||||
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
|
||||
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 overflow = borderRadius > 0 ? 'hidden' as const : undefined;
|
||||
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>;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export const ChartWrapper: React.FC<ChartWrapperProps> = ({ chartId }) => {
|
|||
|
||||
{/* Chart content */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<ChartRenderer chart={chart} data={rows} />
|
||||
<ChartRenderer chart={chart} data={rows} chartId={chartId} />
|
||||
</div>
|
||||
|
||||
{/* Hover styles via inline <style> to show toolbar */}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,29 @@ import React, { useEffect, useRef } from 'react';
|
|||
import * as echarts from 'echarts';
|
||||
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 {
|
||||
option: Record<string, any>;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
onEvents?: Record<string, (params: any) => void>;
|
||||
loading?: boolean;
|
||||
/** Optional id used to register the instance in the global map for export. */
|
||||
chartId?: string;
|
||||
}
|
||||
|
||||
export const EChartsBase: React.FC<EChartsBaseProps> = ({
|
||||
|
|
@ -18,6 +35,7 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
|
|||
className,
|
||||
onEvents,
|
||||
loading = false,
|
||||
chartId,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<echarts.ECharts | null>(null);
|
||||
|
|
@ -29,6 +47,8 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
|
|||
|
||||
// Dispose previous instance if theme changed
|
||||
if (chartRef.current) {
|
||||
// Unregister from global map before disposing
|
||||
if (chartId) instanceMap.delete(chartId);
|
||||
chartRef.current.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +58,9 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
|
|||
);
|
||||
chartRef.current = instance;
|
||||
|
||||
// Register in global map
|
||||
if (chartId) instanceMap.set(chartId, instance);
|
||||
|
||||
// ResizeObserver for responsive sizing
|
||||
const observer = new ResizeObserver(() => {
|
||||
instance.resize();
|
||||
|
|
@ -46,10 +69,11 @@ export const EChartsBase: React.FC<EChartsBaseProps> = ({
|
|||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (chartId) instanceMap.delete(chartId);
|
||||
instance.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [currentTheme]);
|
||||
}, [currentTheme, chartId]);
|
||||
|
||||
// Update option
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -94,29 +94,53 @@ export const ExportDialog: React.FC = () => {
|
|||
setSelectedFormat(null);
|
||||
}, [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 () => {
|
||||
if (!selectedFormat) {
|
||||
message.warning('请先选择导出格式');
|
||||
return;
|
||||
}
|
||||
if (chartIds.length === 0) {
|
||||
if (chartIds.length === 0 && selectedFormat !== 'excel') {
|
||||
message.warning('当前看板没有图表可供导出');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = selectedFormat === 'template' ? 'json' : selectedFormat === 'excel' ? 'xlsx' : selectedFormat;
|
||||
|
||||
try {
|
||||
await handleExport({
|
||||
format: selectedFormat,
|
||||
chartIds,
|
||||
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('导出成功');
|
||||
handleClose();
|
||||
} catch {
|
||||
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 = () => {
|
||||
if (!selectedFormat) return null;
|
||||
|
|
|
|||
|
|
@ -1,45 +1,285 @@
|
|||
'use client';
|
||||
|
||||
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 {
|
||||
format: string;
|
||||
format: ExportFormat;
|
||||
chartIds: string[];
|
||||
datasetId: string;
|
||||
fileName?: string;
|
||||
/** Extra options forwarded from the dialog UI. */
|
||||
options?: ExportOptions;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
/** 10–100, 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() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleExport = useCallback(async (options: ExportRequest) => {
|
||||
const handleExport = useCallback(async (request: ExportRequest) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Create the export job on the backend
|
||||
const { id } = await exportServiceClient.createExport({
|
||||
format: options.format,
|
||||
chart_ids: options.chartIds,
|
||||
dataset_id: options.datasetId,
|
||||
file_name: options.fileName,
|
||||
});
|
||||
const { format, chartIds, fileName, options = {} } = request;
|
||||
const pixelRatio = options.pixelRatio ?? 2;
|
||||
let blob: Blob;
|
||||
|
||||
// Poll until the export is ready, then download the blob
|
||||
const blob = await exportServiceClient.waitForExport(id);
|
||||
switch (format) {
|
||||
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 url = URL.createObjectURL(blob);
|
||||
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);
|
||||
const ext = format === 'template' ? 'json' : format === 'excel' ? 'xlsx' : format;
|
||||
downloadBlob(blob, fileName ?? `dataviz-export.${ext}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Export failed');
|
||||
const msg = e?.message ?? 'Export failed';
|
||||
setError(msg);
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
|||
Loading…
Reference in New Issue