由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - Roles 多组勾选、Schedules 月视图新建、useDirtyGuard 稳定引用 - JinshujuMatchModal success:false、Attendance store 订阅、EditableCell Enter、DynamicChart 空数据、渲染期 ref - AttachmentsTab 附件预览不再 window.open(防存储型 XSS)+ 请求乱序防护 - 考勤 late 计入出勤率与主状态 Reviewed-by: OCR (open-codereview.ai)
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
|
import { saveAs } from 'file-saver';
|
|
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 }) => {
|
|
// 空/无数据时先短路,避免 buildOption 在空数据集上执行
|
|
const hasData = !!chart && !!chart.rows && chart.rows.length > 0;
|
|
const option = useMemo<EChartsOption>(
|
|
() => (chart && hasData ? buildOption(chart) : {}),
|
|
[chart, hasData],
|
|
);
|
|
const [instance, setInstance] = useState<EChartsType | null>(null);
|
|
if (!chart) return null;
|
|
// 空数据集:渲染明确占位,而不是一张空白图
|
|
if (!hasData) {
|
|
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',
|
|
});
|
|
saveAs(url, `${chart.title || '图表'}.png`);
|
|
};
|
|
|
|
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>
|
|
);
|
|
};
|