Files
gongxue-base/apps/admin/src/components/AiChat/DynamicChart.tsx
wangziqi 67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

321 lines
9.9 KiB
TypeScript

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<string, string> = {
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<ChartPreviewProps> = ({ chart }) => {
const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);
const [instance, setInstance] = useState<EChartsType | null>(null);
if (!chart) return null;
// 空数据集:渲染明确占位,而不是一张空白图
if (!chart.rows || chart.rows.length === 0) {
return (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">
<Typography.Text strong>{chart.title}</Typography.Text>
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
</div>
<div
style={{
height: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
);
}
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 (
<div className="ai-chat-chart-card">
<div className="ai-chat-chart-card__header">
<Typography.Text strong>{chart.title}</Typography.Text>
<span className="ai-chat-chart-card__header-actions">
<Tag color="blue">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>
<Tooltip title="下载图片">
<Button
type="text"
size="small"
className="ai-chat-chart-card__download"
aria-label="下载图表图片"
icon={<DownloadOutlined />}
onClick={downloadImage}
disabled={!instance}
/>
</Tooltip>
</span>
</div>
<Suspense fallback={<Spin size="small" />}>
<ReactECharts option={option} style={{ width: '100%', height: 260 }} onReady={setInstance} />
</Suspense>
</div>
);
};
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<DynamicChartProps> = ({ 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 (
<div className="ai-chat-chart">
<XCard.Box components={{ ChartPreview }} commands={commands}>
<XCard.Card id={surfaceId(chart.id)} />
</XCard.Box>
</div>
);
};