import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card'; import { Button, Spin, Tag, Tooltip, Typography } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import type { EChartsType } from 'echarts/core'; import type { EChartsOption } from '../../components/ECharts'; import type { AiChartSchema } from './types'; import { useXCardSurface } from './useSubmissionState'; // echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取 const ReactECharts = lazy(() => import('../../components/ECharts')); const CHART_CATALOG_ID = 'gongxue-chart-catalog'; registerCatalog({ catalogId: CHART_CATALOG_ID, components: { ChartPreview: { type: 'object', properties: { chart: { type: 'object' }, }, }, }, }); function surfaceId(chartId: string): string { return `chart-${chartId}`; } function numberValue(value: unknown): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } const CHART_TYPE_LABELS: Record = { line: '折线图', bar: '柱状图', pie: '饼图', area: '面积图', scatter: '散点图', radar: '雷达图', gauge: '仪表盘', funnel: '漏斗图', }; function buildNameValueRows(chart: AiChartSchema): { name: string; value: number }[] { const nameField = chart.columns[0]?.key ?? ''; const valueField = chart.columns[1]?.key ?? ''; return chart.rows.map((row) => ({ name: String(row[nameField] ?? ''), value: numberValue(row[valueField]), })); } function buildScatterOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; const nameField = columns[0]?.key ?? ''; const xField = columns[1]?.key ?? ''; const yField = columns[2]?.key ?? ''; const data = chart.rows.map((row) => ({ name: String(row[nameField] ?? ''), value: [numberValue(row[xField]), numberValue(row[yField])], })); return { tooltip: { trigger: 'item', formatter: (params: unknown) => { const item = params as { name?: string; value?: number[] }; const [x, y] = item.value ?? []; return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`; }, }, grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, xAxis: { type: 'value', name: columns[1]?.title }, yAxis: { type: 'value', name: columns[2]?.title }, series: [{ type: 'scatter', symbolSize: 10, data }], }; } function buildRadarOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; const seriesNameField = columns[0]?.key ?? ''; const indicatorColumns = columns.slice(1); const indicators = indicatorColumns.map((column) => { const values = chart.rows.map((row) => numberValue(row[column.key])); const max = Math.max(1, ...values); return { name: column.title, max: Math.ceil(max * 1.1) }; }); const seriesData = chart.rows.map((row) => ({ name: String(row[seriesNameField] ?? ''), value: indicatorColumns.map((column) => numberValue(row[column.key])), })); return { tooltip: { trigger: 'item' }, legend: { bottom: 0, type: 'scroll' }, radar: { indicator: indicators, radius: '65%' }, series: [{ type: 'radar', data: seriesData }], }; } function buildGaugeOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; const nameField = columns[0]?.key ?? ''; const valueField = columns[1]?.key ?? ''; const maxField = columns[2]?.key; const gauges = chart.rows.map((row) => ({ name: String(row[nameField] ?? ''), value: numberValue(row[valueField]), max: maxField ? Math.max(1, numberValue(row[maxField])) : 100, })); return { series: gauges.map((gauge, index) => ({ type: 'gauge', center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'], radius: '75%', min: 0, max: gauge.max, title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 }, detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] }, data: [{ value: gauge.value, name: gauge.name }], })), }; } function buildNameValueOption(chart: AiChartSchema): EChartsOption { const data = buildNameValueRows(chart); return chart.chartType === 'funnel' ? { tooltip: { trigger: 'item', formatter: '{b}: {c}' }, legend: { bottom: 0, type: 'scroll' }, series: [ { type: 'funnel', left: '10%', top: 20, bottom: 40, width: '80%', minSize: '20%', label: { formatter: '{b}: {c}' }, data, }, ], } : { tooltip: { trigger: 'item' }, legend: { bottom: 0, type: 'scroll' }, series: [ { type: 'pie', radius: ['35%', '68%'], center: ['50%', '45%'], data, label: { formatter: '{b}: {c}' }, }, ], }; } function buildOption(chart: AiChartSchema): EChartsOption { if (chart.chartType === 'scatter') return buildScatterOption(chart); if (chart.chartType === 'radar') return buildRadarOption(chart); if (chart.chartType === 'gauge') return buildGaugeOption(chart); if (chart.chartType === 'funnel' || chart.chartType === 'pie') return buildNameValueOption(chart); return buildCategoryOption(chart); } function buildCategoryOption(chart: AiChartSchema): EChartsOption { const columns = chart.columns; const categoryField = columns[0]?.key ?? ''; const categories = chart.rows.map((row) => String(row[categoryField] ?? '')); const series = columns.slice(1).map((column) => ({ name: column.title, type: chart.chartType === 'area' ? 'line' : chart.chartType, smooth: chart.chartType === 'line', ...(chart.chartType === 'area' ? { areaStyle: { opacity: 0.18 } } : {}), data: chart.rows.map((row) => numberValue(row[column.key])), })); return { tooltip: { trigger: 'axis' }, legend: { bottom: 0, type: 'scroll' }, grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true }, xAxis: { type: 'category', data: categories, axisLabel: { interval: 0, rotate: categories.length > 8 ? 30 : 0 }, }, yAxis: { type: 'value' }, series, }; } interface ChartPreviewProps { chart?: AiChartSchema; } /** * A2UI component registered for the `gongxue-chart-catalog` catalog. * Receives the validated tabular chart data through data binding and * renders an ECharts option built from it. */ const ChartPreview: React.FC = ({ chart }) => { const option = useMemo(() => (chart ? buildOption(chart) : {}), [chart]); const [instance, setInstance] = useState(null); if (!chart) return null; // 空数据集:渲染明确占位,而不是一张空白图 if (!chart.rows || chart.rows.length === 0) { return (
{chart.title} {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}
暂无数据
); } const downloadImage = () => { if (!instance) return; const url = instance.getDataURL({ type: 'png', pixelRatio: 2, backgroundColor: '#fff', }); const link = document.createElement('a'); link.href = url; link.download = `${chart.title || '图表'}.png`; document.body.appendChild(link); link.click(); link.remove(); }; return (
{chart.title} {CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}
}>
); }; export interface DynamicChartProps { chart: AiChartSchema; } /** * Chart card rendered through the official @ant-design/x-card renderer. * Display-only: no submit endpoint, the schema lives in message metadata * so history replays identically. */ export const DynamicChart: React.FC = ({ chart }) => { const sid = surfaceId(chart.id); const { commands, pushCommands } = useXCardSurface(sid); useEffect(() => { const cmds: XAgentCommand_v0_9[] = [ { version: 'v0.9', createSurface: { surfaceId: sid, catalogId: CHART_CATALOG_ID }, }, { version: 'v0.9', updateDataModel: { surfaceId: sid, path: '/chart', value: chart, }, }, { version: 'v0.9', updateComponents: { surfaceId: sid, components: [ { id: 'root', component: 'ChartPreview', chart: { path: '/chart' }, }, ], }, }, ]; pushCommands(cmds); }, [chart, pushCommands, sid]); return (
); };