- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步 - P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote - P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告 - P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移 - P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块 - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页) - classes/[id] 详情 + classes/schedule 课表 - course-plans(2 页)/diagnostic(2 页)/error-book/practice - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析 - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考 - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡 - homework/submissions 列表 + assignments/[id]/submissions 批量批改 - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改 - lesson-plans 编辑器 + library + calendar + heatmap 5 页 - elective 选修课 3 页 /leave 请假 /schedule-changes 调课 - P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports - 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序) - viewports.ts 扩展 11 个新导航项 - 验证:tsc --noEmit 零错误 + eslint 零错误 - 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* AI 教案生成页面(P5)
|
||
*
|
||
* 数据来源(02-architecture-design.md §6 AI 辅助):
|
||
* - GraphQL GenerateLessonPlanMutation
|
||
*
|
||
* 功能:
|
||
* - 表单:班级 / 科目 / 课时主题 / 教学目标
|
||
* - 生成教案(mutation)+ 流式输出模拟(setInterval 逐字显示)
|
||
* - 完整教案展示
|
||
* - 导出为 .md 文件
|
||
*
|
||
* 维护者:ai13(teacher-portal)
|
||
*/
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { useMutation, useQuery } from "urql";
|
||
import { GenerateLessonPlanMutation } from "@/lib/graphql-p5";
|
||
import type {
|
||
GeneratedLessonPlan,
|
||
GenerateLessonPlanInput,
|
||
} from "@/lib/graphql-p5";
|
||
import { ClassesQuery } from "@/lib/graphql";
|
||
import type { Class } from "@/lib/graphql";
|
||
import { Loading } from "@edu/ui-components";
|
||
|
||
const SUBJECTS = ["数学", "语文", "英语", "物理", "化学"];
|
||
const STREAM_INTERVAL_MS = 12;
|
||
|
||
export default function AiLessonPlanPage(): JSX.Element {
|
||
const [classId, setClassId] = useState("");
|
||
const [subject, setSubject] = useState<string>(SUBJECTS[0] ?? "数学");
|
||
const [topic, setTopic] = useState("函数的单调性与极值");
|
||
const [objectives, setObjectives] = useState(
|
||
"理解单调性、掌握极值求法、能解决实际问题",
|
||
);
|
||
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [streamingText, setStreamingText] = useState("");
|
||
const [generated, setGenerated] = useState<GeneratedLessonPlan | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const streamTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const [, generateLessonPlan] = useMutation(GenerateLessonPlanMutation);
|
||
const [classesResult] = useQuery({ query: ClassesQuery });
|
||
const classes = (classesResult.data?.classes as Class[] | undefined) ?? [];
|
||
|
||
useEffect(() => {
|
||
if (!classId) {
|
||
const first = classes[0];
|
||
if (first) setClassId(first.id);
|
||
}
|
||
}, [classes, classId]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (streamTimerRef.current) clearInterval(streamTimerRef.current);
|
||
};
|
||
}, []);
|
||
|
||
const handleGenerate = async (): Promise<void> => {
|
||
if (isGenerating || isStreaming) return;
|
||
setIsGenerating(true);
|
||
setError(null);
|
||
setGenerated(null);
|
||
setStreamingText("");
|
||
|
||
const input: GenerateLessonPlanInput = {
|
||
classId,
|
||
subject,
|
||
topic,
|
||
objectives,
|
||
};
|
||
|
||
const res = await generateLessonPlan({ input });
|
||
setIsGenerating(false);
|
||
|
||
if (res.error || !res.data?.generateLessonPlan) {
|
||
setError(res.error?.message ?? "生成失败,请重试");
|
||
return;
|
||
}
|
||
|
||
const lp = res.data.generateLessonPlan as GeneratedLessonPlan;
|
||
const full = lp.content;
|
||
|
||
setIsStreaming(true);
|
||
let i = 0;
|
||
streamTimerRef.current = setInterval(() => {
|
||
i += 1;
|
||
setStreamingText(full.slice(0, i));
|
||
if (i >= full.length) {
|
||
if (streamTimerRef.current) {
|
||
clearInterval(streamTimerRef.current);
|
||
streamTimerRef.current = null;
|
||
}
|
||
setIsStreaming(false);
|
||
setGenerated(lp);
|
||
}
|
||
}, STREAM_INTERVAL_MS);
|
||
};
|
||
|
||
const handleExport = (): void => {
|
||
if (!generated) return;
|
||
const blob = new Blob([generated.content], {
|
||
type: "text/markdown;charset=utf-8",
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `lesson-plan-${generated.id}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<div className="px-10 py-10">
|
||
<header className="mb-8">
|
||
<h1 className="text-3xl font-serif text-ink">AI 教案生成</h1>
|
||
<p className="mt-1 text-sm text-ink-muted">
|
||
GraphQL GenerateLessonPlanMutation · 流式输出模拟(P5)
|
||
</p>
|
||
</header>
|
||
|
||
<div className="rule-thin mb-8" />
|
||
|
||
{/* 表单 */}
|
||
<section className="mb-8 max-w-3xl">
|
||
<h2 className="text-xl font-serif text-ink mb-4">教案参数</h2>
|
||
{classesResult.fetching && classes.length === 0 ? (
|
||
<Loading lines={2} />
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||
班级
|
||
</span>
|
||
<select
|
||
value={classId}
|
||
onChange={(e) => setClassId(e.target.value)}
|
||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||
>
|
||
{classes.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-col gap-1">
|
||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||
科目
|
||
</span>
|
||
<select
|
||
value={subject}
|
||
onChange={(e) => setSubject(e.target.value)}
|
||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||
>
|
||
{SUBJECTS.map((s) => (
|
||
<option key={s} value={s}>
|
||
{s}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className="col-span-2 flex flex-col gap-1">
|
||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||
课时主题
|
||
</span>
|
||
<input
|
||
type="text"
|
||
value={topic}
|
||
onChange={(e) => setTopic(e.target.value)}
|
||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink"
|
||
/>
|
||
</label>
|
||
<label className="col-span-2 flex flex-col gap-1">
|
||
<span className="text-tiny uppercase tracking-wide text-ink-muted">
|
||
教学目标
|
||
</span>
|
||
<textarea
|
||
value={objectives}
|
||
onChange={(e) => setObjectives(e.target.value)}
|
||
rows={3}
|
||
className="px-3 py-2 bg-transparent border-b border-rule text-sm text-ink resize-none"
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-6">
|
||
<button
|
||
type="button"
|
||
onClick={handleGenerate}
|
||
disabled={isGenerating || isStreaming || !classId}
|
||
className="px-5 py-2 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isGenerating ? "生成中…" : "生成教案"}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
{error && (
|
||
<div className="mark-left mb-6 py-2 border-l-2 border-danger pl-md">
|
||
<p className="text-sm px-3 text-danger">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 生成结果 */}
|
||
{(isGenerating || isStreaming || streamingText || generated) && (
|
||
<section className="mb-10">
|
||
<div className="flex items-baseline justify-between mb-4">
|
||
<h2 className="text-xl font-serif text-ink">
|
||
教案内容
|
||
{isGenerating && (
|
||
<span className="ml-3 text-sm font-sans text-ink-muted">
|
||
正在请求 AI…
|
||
</span>
|
||
)}
|
||
{isStreaming && (
|
||
<span className="ml-3 text-sm font-sans text-accent">
|
||
流式输出中…
|
||
</span>
|
||
)}
|
||
</h2>
|
||
{generated && !isStreaming && (
|
||
<button
|
||
type="button"
|
||
onClick={handleExport}
|
||
className="text-tiny uppercase tracking-wide text-accent hover:opacity-70"
|
||
>
|
||
导出 .md
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="rule-thin mb-6" />
|
||
|
||
{isStreaming || !generated ? (
|
||
<div className="p-6 border border-rule rounded-card bg-subtle whitespace-pre-wrap text-sm text-ink min-h-24 font-mono">
|
||
{streamingText}
|
||
{isStreaming && (
|
||
<span className="inline-block w-2 h-4 ml-0.5 bg-accent animate-pulse align-middle" />
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="p-6 border border-rule rounded-card bg-subtle">
|
||
<p className="text-sm text-ink-muted mb-4">{generated.summary}</p>
|
||
<pre className="whitespace-pre-wrap text-sm text-ink font-mono leading-relaxed">
|
||
{generated.content}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|