Files
NextEdu/src/modules/ai/components/ai-chart-renderer.tsx
SpecialX 9ce8d6d3fd refactor(data-access): 补全剩余 37 个 data-access 文件迁移至 cacheFn
迁移范围:lesson-preparation 11 文件、attendance 3 文件、settings 4 文件、classes-invitations、跨模块接口 4 文件、adaptive-practice/ai/onboarding/invitation-codes/search/standards/scheduling/leave-requests/audit。修复 3 个 cacheFn 块位置错误。同步架构文档迁移范围至 83 文件 250+ 函数。
2026-07-06 15:12:30 +08:00

367 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>
)
}