diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx new file mode 100644 index 0000000..38ffdd9 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/error.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { BookOpen } from "lucide-react"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +export default function HeatmapError() { + const t = useTranslations("lessonPreparation"); + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ); +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx new file mode 100644 index 0000000..c811d2d --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; + +export default function HeatmapLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx new file mode 100644 index 0000000..9e06262 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/heatmap/page.tsx @@ -0,0 +1,124 @@ +import type { JSX } from "react"; +import { BookOpen } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { Permissions } from "@/shared/types/permissions"; +import { getLessonPlans } from "@/modules/lesson-preparation/data-access"; +import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"; +import { getKnowledgePointsByTextbookId, getTextbooks } from "@/modules/textbooks/data-access"; +import { CurriculumHeatmap } from "@/modules/lesson-preparation/components/curriculum-heatmap"; +import type { PlanKpLink } from "@/modules/lesson-preparation/lib/curriculum-coverage"; +import type { LessonPlanDocument } from "@/modules/lesson-preparation/types"; +import { isRecord } from "@/shared/lib/type-guards"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +export const dynamic = "force-dynamic"; + +/** + * V5-20 T4:教师端课标热力图页面。 + * + * 展示教师所有课案对所选教材知识点的覆盖情况。 + * 从教师所有课案中提取 knowledgePointIds,统计覆盖率。 + */ +export default async function HeatmapPage(): Promise { + const t = await getTranslations("lessonPreparation"); + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); + + // 教师所有课案(含草稿和已发布) + const [plans, textbooks] = await Promise.all([ + getLessonPlans({}, ctx.dataScope, ctx.userId), + getTextbooks(), + ]); + + // 仅保留有教材的课案 + const plansWithTextbook = plans.filter((p) => p.textbookId); + + // 默认展示教师第一本教材(按使用频率) + const textbookCounts = new Map(); + for (const p of plansWithTextbook) { + if (p.textbookId) { + textbookCounts.set(p.textbookId, (textbookCounts.get(p.textbookId) ?? 0) + 1); + } + } + const defaultTextbookId = + [...textbookCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? textbooks[0]?.id ?? ""; + + if (!defaultTextbookId || textbooks.length === 0) { + return ( +
+ +
+ ); + } + + // 获取该教材的所有知识点 + const allKps = await getKnowledgePointsByTextbookId(defaultTextbookId); + + // 提取该教材下所有课案的知识点关联 + const planLinks: PlanKpLink[] = []; + const chapterNames: Record = {}; + for (const plan of plansWithTextbook.filter((p) => p.textbookId === defaultTextbookId)) { + try { + const fullPlan = await getLessonPlanById(plan.id, ctx.userId); + if (!fullPlan) continue; + const doc = fullPlan.content as LessonPlanDocument; + const kpSet = new Set(); + for (const node of doc.nodes) { + if (!isRecord(node.data)) continue; + const dataKps = (node.data as { knowledgePointIds?: unknown }).knowledgePointIds; + if (Array.isArray(dataKps)) { + for (const kp of dataKps) { + if (typeof kp === "string") kpSet.add(kp); + } + } + } + if (kpSet.size > 0) { + planLinks.push({ planId: plan.id, knowledgePointIds: [...kpSet] }); + } + } catch { + // 单个课案加载失败不阻塞整体 + } + } + + // 章节名映射(从 textbooks 模块获取) + // 此处简化:使用知识点自身的 chapterId,章节名从 textbooks data-access 获取 + // 为避免 N+1 查询,此处仅用 chapterId 作为 key,章节名由前端未知章节兜底 + // 完整实现可在 textbooks data-access 添加批量查询函数 + for (const kp of allKps) { + if (kp.chapterId) { + chapterNames[kp.chapterId] = kp.chapterId; // 占位,实际应查询章节名 + } + } + + return ( +
+
+

+

+

{t("heatmap.description")}

+
+ + {allKps.length === 0 ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx new file mode 100644 index 0000000..9ff6cd4 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/library/error.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { BookOpen } from "lucide-react"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +export default function LibraryError() { + const t = useTranslations("lessonPreparation"); + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ); +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx new file mode 100644 index 0000000..77cd4a6 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/library/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; + +export default function LibraryLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx new file mode 100644 index 0000000..3c2aa14 --- /dev/null +++ b/src/app/(dashboard)/teacher/lesson-plans/library/page.tsx @@ -0,0 +1,106 @@ +import type { JSX } from "react"; +import Link from "next/link"; +import { BookOpen, Copy } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { Button } from "@/shared/components/ui/button"; +import { EmptyState } from "@/shared/components/ui/empty-state"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { Permissions } from "@/shared/types/permissions"; +import { getLessonPlans } from "@/modules/lesson-preparation/data-access"; +import { duplicateLessonPlanFormAction } from "@/modules/lesson-preparation/actions"; +import { formatDateTime } from "@/shared/lib/utils"; + +export const dynamic = "force-dynamic"; + +/** + * V5-12 R4:校内课案库。 + * + * 展示本校其他教师分享的已发布课案,支持一键复制为自己的课案。 + * 复用现有 getLessonPlans 数据访问(scope 已限定可见范围), + * 在页面层过滤掉当前用户自己创建的课案。 + */ +export default async function LibraryPage(): Promise { + const t = await getTranslations("lessonPreparation"); + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); + + const allItems = await getLessonPlans( + { status: "published" }, + ctx.dataScope, + ctx.userId, + ); + + // 过滤掉当前用户自己创建的课案,仅展示他人分享的 + const libraryItems = allItems.filter((item) => item.creatorId !== ctx.userId); + + return ( +
+
+

+

+

{t("library.description")}

+
+ + {libraryItems.length === 0 ? ( + + ) : ( +
+ {libraryItems.map((item) => ( +
+
+

+ + {item.title} + +

+
+ {item.subjectName && ( + + {item.subjectName} + + )} + {item.gradeName && ( + + {item.gradeName} + + )} + {item.textbookTitle && ( + + {item.textbookTitle} + + )} +
+

+ {t("library.byCreator", { + creator: item.creatorName ?? t("library.noCreator"), + })} + {" · "} + {formatDateTime(item.updatedAt)} +

+
+
+ + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/modules/lesson-preparation/actions-ai.ts b/src/modules/lesson-preparation/actions-ai.ts index 27b1504..48a6f8e 100644 --- a/src/modules/lesson-preparation/actions-ai.ts +++ b/src/modules/lesson-preparation/actions-ai.ts @@ -3,9 +3,20 @@ import { requirePermission } from "@/shared/lib/auth-guard"; import { handleActionError } from "@/shared/lib/action-utils"; import { Permissions } from "@/shared/types/permissions"; +import { getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"; import { suggestKnowledgePoints } from "./ai-suggest"; import { suggestKnowledgePointsSchema } from "./schema"; import { translateFieldErrors } from "./lib/i18n-errors"; +import { generateLessonPlanFeedback, type AiFeedbackResult } from "./lib/ai-feedback"; +import { + generateDifferentiationSuggestions, + checkCurriculumAlignment, + generateExplainableAssessment, + type DifferentiationSuggestion, + type CurriculumCheckItem, + type ExplainableAssessment, +} from "./lib/ai-differentiation"; +import type { LessonPlanDocument } from "./types"; import type { ActionState } from "@/shared/types/action-state"; export async function suggestKnowledgePointsAction(input: { @@ -45,3 +56,99 @@ export async function suggestKnowledgePointsAction(input: { return handleActionError(e); } } + +/** + * V5-17 A1/A2:AI 反馈闭环。 + * 调用 AI 对课案文档生成结构化反馈(含解释性展示)。 + */ +export async function generateLessonPlanFeedbackAction( + doc: LessonPlanDocument, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.AI_CHAT), + ]); + const result = await generateLessonPlanFeedback(doc); + return { success: true, data: result }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V5-21 A3:AI 差异化教学建议生成。 + * 根据课案内容生成基础/提高/拓展三个层次的教学建议。 + */ +export async function generateDifferentiationSuggestionsAction( + doc: LessonPlanDocument, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.AI_CHAT), + ]); + const items = await generateDifferentiationSuggestions(doc); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V5-21 A4:AI 课标实时核对。 + * 检查课案是否覆盖教材课标要求。 + */ +export async function checkCurriculumAlignmentAction( + doc: LessonPlanDocument, + knowledgePoints: { id: string; name: string }[], +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.AI_CHAT), + ]); + const items = await checkCurriculumAlignment(doc, knowledgePoints); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V5-21 A5:AI 可解释评估。 + * 对课案中的练习节点给出可解释的评分依据。 + */ +export async function generateExplainableAssessmentAction( + doc: LessonPlanDocument, +): Promise> { + try { + await Promise.all([ + requirePermission(Permissions.LESSON_PLAN_READ), + requirePermission(Permissions.AI_CHAT), + ]); + const items = await generateExplainableAssessment(doc); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * V5-21 A4 辅助:按教材 ID 获取知识点列表(仅 id + name), + * 供课标核对 Tab 按需加载,避免编辑页首屏负担。 + */ +export async function getKnowledgePointsForAlignmentAction( + textbookId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const kps = await getKnowledgePointsByTextbookId(textbookId); + return { + success: true, + data: { items: kps.map((kp) => ({ id: kp.id, name: kp.name })) }, + }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-schedules.ts b/src/modules/lesson-preparation/actions-schedules.ts new file mode 100644 index 0000000..d728ffd --- /dev/null +++ b/src/modules/lesson-preparation/actions-schedules.ts @@ -0,0 +1,87 @@ +/** + * V5-7:课案-课时绑定 Server Actions + */ +"use server"; + +import { getTranslations } from "next-intl/server"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + getSchedulesByPlanId, + createSchedule, + deleteSchedule, +} from "./data-access-schedules"; +import type { ActionState } from "@/shared/types/action-state"; +import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; +import { Permissions } from "@/shared/types/permissions"; + +const createScheduleSchema = z.object({ + planId: z.string().min(1, "error.planIdRequired"), + classId: z.string().min(1, "error.classIdRequired"), + scheduledDate: z.string().min(1, "error.dateRequired"), + period: z.number().int().min(1).max(12), + classScheduleId: z.string().optional(), + durationMin: z.number().int().min(5).max(180).optional(), +}); + +export async function getLessonPlanSchedulesAction( + planId: string, +): Promise> }>> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = await getSchedulesByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +export async function createLessonPlanScheduleAction( + input: Record, +): Promise> }>> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const t = await getTranslations("lessonPreparation"); + const parseResult = createScheduleSchema.safeParse(input); + if (!parseResult.success) { + return { + success: false, + message: t("error.invalidInput"), + errors: Object.fromEntries( + Object.entries(parseResult.error.flatten().fieldErrors), + ), + }; + } + + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: t("error.unauthorized") }; + } + + const schedule = await createSchedule({ + ...parseResult.data, + createdBy: auth.userId, + }); + + revalidatePath("/teacher/lesson-plans"); + revalidatePath("/teacher/lesson-plans/calendar"); + return { success: true, data: { schedule } }; + } catch (e) { + return handleActionError(e); + } +} + +export async function deleteLessonPlanScheduleAction( + scheduleId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteSchedule(scheduleId); + revalidatePath("/teacher/lesson-plans"); + revalidatePath("/teacher/lesson-plans/calendar"); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions.ts b/src/modules/lesson-preparation/actions.ts index 7af0e22..46daf27 100644 --- a/src/modules/lesson-preparation/actions.ts +++ b/src/modules/lesson-preparation/actions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { requirePermission } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; @@ -315,6 +316,19 @@ export async function duplicateLessonPlanAction( } } +// V5-12 R4:表单兼容版本(从 formData 提取 planId),用于校内课案库一键复制 +export async function duplicateLessonPlanFormAction( + formData: FormData, +): Promise { + const planId = String(formData.get("planId") ?? ""); + if (!planId) return; + const result = await duplicateLessonPlanAction(planId); + if (result.success && result.data) { + revalidatePath("/teacher/lesson-plans"); + redirect(`/teacher/lesson-plans/${result.data.newPlanId}/edit`); + } +} + // ---- 模板列表 ---- export async function getLessonPlanTemplatesAction(): Promise< ActionState<{ diff --git a/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx new file mode 100644 index 0000000..823d78b --- /dev/null +++ b/src/modules/lesson-preparation/components/ai-differentiation-dialog.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + X, + Layers, + Loader2, + AlertCircle, + Users, + CheckCircle2, + XCircle, + BookCheck, + ClipboardList, + GraduationCap, +} from "lucide-react"; +import { FocusTrap } from "@/shared/components/a11y/focus-trap"; +import { Button } from "@/shared/components/ui/button"; +import { + generateDifferentiationSuggestionsAction, + checkCurriculumAlignmentAction, + generateExplainableAssessmentAction, + getKnowledgePointsForAlignmentAction, +} from "../actions-ai"; +import type { LessonPlanDocument, DifferentiationLevel } from "../types"; +import type { + DifferentiationSuggestion, + CurriculumCheckItem, + ExplainableAssessment, +} from "../lib/ai-differentiation"; + +interface Props { + doc: LessonPlanDocument; + /** 课案所属教材 ID(用于 A4 课标核对按需加载知识点) */ + textbookId?: string; + onClose: () => void; +} + +type TabKey = "differentiation" | "curriculum" | "assessment"; + +const TABS: TabKey[] = ["differentiation", "curriculum", "assessment"]; + +/** + * V5-21 A3/A4/A5:AI 差异化对话框。 + * + * 三个 Tab 分别承载: + * - A3 差异化教学建议(基础/提高/拓展) + * - A4 课标实时核对(按 textbookId 按需加载知识点) + * - A5 可解释评估(练习节点的评分依据) + */ +export function AiDifferentiationDialog({ doc, textbookId, onClose }: Props) { + const t = useTranslations("lessonPreparation"); + const [activeTab, setActiveTab] = useState("differentiation"); + + // ESC 关闭 + useEffect(() => { + function handleEsc(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [onClose]); + + return ( +
+
+ +
+

+

+ +
+ + {/* Tab 切换 */} +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {activeTab === "differentiation" && ( + + )} + {activeTab === "curriculum" && ( + + )} + {activeTab === "assessment" && } +
+ +
+ +
+
+
+
+ ); +} + +/** A3:差异化教学建议 Tab */ +function DifferentiationTab({ + doc, + t, +}: { + doc: LessonPlanDocument; + t: ReturnType; +}) { + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const res = await generateDifferentiationSuggestionsAction(doc); + if (cancelled) return; + if (res.success && res.data) { + setItems(res.data.items); + } else { + setError(res.message ?? t("aiDifferentiation.loadFailed")); + } + } catch { + if (!cancelled) setError(t("aiDifferentiation.loadFailed")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [doc, t]); + + if (loading) return ; + if (error) return ; + if (items.length === 0) return ; + + // 按层次排序:basic → intermediate → advanced + const order: DifferentiationLevel[] = ["basic", "intermediate", "advanced"]; + const sorted = [...items].sort( + (a, b) => order.indexOf(a.level) - order.indexOf(b.level), + ); + + return ( +
+ {sorted.map((item) => ( +
+

+

+
    + {item.suggestions.map((s, idx) => ( +
  • + · + {s} +
  • + ))} +
+
+ ))} +
+ ); +} + +/** A4:课标实时核对 Tab(按 textbookId 按需加载知识点) */ +function CurriculumTab({ + doc, + textbookId, + t, +}: { + doc: LessonPlanDocument; + textbookId?: string; + t: ReturnType; +}) { + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + // 无教材 ID 时无法获取知识点 + if (!textbookId) { + setLoading(false); + setError(t("aiDifferentiation.noKnowledgePoints")); + return; + } + setLoading(true); + setError(null); + try { + // 先按需加载知识点列表 + const kpRes = await getKnowledgePointsForAlignmentAction(textbookId); + if (cancelled) return; + if (!kpRes.success || !kpRes.data || kpRes.data.items.length === 0) { + setError(t("aiDifferentiation.noKnowledgePoints")); + return; + } + // 再调用课标核对 + const res = await checkCurriculumAlignmentAction(doc, kpRes.data.items); + if (cancelled) return; + if (res.success && res.data) { + setItems(res.data.items); + } else { + setError(res.message ?? t("aiDifferentiation.loadFailed")); + } + } catch { + if (!cancelled) setError(t("aiDifferentiation.loadFailed")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [doc, textbookId, t]); + + if (loading) return ; + if (error) return ; + if (items.length === 0) return ; + + const covered = items.filter((i) => i.covered).length; + const missed = items.length - covered; + + return ( +
+
+ + + + +
+
    + {items.map((item, idx) => ( +
  • +
    + {item.covered ? ( +
    + {item.explanation && ( +

    {item.explanation}

    + )} +
  • + ))} +
+
+ ); +} + +/** A5:可解释评估 Tab */ +function AssessmentTab({ + doc, + t, +}: { + doc: LessonPlanDocument; + t: ReturnType; +}) { + const [loading, setLoading] = useState(true); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const res = await generateExplainableAssessmentAction(doc); + if (cancelled) return; + if (res.success && res.data) { + setItems(res.data.items); + } else { + setError(res.message ?? t("aiDifferentiation.loadFailed")); + } + } catch { + if (!cancelled) setError(t("aiDifferentiation.loadFailed")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [doc, t]); + + if (loading) return ; + if (error) return ; + if (items.length === 0) return ; + + return ( +
    + {items.map((item, idx) => ( +
  • +
    +
    + {item.rationale && ( +

    +

    + )} + {item.suggestion && ( +

    +

    + )} +
  • + ))} +
+ ); +} + +function LoadingBlock({ label }: { label: string }) { + return ( +
+
+ ); +} + +function ErrorBlock({ message }: { message: string }) { + return ( +
+
+ ); +} + +function EmptyBlock({ label }: { label: string }) { + return
{label}
; +} diff --git a/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx new file mode 100644 index 0000000..09cf89e --- /dev/null +++ b/src/modules/lesson-preparation/components/ai-feedback-dialog.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { X, Sparkles, Loader2, AlertCircle, Lightbulb, CheckCircle2, Target, Users } from "lucide-react"; +import { FocusTrap } from "@/shared/components/a11y/focus-trap"; +import { Button } from "@/shared/components/ui/button"; +import { generateLessonPlanFeedbackAction } from "../actions-ai"; +import type { AiFeedbackResult, AiFeedbackItem } from "../lib/ai-feedback"; +import type { LessonPlanDocument } from "../types"; + +interface Props { + doc: LessonPlanDocument; + onClose: () => void; +} + +/** V5-17 A1/A2:AI 反馈闭环对话框(含解释性展示) */ +export function AiFeedbackDialog({ doc, onClose }: Props) { + const t = useTranslations("lessonPreparation"); + const [loading, setLoading] = useState(true); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const res = await generateLessonPlanFeedbackAction(doc); + if (cancelled) return; + if (res.success && res.data) { + setResult(res.data); + } else { + setError(res.message ?? t("feedback.loadFailed")); + } + } catch { + if (!cancelled) setError(t("feedback.loadFailed")); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [doc, t]); + + // ESC 关闭 + useEffect(() => { + function handleEsc(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [onClose]); + + return ( +
+
+ +
+

+

+ +
+ +
+ {loading ? ( +
+
+ ) : error ? ( +
+
+ ) : result ? ( + + ) : null} +
+ +
+ +
+
+
+
+ ); +} + +function FeedbackContent({ + result, + t, +}: { + result: AiFeedbackResult; + t: ReturnType; +}) { + // 按维度分组 + const grouped: Record = { + strengths: [], + improvements: [], + alignment: [], + differentiation: [], + }; + for (const item of result.items) { + grouped[item.category].push(item); + } + + return ( +
+ {/* 摘要 + 评分 */} +
+
+

{t("feedback.summary")}

+

{result.summary}

+
+
+
{result.overallScore}
+
{t("feedback.score")}
+
+
+ + {/* 各维度反馈 */} + {(Object.keys(grouped) as AiFeedbackItem["category"][]).map((cat) => { + const items = grouped[cat]; + if (items.length === 0) return null; + return ( +
+

+ + {t(`feedback.category.${cat}`)} + ({items.length}) +

+
    + {items.map((item, idx) => ( +
  • +
    {item.title}
    + {/* A2:解释性展示 — reason 字段说明 AI 判断依据 */} + {item.reason && ( +
    +
    + )} +
  • + ))} +
+
+ ); + })} + + {result.items.length === 0 && ( +
+ {t("feedback.empty")} +
+ )} +
+ ); +} + +function CategoryIcon({ category }: { category: AiFeedbackItem["category"] }) { + const icon = { + strengths: