feat(portal-shell): 学生域全页面迁移与规范合规修复

- 学生域 32 页全量迁移(含作答/自动保存/提交/诊断)

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

- 修 4 处 Tailwind 任意值;新增共享组件与路由
This commit is contained in:
SpecialX
2026-08-31 11:25:21 +08:00
parent 039db5efdd
commit 04b7a40bdc
493 changed files with 70985 additions and 2112 deletions

View File

@@ -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>
);
}