迁移范围: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+ 函数。
367 lines
9.6 KiB
TypeScript
367 lines
9.6 KiB
TypeScript
"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>
|
||
)
|
||
}
|