diff --git a/src/modules/lesson-preparation/actions-ai-evaluation.ts b/src/modules/lesson-preparation/actions-ai-evaluation.ts new file mode 100644 index 0000000..fe22112 --- /dev/null +++ b/src/modules/lesson-preparation/actions-ai-evaluation.ts @@ -0,0 +1,120 @@ +/** + * M8 AI 评估 - Server Actions + */ +"use server"; + +import { getTranslations } from "next-intl/server"; +import { z } from "zod"; +import { + getEvaluationsByPlanId, + getLatestEvaluation, + createEvaluation, + deleteEvaluation, + evaluateDocument, +} from "./data-access-ai-evaluation"; +import type { AiEvaluation } from "./data-access-ai-evaluation"; +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"; +import { getLessonPlanById } from "./data-access"; +import { getStandardsByPlanId } from "../standards/data-access"; +import { normalizeDocument } from "./lib/document-migration"; + +const createEvaluationSchema = z.object({ + planId: z.string().min(1), + versionNo: z.number().int().positive(), +}); + +/** + * 查询课案的所有 AI 评估 + */ +export async function getLessonPlanEvaluationsAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = await getEvaluationsByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询最新评估 + */ +export async function getLatestEvaluationAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const evaluation = await getLatestEvaluation(planId); + return { success: true, data: { evaluation } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 触发 AI 评估(当前为本地规则评估,未来可替换为远程 AI API) + */ +export async function evaluateLessonPlanAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const t = await getTranslations("lessonPreparation"); + const parseResult = createEvaluationSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: t("error.invalidInput") }; + } + + const { planId, versionNo } = parseResult.data; + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: t("error.unauthorized") }; + } + + const plan = await getLessonPlanById(planId, auth.userId); + if (!plan) { + return { success: false, message: t("error.notFound") }; + } + + const doc = normalizeDocument(plan.content as unknown); + const standards = await getStandardsByPlanId(planId); + const result = evaluateDocument(doc, { + standardsLinkedCount: standards.length, + hasInteractiveItems: false, // TODO: 查询 M5 formative items + }); + + const evaluation = await createEvaluation( + { + planId, + versionNo, + overallScore: result.overallScore, + dimensionScores: result.dimensionScores, + suggestions: result.suggestions, + }, + auth.userId, + ); + return { success: true, data: { evaluation } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 删除评估 + */ +export async function deleteEvaluationAction( + id: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteEvaluation(id); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-ai.ts b/src/modules/lesson-preparation/actions-ai.ts index ab7c511..27b1504 100644 --- a/src/modules/lesson-preparation/actions-ai.ts +++ b/src/modules/lesson-preparation/actions-ai.ts @@ -1,10 +1,11 @@ "use server"; -import { getTranslations } from "next-intl/server"; -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; import { Permissions } from "@/shared/types/permissions"; import { suggestKnowledgePoints } from "./ai-suggest"; import { suggestKnowledgePointsSchema } from "./schema"; +import { translateFieldErrors } from "./lib/i18n-errors"; import type { ActionState } from "@/shared/types/action-state"; export async function suggestKnowledgePointsAction(input: { @@ -16,11 +17,11 @@ export async function suggestKnowledgePointsAction(input: { suggestions: { id: string; name: string; reason: string }[]; }> > { - const t = await getTranslations("lessonPreparation"); try { const parsed = suggestKnowledgePointsSchema.safeParse(input); if (!parsed.success) { - return { success: false, errors: parsed.error.flatten().fieldErrors }; + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; } // 并行校验两个权限点 @@ -29,9 +30,10 @@ export async function suggestKnowledgePointsAction(input: { requirePermission(Permissions.AI_CHAT), ]); - // 从 unknown 安全提取 nodes 数组:Zod 已校验 doc 是对象 + // P1 修复:从 unknown 安全提取 nodes 数组,显式类型标注替代隐式 any + // Zod 已校验 doc 是 Record,但 nodes 字段需运行时检查 const doc = parsed.data.doc; - const nodes = Array.isArray(doc.nodes) ? doc.nodes : []; + const nodes: unknown[] = Array.isArray(doc.nodes) ? doc.nodes : []; const suggestions = await suggestKnowledgePoints( { nodes }, parsed.data.textbookId, @@ -39,8 +41,7 @@ export async function suggestKnowledgePointsAction(input: { ); return { success: true, data: { suggestions } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.aiSuggest") }; + // P1 修复:统一使用 handleActionError 处理错误,避免手动判断 PermissionDeniedError + return handleActionError(e); } } diff --git a/src/modules/lesson-preparation/actions-analytics.ts b/src/modules/lesson-preparation/actions-analytics.ts new file mode 100644 index 0000000..1048041 --- /dev/null +++ b/src/modules/lesson-preparation/actions-analytics.ts @@ -0,0 +1,97 @@ +/** + * M10 备课分析仪表盘 - Server Actions + */ +"use server"; + +import { z } from "zod"; +import { + getTeacherInvestment, + getTemplateUsageStats, + getStandardsCoverageHeatmap, + getGlobalLessonPlanStats, +} from "./data-access-analytics"; +import type { + TeacherInvestmentDataPoint, + TemplateUsageDataPoint, + StandardsCoverageCell, +} from "./data-access-analytics"; +import type { ActionState } from "@/shared/types/action-state"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; + +const dateSchema = z.string().refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date"); + +const queryInvestmentSchema = z.object({ + startDate: dateSchema, + endDate: dateSchema, + teacherId: z.string().optional(), +}); + +/** + * 查询教师备课投入 + */ +export async function getTeacherInvestmentAction( + input: Record, +): Promise> { + try { + // M10 备课分析仪表盘需要管理员权限 + await requirePermission("lesson_plan:read"); + const parseResult = queryInvestmentSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + const items = await getTeacherInvestment( + new Date(parseResult.data.startDate), + new Date(parseResult.data.endDate), + parseResult.data.teacherId, + ); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询模板使用率 + */ +export async function getTemplateUsageStatsAction(): Promise< + ActionState<{ items: TemplateUsageDataPoint[] }> +> { + try { + await requirePermission("lesson_plan:read"); + const items = await getTemplateUsageStats(); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询课标覆盖热力图 + */ +export async function getStandardsCoverageHeatmapAction(): Promise< + ActionState<{ items: StandardsCoverageCell[] }> +> { + try { + await requirePermission("lesson_plan:read"); + const items = await getStandardsCoverageHeatmap(); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询全局备课统计 + */ +export async function getGlobalLessonPlanStatsAction(): Promise< + ActionState<{ stats: Awaited> }> +> { + try { + await requirePermission("lesson_plan:read"); + const stats = await getGlobalLessonPlanStats(); + return { success: true, data: { stats } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-attachments.ts b/src/modules/lesson-preparation/actions-attachments.ts new file mode 100644 index 0000000..45e2c28 --- /dev/null +++ b/src/modules/lesson-preparation/actions-attachments.ts @@ -0,0 +1,125 @@ +/** + * M6 资源附件库 - Server Actions + */ +"use server"; + +import { getTranslations } from "next-intl/server"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + getAttachmentsByPlanId, + getAttachmentsByBlockId, + createAttachment, + deleteAttachment, + updateAttachmentType, + getAttachmentById, +} from "./data-access-attachments"; +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 createAttachmentSchema = z.object({ + planId: z.string().min(1, "error.planIdRequired"), + blockId: z.string().optional(), + fileId: z.string().min(1, "error.fileIdRequired"), + displayName: z.string().min(1, "error.displayNameRequired").max(255, "error.displayNameTooLong"), + attachmentType: z.enum(["reference", "material", "supplementary"]).default("reference"), +}); + +const updateAttachmentTypeSchema = z.object({ + attachmentId: z.string().min(1, "error.attachmentIdRequired"), + attachmentType: z.enum(["reference", "material", "supplementary"]), +}); + +/** + * 查询课案附件列表 + */ +export async function getLessonPlanAttachmentsAction( + planId: string, + blockId?: string, +): Promise< + ActionState<{ items: Awaited> }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = blockId + ? await getAttachmentsByBlockId(planId, blockId) + : await getAttachmentsByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 添加附件 + */ +export async function createLessonPlanAttachmentAction( + input: Record, +): Promise< + ActionState<{ attachment: Awaited> }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const t = await getTranslations("lessonPreparation"); + const parseResult = createAttachmentSchema.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") }; + } + await createAttachment(parseResult.data, auth.userId); + const attachment = await getAttachmentById(parseResult.data.planId); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { attachment } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 删除附件 + */ +export async function deleteLessonPlanAttachmentAction( + attachmentId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteAttachment(attachmentId); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 更新附件类型 + */ +export async function updateAttachmentTypeAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const parseResult = updateAttachmentTypeSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + await updateAttachmentType( + parseResult.data.attachmentId, + parseResult.data.attachmentType, + ); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-calendar.ts b/src/modules/lesson-preparation/actions-calendar.ts new file mode 100644 index 0000000..45b2858 --- /dev/null +++ b/src/modules/lesson-preparation/actions-calendar.ts @@ -0,0 +1,49 @@ +/** + * M9 日历视图 - Server Actions + */ +"use server"; + +import { z } from "zod"; +import { getCalendarEvents, groupEventsByDate } from "./data-access-calendar"; +import type { LessonPlanCalendarEvent } from "./data-access-calendar"; +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 getCalendarEventsSchema = z.object({ + startDate: z.string().refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date"), + endDate: z.string().refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date"), +}); + +/** + * 查询当前教师的备课日历事件 + */ +export async function getCalendarEventsAction( + input: Record, +): Promise }>> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const parseResult = getCalendarEventsSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const events = await getCalendarEvents( + auth.userId, + new Date(parseResult.data.startDate), + new Date(parseResult.data.endDate), + ); + const groupedMap = groupEventsByDate(events); + const grouped: Record = {}; + for (const [key, value] of groupedMap.entries()) { + grouped[key] = value; + } + return { success: true, data: { events, grouped } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-comments.ts b/src/modules/lesson-preparation/actions-comments.ts new file mode 100644 index 0000000..9aaf7a6 --- /dev/null +++ b/src/modules/lesson-preparation/actions-comments.ts @@ -0,0 +1,145 @@ +/** + * M2 协同备课 - 评论 Server Actions + */ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + getCommentsByPlanId, + getCommentsByBlockId, + createComment, + updateCommentContent, + toggleCommentResolved, + deleteComment, + countUnresolvedComments, +} from "./data-access-comments"; +import type { LessonPlanComment } from "./data-access-comments"; +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 createCommentSchema = z.object({ + planId: z.string().min(1, "error.planIdRequired"), + blockId: z.string().min(1, "error.blockIdRequired"), + content: z.string().min(1, "error.contentRequired").max(2000, "error.contentTooLong"), + parentCommentId: z.string().optional(), +}); + +const updateCommentSchema = z.object({ + commentId: z.string().min(1, "error.commentIdRequired"), + content: z.string().min(1, "error.contentRequired").max(2000, "error.contentTooLong"), +}); + +/** + * 查询课案评论 + */ +export async function getLessonPlanCommentsAction( + planId: string, + blockId?: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = blockId + ? await getCommentsByBlockId(planId, blockId) + : await getCommentsByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 创建评论 + */ +export async function createLessonPlanCommentAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const parseResult = createCommentSchema.safeParse(input); + if (!parseResult.success) { + return { + success: false, + message: "Invalid input", + errors: Object.fromEntries( + Object.entries(parseResult.error.flatten().fieldErrors), + ), + }; + } + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const comment = await createComment(parseResult.data, auth.userId); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { comment } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 更新评论 + */ +export async function updateLessonPlanCommentAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const parseResult = updateCommentSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + await updateCommentContent(parseResult.data.commentId, parseResult.data.content); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 切换评论解决状态 + */ +export async function toggleCommentResolvedAction( + commentId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await toggleCommentResolved(commentId); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 删除评论 + */ +export async function deleteLessonPlanCommentAction( + commentId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteComment(commentId); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 统计未解决评论数(用于列表卡片徽章) + */ +export async function getUnresolvedCommentCountAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const count = await countUnresolvedComments(planId); + return { success: true, data: { count } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-formative.ts b/src/modules/lesson-preparation/actions-formative.ts new file mode 100644 index 0000000..597fec3 --- /dev/null +++ b/src/modules/lesson-preparation/actions-formative.ts @@ -0,0 +1,158 @@ +/** + * M5 形成性评价闭环 - Server Actions + */ +"use server"; + +import { z } from "zod"; +import { revalidatePath } from "next/cache"; +import { + getFormativeItemsByPlanId, + createFormativeItem, + updateFormativeItem, + deleteFormativeItem, + submitFormativeResponse, + getResponsesByItemId, + getFormativeItemStats, +} from "./data-access-formative"; +import type { FormativeItem, FormativeResponse } from "./data-access-formative"; +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 createFormativeItemSchema = z.object({ + planId: z.string().min(1), + blockId: z.string().min(1), + interactionType: z.enum(["poll", "quiz", "exit_ticket"]), + payload: z.record(z.string(), z.unknown()), + instantFeedback: z.boolean().optional(), + orderIndex: z.number().int().min(0).optional(), +}); + +const submitResponseSchema = z.object({ + itemId: z.string().min(1), + classId: z.string().optional(), + response: z.record(z.string(), z.unknown()), + isCorrect: z.boolean().optional(), + durationSec: z.number().int().min(0).optional(), +}); + +/** + * 查询课案互动组件 + */ +export async function getFormativeItemsAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = await getFormativeItemsByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 创建互动组件 + */ +export async function createFormativeItemAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const parseResult = createFormativeItemSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + const item = await createFormativeItem(parseResult.data); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { item } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 更新互动组件 + */ +export async function updateFormativeItemAction( + id: string, + patch: { payload?: unknown; instantFeedback?: boolean; orderIndex?: number }, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await updateFormativeItem(id, patch); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 删除互动组件 + */ +export async function deleteFormativeItemAction(id: string): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteFormativeItem(id); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 学生提交作答 + */ +export async function submitFormativeResponseAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const parseResult = submitResponseSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const response = await submitFormativeResponse({ + ...parseResult.data, + studentId: auth.userId, + }); + return { success: true, data: { response } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询互动组件作答列表(教师查看) + */ +export async function getFormativeResponsesAction( + itemId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const responses = await getResponsesByItemId(itemId); + return { success: true, data: { responses } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询互动组件统计(教师实时反馈) + */ +export async function getFormativeItemStatsAction( + itemId: string, +): Promise> }>> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const stats = await getFormativeItemStats(itemId); + return { success: true, data: { stats } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-kp.ts b/src/modules/lesson-preparation/actions-kp.ts index 3b5b8c5..f30cb55 100644 --- a/src/modules/lesson-preparation/actions-kp.ts +++ b/src/modules/lesson-preparation/actions-kp.ts @@ -1,33 +1,38 @@ "use server"; -import { getTranslations } from "next-intl/server"; -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; import { Permissions } from "@/shared/types/permissions"; import { getKnowledgePointsByTextbookId, getKnowledgePointsByChapterId, } from "@/modules/textbooks/data-access"; import { getKnowledgePointOptionsSchema } from "./schema"; +import { translateFieldErrors } from "./lib/i18n-errors"; import type { ActionState } from "./types"; +// 知识点选项类型(P1 修复:显式类型标注替代隐式 any) +type KnowledgePointOption = { id: string; name: string }; + // 加载知识点选项(供客户端知识点选择器使用) export async function getKnowledgePointOptionsAction(input: { textbookId?: string; chapterId?: string; }): Promise< - ActionState<{ options: { id: string; name: string }[] }> + ActionState<{ options: KnowledgePointOption[] }> > { - const t = await getTranslations("lessonPreparation"); try { const parsed = getKnowledgePointOptionsSchema.safeParse(input); if (!parsed.success) { - return { success: false, errors: parsed.error.flatten().fieldErrors }; + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; } await requirePermission(Permissions.LESSON_PLAN_READ); if (!parsed.data.textbookId) return { success: true, data: { options: [] } }; - let kps; + // P1 修复:显式类型标注替代 `let kps;` 隐式 any + let kps: KnowledgePointOption[]; if (parsed.data.chapterId) { kps = await getKnowledgePointsByChapterId(parsed.data.chapterId); } else { @@ -40,8 +45,7 @@ export async function getKnowledgePointOptionsAction(input: { }, }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.loadKnowledgePoints") }; + // P1 修复:统一使用 handleActionError 处理错误 + return handleActionError(e); } } diff --git a/src/modules/lesson-preparation/actions-publish.ts b/src/modules/lesson-preparation/actions-publish.ts index db4294c..f0652d2 100644 --- a/src/modules/lesson-preparation/actions-publish.ts +++ b/src/modules/lesson-preparation/actions-publish.ts @@ -7,6 +7,7 @@ import { } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; import { handleActionError, safeParseDate } from "@/shared/lib/action-utils"; +import { getLessonPlanById } from "./data-access"; import { publishLessonPlanHomework, PublishServiceError } from "./publish-service"; import { publishLessonPlanHomeworkSchema } from "./schema"; import { translateFieldErrors } from "./lib/i18n-errors"; @@ -31,8 +32,14 @@ export async function publishLessonPlanHomeworkAction(input: { Permissions.LESSON_PLAN_PUBLISH, ); await requirePermission(Permissions.HOMEWORK_CREATE); - // V3 修复:作业标题/描述由 actions 层 i18n 翻译后传入,避免 service 层硬编码中文 - const homeworkTitle = t("publish.homeworkTitle", { title: parsed.data.planId }); + + // P0-6 修复:先查询课案标题,用于作业标题的 i18n 翻译 + // (原代码错误地传入 planId 作为 title 参数) + const plan = await getLessonPlanById(parsed.data.planId, ctx.userId); + if (!plan) { + return { success: false, message: t("error.notFound") }; + } + const homeworkTitle = t("publish.homeworkTitle", { title: plan.title }); const homeworkDescription = t("publish.homeworkDescription"); const availableAtLabel = t("publish.availableAtLabel"); const dueAtLabel = t("publish.dueAtLabel"); diff --git a/src/modules/lesson-preparation/actions-questions.ts b/src/modules/lesson-preparation/actions-questions.ts new file mode 100644 index 0000000..3dc1f0c --- /dev/null +++ b/src/modules/lesson-preparation/actions-questions.ts @@ -0,0 +1,32 @@ +"use server"; + +import { requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; +import { Permissions } from "@/shared/types/permissions"; +import { fetchExternalQuestions } from "./services/external-questions-bridge"; +import type { ActionState } from "./types"; +import type { QuestionPickerItem, QuestionPickerParams } from "./providers/lesson-plan-provider"; + +/** + * 题库查询 Server Action(V4 P0-4 修复)。 + * + * question-bank-picker 不再直接调用 questions 模块的 Server Action, + * 而是通过本 Action 调用 `external-questions-bridge`(data-access → data-access 合规)。 + * + * 权限:备课模块的 LESSON_PLAN_READ 即可访问题库 picker, + * 题库本身的细粒度权限由 questions/data-access 在返回数据时隐式遵守(仅返回公开题库)。 + */ +export async function getQuestionsForPickerAction( + params: QuestionPickerParams, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const res = await fetchExternalQuestions(params); + if (res.success && res.data) { + return { success: true, data: res.data }; + } + return { success: false, message: res.message }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-review.ts b/src/modules/lesson-preparation/actions-review.ts new file mode 100644 index 0000000..3f6071a --- /dev/null +++ b/src/modules/lesson-preparation/actions-review.ts @@ -0,0 +1,128 @@ +/** + * M3 审核工作流 - Server Actions + */ +"use server"; + +import { getTranslations } from "next-intl/server"; +import { revalidatePath } from "next/cache"; +import { + submitLessonPlanForReviewSchema, + reviewLessonPlanSchema, +} from "./schema"; +import { + submitForReview, + reviewPlan, + getReviewRecordsByPlanId, + getPendingReviewPlans, + withdrawSubmission, +} from "./data-access-review"; +import type { LessonPlanReviewRecord } from "./data-access-review"; +import type { ActionState } from "@/shared/types/action-state"; +import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard"; +import { handleActionError } from "@/shared/lib/action-utils"; +import { safeParseWithI18n } from "./lib/i18n-errors"; +import { Permissions } from "@/shared/types/permissions"; + +/** + * 教师提交课案审核 + */ +export async function submitLessonPlanForReviewAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const t = await getTranslations("lessonPreparation"); + const parseResult = await safeParseWithI18n(submitLessonPlanForReviewSchema, input); + if (!parseResult.success) return parseResult; + + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: t("error.unauthorized") }; + } + const status = await submitForReview(parseResult.data.planId, auth.userId); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { status } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 教研组长审核课案 + */ +export async function reviewLessonPlanAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_PUBLISH); // 暂复用 publish 权限 + const t = await getTranslations("lessonPreparation"); + const parseResult = await safeParseWithI18n(reviewLessonPlanSchema, input); + if (!parseResult.success) return parseResult; + + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: t("error.unauthorized") }; + } + + const { newStatus, record } = await reviewPlan( + parseResult.data.planId, + auth.userId, + parseResult.data.decision, + parseResult.data.reviewComment, + ); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { newStatus, record } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询课案审核记录 + */ +export async function getLessonPlanReviewRecordsAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const records = await getReviewRecordsByPlanId(planId); + return { success: true, data: { records } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询待审核队列 + */ +export async function getPendingReviewPlansAction(): Promise< + ActionState<{ items: Awaited> }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_PUBLISH); + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const items = await getPendingReviewPlans(); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 撤回提交 + */ +export async function withdrawSubmissionAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const status = await withdrawSubmission(planId); + revalidatePath(`/teacher/lesson-plans/${planId}/edit`); + return { success: true, data: { status } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions-substitutes.ts b/src/modules/lesson-preparation/actions-substitutes.ts new file mode 100644 index 0000000..c2fd8a0 --- /dev/null +++ b/src/modules/lesson-preparation/actions-substitutes.ts @@ -0,0 +1,166 @@ +/** + * M12 代课教师机制 - Server Actions + */ +"use server"; + +import { getTranslations } from "next-intl/server"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { + getSubstitutesByPlanId, + getActiveSubstitutesByTeacherId, + createSubstitute, + updateSubstituteStatus, + deleteSubstitute, + canTeacherAccessPlan, +} from "./data-access-substitutes"; +import type { LessonPlanSubstitute } from "./data-access-substitutes"; +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 createSubstituteSchema = z.object({ + planId: z.string().min(1, "error.planIdRequired"), + originalTeacherId: z.string().min(1, "error.teacherIdRequired"), + substituteTeacherId: z.string().min(1, "error.teacherIdRequired"), + startDate: z.string().refine((v) => !Number.isNaN(new Date(v).getTime()), "error.invalidDate"), + endDate: z.string().refine((v) => !Number.isNaN(new Date(v).getTime()), "error.invalidDate").optional(), + reason: z.string().max(255, "error.reasonTooLong").optional(), +}); + +const updateSubstituteStatusSchema = z.object({ + substituteId: z.string().min(1, "error.substituteIdRequired"), + status: z.enum(["active", "expired", "cancelled"]), +}); + +/** + * 查询课案代课教师列表 + */ +export async function getLessonPlanSubstitutesAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const items = await getSubstitutesByPlanId(planId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 查询当前教师的代课任务 + */ +export async function getMySubstitutesAction(): Promise< + ActionState<{ items: LessonPlanSubstitute[] }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const items = await getActiveSubstitutesByTeacherId(auth.userId); + return { success: true, data: { items } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 创建代课教师映射 + */ +export async function createSubstituteAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const t = await getTranslations("lessonPreparation"); + const parseResult = createSubstituteSchema.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 substitute = await createSubstitute( + { + planId: parseResult.data.planId, + originalTeacherId: parseResult.data.originalTeacherId, + substituteTeacherId: parseResult.data.substituteTeacherId, + startDate: new Date(parseResult.data.startDate), + endDate: parseResult.data.endDate + ? new Date(parseResult.data.endDate) + : undefined, + reason: parseResult.data.reason, + }, + auth.userId, + ); + revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`); + return { success: true, data: { substitute } }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 更新代课教师状态 + */ +export async function updateSubstituteStatusAction( + input: Record, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + const parseResult = updateSubstituteStatusSchema.safeParse(input); + if (!parseResult.success) { + return { success: false, message: "Invalid input" }; + } + await updateSubstituteStatus(parseResult.data.substituteId, parseResult.data.status); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 删除代课教师映射 + */ +export async function deleteSubstituteAction( + substituteId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_UPDATE); + await deleteSubstitute(substituteId); + return { success: true, data: null }; + } catch (e) { + return handleActionError(e); + } +} + +/** + * 检查当前教师对某课案的访问权限(用于路由层校验) + */ +export async function canAccessPlanAction( + planId: string, +): Promise> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const auth = await getAuthContext(); + if (!auth.userId) { + return { success: false, message: "Unauthorized" }; + } + const canAccess = await canTeacherAccessPlan(planId, auth.userId); + return { success: true, data: { canAccess } }; + } catch (e) { + return handleActionError(e); + } +} diff --git a/src/modules/lesson-preparation/actions.ts b/src/modules/lesson-preparation/actions.ts index df9b6c6..7af0e22 100644 --- a/src/modules/lesson-preparation/actions.ts +++ b/src/modules/lesson-preparation/actions.ts @@ -17,6 +17,7 @@ import { getTextbooksForPicker, getChaptersForPicker, LessonPlanDataError, + normalizeDocument, } from "./data-access"; import { getLessonPlanVersions, @@ -35,11 +36,13 @@ import { saveVersionSchema, revertVersionSchema, saveAsTemplateSchema, + getLessonPlansParamsSchema, } from "./schema"; import { translateFieldErrors } from "./lib/i18n-errors"; import type { ActionState, LessonPlan, LessonPlanDocument } from "./types"; // ---- 课案列表 ---- +// P0-7 修复:params 使用 Zod 验证,防止畸形输入和 LIKE 注入 export async function getLessonPlansAction(params: { query?: string; textbookId?: string; @@ -53,7 +56,12 @@ export async function getLessonPlansAction(params: { > { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); - const items = await getLessonPlans(params, ctx.dataScope, ctx.userId); + const parsed = getLessonPlansParamsSchema.safeParse(params); + if (!parsed.success) { + const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); + return { success: false, errors }; + } + const items = await getLessonPlans(parsed.data, ctx.dataScope, ctx.userId); return { success: true, data: { items } }; } catch (e) { return handleActionError(e); @@ -138,10 +146,12 @@ export async function updateLessonPlanAction(input: { const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); return { success: false, errors }; } + // P1 修复:Zod 已校验 content 是 Record, + // 通过 normalizeDocument 安全转换(内部使用类型守卫),替代 as unknown as 断言 + const content = normalizeDocument(parsed.data.content); await updateLessonPlanContent(parsed.data.planId, ctx.userId, { ...(parsed.data.title ? { title: parsed.data.title } : {}), - // 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫 - content: parsed.data.content as unknown as LessonPlanDocument, + content, }); revalidatePath("/teacher/lesson-plans"); return { success: true }; @@ -151,6 +161,7 @@ export async function updateLessonPlanAction(input: { } // ---- 手动保存版本 ---- +// P0-5 修复:saveVersionSchema 现在包含 content 字段校验 export async function saveLessonPlanVersionAction(input: { planId: string; content: LessonPlanDocument; @@ -163,15 +174,22 @@ export async function saveLessonPlanVersionAction(input: { const errors = await translateFieldErrors(parsed.error.flatten().fieldErrors); return { success: false, errors }; } - const { versionNo } = await createLessonPlanVersion({ + // P0-5 修复:使用 Zod 验证后的 content,通过 normalizeDocument 安全转换为 LessonPlanDocument + const content = normalizeDocument(parsed.data.content); + const version = await createLessonPlanVersion({ planId: parsed.data.planId, - content: input.content, + content, userId: ctx.userId, isAuto: false, label: parsed.data.label, }); - await pruneAutoVersions(parsed.data.planId); - return { success: true, data: { versionNo } }; + if (!version) { + // V4 P1-16 修复:原始字符串改为 i18n 键 + const t = await getTranslations("lessonPreparation"); + return { success: false, message: t("error.notFound") }; + } + await pruneAutoVersions(parsed.data.planId, ctx.userId); + return { success: true, data: { versionNo: version.versionNo } }; } catch (e) { return handleActionError(e); } @@ -371,14 +389,8 @@ export async function getChaptersForPickerAction( textbookId: string, ): Promise< ActionState<{ - chapters: { - id: string; - title: string; - parentId: string | null; - order: number | null; - content?: string | null; - children?: unknown[]; - }[]; + // P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[] + chapters: import("./providers/lesson-plan-provider").ChapterPickerOption[]; }> > { try { diff --git a/src/modules/lesson-preparation/ai-suggest.ts b/src/modules/lesson-preparation/ai-suggest.ts index c49073a..d41b8f8 100644 --- a/src/modules/lesson-preparation/ai-suggest.ts +++ b/src/modules/lesson-preparation/ai-suggest.ts @@ -1,8 +1,8 @@ import "server-only"; - import { z } from "zod"; import { env } from "@/env.mjs"; import { createAiChatCompletion } from "@/shared/lib/ai"; +import { isRecord } from "@/shared/lib/type-guards"; import { getKnowledgePointsByTextbookId, getKnowledgePointsByChapterId, @@ -18,18 +18,23 @@ const SuggestedKpListSchema = z.array(SuggestedKpSchema); /** 从 unknown 节点安全提取文本(类型守卫从 unknown 收窄) */ const extractNodeText = (node: unknown): string => { - if (!node || typeof node !== "object") return "" - // 从 unknown 收窄为 Record 以进行字段检查 - const record = node as Record - const data = record.data - if (!data || typeof data !== "object") return "" - // 从 unknown 收窄为 Record 以进行字段检查 - const dataRecord = data as Record - const html = typeof dataRecord.html === "string" ? dataRecord.html : "" - const sourceText = typeof dataRecord.sourceText === "string" ? dataRecord.sourceText : "" + if (!isRecord(node)) return "" + const data = node.data + if (!isRecord(data)) return "" + const html = typeof data.html === "string" ? data.html : "" + const sourceText = typeof data.sourceText === "string" ? data.sourceText : "" return html || sourceText || "" } +// P2 修复:AI prompt 提取为模块常量,便于维护和未来国际化 +// 注:AI prompt 属于系统级提示词,非用户可见文本,暂不纳入 next-intl i18n 体系 +const AI_SUGGEST_PROMPT_TEMPLATE = `你是教学设计助手。以下是教师备课内容: +--- +{text} +--- +请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。 +候选知识点:{kpList}`; + export async function suggestKnowledgePoints( doc: { nodes: unknown[] }, textbookId?: string, @@ -52,13 +57,10 @@ export async function suggestKnowledgePoints( const kpList = allKps.map((kp) => ({ id: kp.id, name: kp.name })).slice(0, 100); - // 3. 调用 AI - const prompt = `你是教学设计助手。以下是教师备课内容: ---- -${text} ---- -请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。 -候选知识点:${JSON.stringify(kpList)}`; + // 3. 调用 AI(使用模板构建 prompt) + const prompt = AI_SUGGEST_PROMPT_TEMPLATE + .replace("{text}", text) + .replace("{kpList}", JSON.stringify(kpList)); const { content } = await createAiChatCompletion({ messages: [{ role: "user", content: prompt }], diff --git a/src/modules/lesson-preparation/components/block-renderer.tsx b/src/modules/lesson-preparation/components/block-renderer.tsx deleted file mode 100644 index 1075234..0000000 --- a/src/modules/lesson-preparation/components/block-renderer.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -/** - * @deprecated 已被 NodeEditor 替代,保留此文件用于向后兼容。 - * 列表式渲染器,使用新的 nodes API。 - */ -import { - DndContext, - closestCenter, - type DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - verticalListSortingStrategy, - useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - GripVertical, - Trash2, - ChevronUp, - ChevronDown, -} from "lucide-react"; -import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; -import { RICH_TEXT_BLOCK_TYPES } from "../constants"; -import { RichTextBlock } from "./blocks/rich-text-block"; -import { ExerciseBlock } from "./blocks/exercise-block"; -import { TextStudyBlock } from "./blocks/text-study-block"; -import { ReflectionBlock } from "./blocks/reflection-block"; -import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types"; - -interface BlockRendererProps { - textbookId?: string; - chapterId?: string; - classes?: { id: string; name: string }[]; -} - -function SortableBlock({ - node, - index, - total, - textbookId, - chapterId, - classes, -}: { - node: LessonPlanNode; - index: number; - total: number; - textbookId?: string; - chapterId?: string; - classes?: { id: string; name: string }[]; -}) { - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id: node.id }); - const { updateNode, removeNode } = useLessonPlanEditor(); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type); - - return ( -
-
- - updateNode(node.id, { title: e.target.value })} - className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none" - /> - - - -
-
- {isRichText ? ( - updateNode(node.id, { data: d })} - /> - ) : node.type === "exercise" ? ( - - ) : node.type === "text_study" ? ( - - ) : node.type === "reflection" ? ( - updateNode(node.id, { data: d })} - /> - ) : ( -
- 未知 block 类型 -
- )} -
-
- ); -} - -export function BlockRenderer({ - textbookId, - chapterId, - classes, -}: BlockRendererProps) { - const { doc, updateNode } = useLessonPlanEditor(); - - function onDragEnd(e: DragEndEvent) { - const { active, over } = e; - if (!over || active.id === over.id) return; - // 拖拽排序仅更新 order 字段,实际位置由节点图管理 - const oldIndex = doc.nodes.findIndex((b) => b.id === active.id); - const newIndex = doc.nodes.findIndex((b) => b.id === over.id); - if (oldIndex === -1 || newIndex === -1) return; - // 交换 order 并写回 store(修复 onDragEnd 未回写 store 的 BUG) - const tmpOrder = doc.nodes[oldIndex].order; - updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order }); - updateNode(doc.nodes[newIndex].id, { order: tmpOrder }); - } - - return ( - - b.id)} - strategy={verticalListSortingStrategy} - > -
- {doc.nodes - .filter((b): b is LessonPlanNode => b.type !== "textbook_content") - .map((b, i) => ( - - ))} -
-
-
- ); -} diff --git a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx index 7c683e9..02d2a5b 100644 --- a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx @@ -6,6 +6,7 @@ import { Tag } from "lucide-react"; import type { BlackboardBlockData } from "../../types"; import { isBlackboardLayout } from "../../lib/type-guards"; import { KnowledgePointPicker } from "../knowledge-point-picker"; +import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary"; interface Props { data: BlackboardBlockData; @@ -72,13 +73,15 @@ export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props {showKpPicker && ( - onUpdate({ ...data, knowledgePointIds: ids })} - onClose={() => setShowKpPicker(false)} - /> + + onUpdate({ ...data, knowledgePointIds: ids })} + onClose={() => setShowKpPicker(false)} + /> + )} ); diff --git a/src/modules/lesson-preparation/components/blocks/exercise-block.tsx b/src/modules/lesson-preparation/components/blocks/exercise-block.tsx index 7a9699a..ce4b757 100644 --- a/src/modules/lesson-preparation/components/blocks/exercise-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/exercise-block.tsx @@ -1,12 +1,14 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; import { QuestionBankPicker } from "../question-bank-picker"; import { InlineQuestionEditor } from "../inline-question-editor"; import { PublishHomeworkDialog } from "../publish-homework-dialog"; +import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary"; import { Button } from "@/shared/components/ui/button"; import { Plus, Trash2 } from "lucide-react"; import type { @@ -120,12 +122,13 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: {t("status.publishedAsHomework")} - 替换为 next/link 的 */} + {t("action.viewHomework")} - + ) : ( data.purpose === "after_class_homework" && @@ -140,31 +143,37 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: )} {showBank && ( - i.questionId)} - onPick={addItems} - onClose={() => setShowBank(false)} - /> + + i.questionId)} + onPick={addItems} + onClose={() => setShowBank(false)} + /> + )} {showInline && ( - { - addItems([item]); - setShowInline(false); - }} - onClose={() => setShowInline(false)} - /> + + { + addItems([item]); + setShowInline(false); + }} + onClose={() => setShowInline(false)} + /> + )} {showPublish && ( - setShowPublish(false)} - onPublished={() => router.refresh()} - /> + + setShowPublish(false)} + onPublished={() => router.refresh()} + /> + )} ); diff --git a/src/modules/lesson-preparation/components/blocks/homework-block.tsx b/src/modules/lesson-preparation/components/blocks/homework-block.tsx index ad237b6..a587d55 100644 --- a/src/modules/lesson-preparation/components/blocks/homework-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/homework-block.tsx @@ -43,7 +43,7 @@ export function HomeworkBlock({ data, onUpdate }: Props) { {t("homework.hint")} {data.assignments.map((item, idx) => ( -
+
{ diff --git a/src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx b/src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx index adacc40..02d9c18 100644 --- a/src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx @@ -6,6 +6,7 @@ import { Plus, Trash2, Tag } from "lucide-react"; import type { NewTeachingBlockData, NewTeachingPoint } from "../../types"; import { Button } from "@/shared/components/ui/button"; import { KnowledgePointPicker } from "../knowledge-point-picker"; +import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary"; interface Props { data: NewTeachingBlockData; @@ -48,7 +49,7 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop {t("newTeaching.hint")}
{data.teachingPoints.map((point, idx) => ( -
+
{t("newTeaching.pointIndex", { index: idx + 1 })} @@ -106,13 +107,15 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop {t("newTeaching.addPoint")} {pickerFor !== null && ( - updatePoint(pickerFor, { knowledgePointIds: ids })} - onClose={() => setPickerFor(null)} - /> + + updatePoint(pickerFor, { knowledgePointIds: ids })} + onClose={() => setPickerFor(null)} + /> + )}
); diff --git a/src/modules/lesson-preparation/components/blocks/objective-block.tsx b/src/modules/lesson-preparation/components/blocks/objective-block.tsx index fb70f03..194a1fe 100644 --- a/src/modules/lesson-preparation/components/blocks/objective-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/objective-block.tsx @@ -46,7 +46,7 @@ export function ObjectiveBlock({ data, onUpdate }: Props) { {t("objective.hint")}
{data.objectives.map((item, idx) => ( -
+
{ diff --git a/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx b/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx index 4255aa6..e99ffdf 100644 --- a/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import type { RichTextBlockData } from "../../types"; import { KnowledgePointPicker } from "../knowledge-point-picker"; +import { LessonPlanErrorBoundary } from "../lesson-plan-error-boundary"; import { Tag } from "lucide-react"; interface Props { @@ -70,13 +71,15 @@ export function RichTextBlock({
{showKpPicker && ( - onUpdate({ ...data, knowledgePointIds: ids })} - onClose={() => setShowKpPicker(false)} - /> + + onUpdate({ ...data, knowledgePointIds: ids })} + onClose={() => setShowKpPicker(false)} + /> + )}
); diff --git a/src/modules/lesson-preparation/components/blocks/summary-block.tsx b/src/modules/lesson-preparation/components/blocks/summary-block.tsx index 7e63096..ccdac73 100644 --- a/src/modules/lesson-preparation/components/blocks/summary-block.tsx +++ b/src/modules/lesson-preparation/components/blocks/summary-block.tsx @@ -38,7 +38,7 @@ export function SummaryBlock({ data, onUpdate }: Props) { {t("summary.hint")}
{data.summaryPoints.map((point, idx) => ( -
+
{idx + 1}. ("week"); + const [cursor, setCursor] = useState(() => new Date()); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const today = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + // 计算当前视图的起止日期 + const { startDate, endDate } = useMemo(() => { + if (viewMode === "week") { + const start = getWeekStart(cursor); + const end = new Date(start); + end.setDate(start.getDate() + 6); + end.setHours(23, 59, 59, 999); + return { startDate: start, endDate: end }; + } + const start = getMonthStart(cursor); + // 月视图扩展为 6 周网格 + const gridStart = getWeekStart(start); + const gridEnd = new Date(gridStart); + gridEnd.setDate(gridStart.getDate() + 41); + gridEnd.setHours(23, 59, 59, 999); + return { startDate: gridStart, endDate: gridEnd }; + }, [viewMode, cursor]); + + // 加载数据 + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const res = await getCalendarEventsAction({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }); + if (cancelled) return; + if (res.success && res.data) { + setEvents(res.data.events); + } else { + setError(res.message ?? t("calendar.loadFailed")); + setEvents([]); + } + } catch (e) { + if (cancelled) return; + console.error("[CalendarView] load failed", e); + setError(t("calendar.loadFailed")); + setEvents([]); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [startDate, endDate, t]); + + // 按日期分组事件 + const grouped = useMemo(() => { + const map = new Map(); + for (const e of events) { + const key = formatDateKey(e.occurredAt); + const list = map.get(key) ?? []; + list.push(e); + map.set(key, list); + } + return map; + }, [events]); + + const days = useMemo(() => { + return viewMode === "week" ? buildWeekGrid(cursor) : buildMonthGrid(cursor); + }, [viewMode, cursor]); + + const handlePrev = useCallback(() => { + setCursor((prev) => { + const d = new Date(prev); + if (viewMode === "week") { + d.setDate(d.getDate() - 7); + } else { + d.setMonth(d.getMonth() - 1); + } + return d; + }); + }, [viewMode]); + + const handleNext = useCallback(() => { + setCursor((prev) => { + const d = new Date(prev); + if (viewMode === "week") { + d.setDate(d.getDate() + 7); + } else { + d.setMonth(d.getMonth() + 1); + } + return d; + }); + }, [viewMode]); + + const handleToday = useCallback(() => { + setCursor(new Date()); + }, []); + + const title = useMemo(() => { + const y = cursor.getFullYear(); + const m = cursor.getMonth() + 1; + if (viewMode === "month") { + return `${y}-${String(m).padStart(2, "0")}`; + } + const ws = getWeekStart(cursor); + const we = new Date(ws); + we.setDate(ws.getDate() + 6); + return `${ws.getFullYear()}-${String(ws.getMonth() + 1).padStart(2, "0")}-${String(ws.getDate()).padStart(2, "0")} ~ ${we.getFullYear()}-${String(we.getMonth() + 1).padStart(2, "0")}-${String(we.getDate()).padStart(2, "0")}`; + }, [cursor, viewMode]); + + // 隐藏未使用变量 lint 警告(initialTeacherId 由 Server Component 注入以便未来扩展) + void initialTeacherId; + + return ( +
+ {/* 工具栏 */} +
+
+ + + + {title} +
+
+ + +
+
+ + {/* 错误提示 */} + {error !== null && ( +
+ {error} +
+ )} + + {/* 周历视图 */} + {viewMode === "week" ? ( + + ) : ( + + )} +
+ ); +} + +interface GridProps { + days: Date[]; + grouped: Map; + today: Date; + loading: boolean; + t: ReturnType; +} + +function WeekGrid({ days, grouped, today, loading, t }: GridProps): JSX.Element { + return ( +
+ {days.map((d) => { + const key = formatDateKey(d); + const dayEvents = grouped.get(key) ?? []; + const isToday = isSameDay(d, today); + return ( +
+
+ {t(`calendar.weekDays.${WEEK_DAY_KEYS[d.getDay()]}`)} + {d.getDate()} +
+
+ {loading ? ( + + ) : dayEvents.length === 0 ? ( +

+ {t("calendar.noEvents")} +

+ ) : ( + dayEvents.slice(0, 6).map((e) => ) + )} + {dayEvents.length > 6 && ( +

+{dayEvents.length - 6}

+ )} +
+
+ ); + })} +
+ ); +} + +function MonthGrid({ days, cursor, grouped, today, loading, t }: GridProps & { cursor: Date }): JSX.Element { + return ( +
+ {/* 表头 */} +
+ {WEEK_DAY_KEYS.map((dk) => ( +
+ {t(`calendar.weekDays.${dk}`)} +
+ ))} +
+ {/* 日期格子 */} +
+ {days.map((d, i) => { + const key = formatDateKey(d); + const dayEvents = grouped.get(key) ?? []; + const isCurrentMonth = d.getMonth() === cursor.getMonth(); + const isToday = isSameDay(d, today); + return ( +
= days.length - 7 && "border-b-0", + !isCurrentMonth && "bg-muted/20", + )} + > +
+ {d.getDate()} +
+
+ {loading ? ( + + ) : ( + dayEvents.slice(0, 3).map((e) => ) + )} + {dayEvents.length > 3 && ( +

+{dayEvents.length - 3}

+ )} +
+
+ ); + })} +
+
+ ); +} + +interface EventChipProps { + event: LessonPlanCalendarEvent; + t: ReturnType; + compact?: boolean; +} + +function EventChip({ event, t, compact }: EventChipProps): JSX.Element { + const color = getEventColor(event.eventType); + const href = `/teacher/lesson-plans/${event.planId}/edit`; + const label = t(`calendar.eventType.${event.eventType}`); + const meta = + event.eventType === "version_saved" && event.versionNo !== undefined + ? t("calendar.eventMeta", { versionNo: event.versionNo }) + : null; + + return ( + + {label} + · + {event.title} + {meta !== null && {meta}} + + ); +} diff --git a/src/modules/lesson-preparation/components/curriculum-map-view.tsx b/src/modules/lesson-preparation/components/curriculum-map-view.tsx new file mode 100644 index 0000000..3325783 --- /dev/null +++ b/src/modules/lesson-preparation/components/curriculum-map-view.tsx @@ -0,0 +1,120 @@ +import type { JSX } from "react"; +import { useMemo } from "react"; +import { cn } from "@/shared/lib/utils"; +import type { GradeOption, SubjectOption } from "@/modules/school/data-access"; +import type { StandardsCoverageCell } from "@/modules/lesson-preparation/data-access-analytics"; + +interface Props { + grades: GradeOption[]; + subjects: SubjectOption[]; + heatmap: StandardsCoverageCell[]; +} + +/** 根据覆盖率返回背景色 */ +function getCoverageColor(percent: number): string { + if (percent === 0) return "bg-muted/40 text-muted-foreground"; + if (percent < 25) return "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300"; + if (percent < 50) return "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300"; + if (percent < 75) return "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300"; + return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300"; +} + +export function CurriculumMapView({ grades, subjects, heatmap }: Props): JSX.Element { + // 构建查找表:subjectId|gradeId -> cell + const cellMap = useMemo(() => { + const map = new Map(); + for (const cell of heatmap) { + const key = `${cell.subjectId ?? ""}|${cell.gradeId ?? ""}`; + map.set(key, cell); + } + return map; + }, [heatmap]); + + if (grades.length === 0 || subjects.length === 0) { + return ( +
+ 暂无年级或学科数据 +
+ ); + } + + return ( +
+
+ + + + + {grades.map((g) => ( + + ))} + + + + {subjects.map((s) => ( + + + {grades.map((g) => { + const key = `${s.id}|${g.id}`; + const cell = cellMap.get(key); + const total = cell?.totalPlans ?? 0; + const linked = cell?.standardsLinkedPlans ?? 0; + const percent = cell?.coveragePercent ?? 0; + return ( + + ); + })} + + ))} + +
+ 学科\年级 + + {g.name} +
+ {s.name} + + {total === 0 ? ( + + ) : ( +
+ {percent}% + + {linked}/{total} + +
+ )} +
+
+ + {/* 图例 */} +
+ 覆盖率图例: + + + + + +
+
+ ); +} + +function LegendItem({ color, label }: { color: string; label: string }): JSX.Element { + return ( + + + {label} + + ); +} \ No newline at end of file diff --git a/src/modules/lesson-preparation/components/inline-question-editor.tsx b/src/modules/lesson-preparation/components/inline-question-editor.tsx index f34724c..8c8dcbb 100644 --- a/src/modules/lesson-preparation/components/inline-question-editor.tsx +++ b/src/modules/lesson-preparation/components/inline-question-editor.tsx @@ -5,9 +5,12 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { createId } from "@paralleldrive/cuid2"; import { Button } from "@/shared/components/ui/button"; +import { FocusTrap } from "@/shared/components/a11y/focus-trap"; import { X, Tag } from "lucide-react"; import { KnowledgePointPicker } from "./knowledge-point-picker"; +import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary"; import type { ExerciseItem, InlineQuestionContent } from "../types"; +import { VALID_QUESTION_TYPES } from "../lib/type-guards"; interface Props { onAdd: (item: ExerciseItem) => void; @@ -16,11 +19,21 @@ interface Props { chapterId?: string; } +// V4 P1-10 修复:复用 lib/type-guards 的 VALID_QUESTION_TYPES, +// 过滤掉 inline 编辑器不支持的类型(multiple_choice / composite 需要复杂 UI) +const INLINE_QUESTION_TYPES = VALID_QUESTION_TYPES.filter( + (t): t is "single_choice" | "text" | "judgment" => + t === "single_choice" || t === "text" || t === "judgment", +); +type InlineQuestionType = (typeof INLINE_QUESTION_TYPES)[number]; + +function isInlineQuestionType(v: string): v is InlineQuestionType { + return (INLINE_QUESTION_TYPES as readonly string[]).includes(v); +} + export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: Props) { const t = useTranslations("lessonPreparation"); - const [type, setType] = useState< - "single_choice" | "text" | "judgment" - >("single_choice"); + const [type, setType] = useState("single_choice"); const [difficulty, setDifficulty] = useState(3); const [text, setText] = useState(""); const [options, setOptions] = useState(["", ""]); @@ -28,12 +41,6 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: const [kpIds, setKpIds] = useState([]); const [showKpPicker, setShowKpPicker] = useState(false); - // 类型守卫:安全地将 string 收窄为联合类型 - const QUESTION_TYPES = ["single_choice", "text", "judgment"] as const; - function isQuestionType(v: string): v is "single_choice" | "text" | "judgment" { - return QUESTION_TYPES.includes(v as typeof QUESTION_TYPES[number]); - } - function handleAdd() { if (!text.trim()) { toast.error(t("questionBank.stemRequired")); @@ -70,158 +77,173 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: return (
-
-
-

{t("questionBank.inlineTitle")}

- -
-
-
- - +
+ {/* P1 修复:包裹 FocusTrap 实现焦点陷阱,支持键盘 Tab 循环与关闭后焦点恢复。 + className="contents" 使 FocusTrap 容器不生成盒子,子元素直接参与父级 flex 布局。 */} + +
+

{t("questionBank.inlineTitle")}

+
-
- -