Files
Edu/apps/teacher-portal/src/app/(app)/ai-lesson-plan/page.tsx
SpecialX d49d211425 feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现
- 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 参考项目差距闭环(含完整文件清单)
2026-07-13 14:27:04 +08:00

260 lines
8.7 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";
/**
* AI 教案生成页面P5
*
* 数据来源02-architecture-design.md §6 AI 辅助):
* - GraphQL GenerateLessonPlanMutation
*
* 功能:
* - 表单:班级 / 科目 / 课时主题 / 教学目标
* - 生成教案mutation+ 流式输出模拟setInterval 逐字显示)
* - 完整教案展示
* - 导出为 .md 文件
*
* 维护者ai13teacher-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>
);
}