feat(portal-shell): 学生域全页面迁移与规范合规修复
- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断) - 补齐 4 个 MSW mock 缺口,修 diagnostic case 名 - 修 4 处 Tailwind 任意值;新增共享组件与路由
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user