Files
Edu/apps/portal-shell/src/features/teacher/ai/components/ai-chart-renderer.tsx
SpecialX 04b7a40bdc feat(portal-shell): 学生域全页面迁移与规范合规修复
- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断)

- 补齐 4 个 MSW mock 缺口,修 diagnostic case 名

- 修 4 处 Tailwind 任意值;新增共享组件与路由
2026-08-31 11:25:21 +08:00

375 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
XAxis,
YAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart";
import { cn } from "@/shared/lib/utils";
import { AiChartSpecSchema } from "../schema";
/** recharts 内联对象常量:避免每次渲染创建新对象触发 re-render */
const CHART_MARGIN = { left: 8, right: 8, top: 8, bottom: 8 };
const CARTESIAN_GRID_PROPS = {
vertical: false,
strokeDasharray: "4 4",
strokeOpacity: 0.4,
} as const;
const POLAR_GRID_PROPS = { strokeOpacity: 0.4 } as const;
const BAR_X_AXIS_PROPS = {
tickLine: false,
axisLine: false,
tickMargin: 8,
} as const;
const BAR_Y_AXIS_PROPS = {
allowDecimals: true,
tickLine: false,
axisLine: false,
width: 36,
} as const;
const LINE_Y_AXIS_PROPS = {
tickLine: false,
axisLine: false,
width: 36,
} as const;
const LINE_TOOLTIP_CURSOR = {
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
} as const;
const LINE_ACTIVE_DOT = { r: 5, strokeWidth: 0 } as const;
const POLAR_ANGLE_TICK = { fontSize: 12 } as const;
const POLAR_RADIUS_TICK = { fontSize: 10 } as const;
const BAR_RADIUS_TOP: [number, number, number, number] = [4, 4, 0, 0];
const DEFAULT_Y_DOMAIN: [number, number] = [0, 100];
function formatPercentTick(value: number): string {
return `${value}%`;
}
function formatPieLabel(entry: { name?: string; value?: number }): string {
return `${entry.name ?? ""}: ${entry.value ?? ""}`;
}
/**
* AI 图表渲染器
*
* 将 AI 返回的图表 JSON 规格渲染为 recharts 图表。
* 支持 4 种图表类型bar / line / pie / radar。
*
* AI 通过在 Markdown 中返回特殊代码块触发渲染:
*
* ```chart:bar
* { "title": "...", "data": [...], "series": [...] }
* ```
*
* 规格格式(通用):
* {
* "title": "图表标题", // 可选
* "description": "说明文字", // 可选
* "data": [ // 数据数组
* { "name": "数学", "score": 85, "fullTitle": "数学科目" }
* ],
* "xKey": "name", // X 轴字段bar/line
* "series": [ // 系列配置
* { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }
* ],
* "yDomain": [0, 100], // 可选Y 轴定义域
* "height": 280 // 可选,高度 px
* }
*
* pie 图特有:
* {
* "data": [{ "name": "及格", "value": 30 }],
* "series": [{ "name": "分布" }]
* }
*/
export type AiChartType = "bar" | "line" | "pie" | "radar";
export interface AiChartSeries {
dataKey: string;
name: string;
color?: string;
/** pie: 填充透明度radar: 填充透明度 */
fillOpacity?: number;
/** radar: 线宽 */
strokeWidth?: number;
/** radar: 虚线 */
strokeDasharray?: string;
}
export interface AiChartSpec {
title?: string;
description?: string;
type?: AiChartType;
data: Array<Record<string, string | number>>;
xKey?: string;
angleKey?: string;
series: AiChartSeries[];
yDomain?: [number, number];
height?: number;
showLegend?: boolean;
}
/** 默认调色板(色盲友好) */
const DEFAULT_PALETTE = [
"hsl(221, 83%, 53%)", // 蓝
"hsl(142, 71%, 45%)", // 绿
"hsl(43, 96%, 56%)", // 黄
"hsl(0, 84%, 60%)", // 红
"hsl(271, 76%, 53%)", // 紫
"hsl(199, 89%, 48%)", // 青
"hsl(25, 95%, 53%)", // 橙
"hsl(280, 65%, 60%)", // 品红
];
interface AiChartRendererProps {
/** 图表类型 */
type: AiChartType;
/** JSON 规格字符串 */
spec: string;
className?: string;
}
/**
* 解析 JSON 规格,失败时返回 null
*
* 使用 Zod schema 校验,避免 as 断言。
*/
function parseSpec(spec: string): AiChartSpec | null {
try {
const parsed: unknown = JSON.parse(spec);
const result = AiChartSpecSchema.safeParse(parsed);
if (!result.success) return null;
return result.data;
} catch {
return null;
}
}
/**
* 为 series 补充默认颜色
*/
function withDefaultColors(series: AiChartSeries[]): AiChartSeries[] {
return series.map((s, i) => ({
...s,
color: s.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length],
}));
}
export function AiChartRenderer({
type,
spec,
className,
}: AiChartRendererProps): React.ReactNode {
const t = useTranslations("ai");
const parsed = useMemo(() => parseSpec(spec), [spec]);
if (!parsed) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{t("chart.parseError")}
</div>
);
}
const series = withDefaultColors(parsed.series);
const height = parsed.height ?? 280;
// 构建 ChartConfig
const chartConfig: ChartConfig = {};
for (const s of series) {
chartConfig[s.dataKey] = {
label: s.name,
color: s.color,
};
}
return (
<div className={cn("my-2 rounded-lg border bg-card p-3", className)}>
{parsed.title ? (
<div className="mb-2">
<p className="text-sm font-medium leading-tight">{parsed.title}</p>
{parsed.description ? (
<p className="text-xs text-muted-foreground mt-0.5">
{parsed.description}
</p>
) : null}
</div>
) : null}
<ChartContainer
config={chartConfig}
className="w-full"
style={{ height: `${height}px` }}
>
{renderChart(type, parsed, series)}
</ChartContainer>
</div>
);
}
function renderChart(
type: AiChartType,
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
switch (type) {
case "bar":
return renderBarChart(spec, series);
case "line":
return renderLineChart(spec, series);
case "pie":
return renderPieChart(spec, series);
case "radar":
return renderRadarChart(spec, series);
default:
return null;
}
}
function renderBarChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const xKey = spec.xKey ?? "name";
return (
<BarChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis domain={spec.yDomain} {...BAR_Y_AXIS_PROPS} />
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={`var(--color-${s.dataKey})`}
radius={BAR_RADIUS_TOP}
/>
))}
</BarChart>
);
}
function renderLineChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const xKey = spec.xKey ?? "name";
return (
<LineChart data={spec.data} margin={CHART_MARGIN}>
<CartesianGrid {...CARTESIAN_GRID_PROPS} />
<XAxis dataKey={xKey} {...BAR_X_AXIS_PROPS} />
<YAxis domain={spec.yDomain ?? DEFAULT_Y_DOMAIN} {...LINE_Y_AXIS_PROPS} />
{/* arbitrary-value: chart canvas fixed size */}
<ChartTooltip
cursor={LINE_TOOLTIP_CURSOR}
content={<ChartTooltipContent indicator="line" className="w-[220px]" />}
/>
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Line
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
type="monotone"
stroke={`var(--color-${s.dataKey})`}
strokeWidth={2}
dot={{
fill: `var(--color-${s.dataKey})`,
r: 3,
strokeWidth: 2,
}}
activeDot={LINE_ACTIVE_DOT}
/>
))}
</LineChart>
);
}
function renderPieChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
// Pie 图data 中每项 { name, value }series 仅取第一个作为图例名
const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0]);
const dataKey = series[0]?.dataKey ?? "value";
return (
<PieChart margin={CHART_MARGIN}>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
<Pie
data={spec.data}
dataKey={dataKey}
nameKey={spec.xKey ?? "name"}
cx="50%"
cy="50%"
outerRadius="75%"
label={formatPieLabel}
labelLine={false}
>
{spec.data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
</PieChart>
);
}
function renderRadarChart(
spec: AiChartSpec,
series: AiChartSeries[],
): React.ReactNode {
const angleKey = spec.angleKey ?? spec.xKey ?? "name";
return (
<RadarChart data={spec.data} outerRadius="75%" margin={CHART_MARGIN}>
<PolarGrid {...POLAR_GRID_PROPS} />
<PolarAngleAxis dataKey={angleKey} tick={POLAR_ANGLE_TICK} />
<PolarRadiusAxis
domain={spec.yDomain ?? DEFAULT_Y_DOMAIN}
tickFormatter={formatPercentTick}
tick={POLAR_RADIUS_TICK}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Radar
key={s.dataKey}
name={s.name}
dataKey={s.dataKey}
stroke={`var(--color-${s.dataKey})`}
fill={`var(--color-${s.dataKey})`}
fillOpacity={s.fillOpacity ?? 0.3}
strokeWidth={s.strokeWidth}
strokeDasharray={s.strokeDasharray}
/>
))}
</RadarChart>
);
}