feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff

- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
This commit is contained in:
SpecialX
2026-07-03 10:25:21 +08:00
parent a16f09d3c3
commit 20023e13fd
75 changed files with 5131 additions and 1186 deletions

View File

@@ -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<ActionState<{ items: AiEvaluation[] }>> {
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<ActionState<{ evaluation: AiEvaluation | null }>> {
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<string, unknown>,
): Promise<ActionState<{ evaluation: AiEvaluation }>> {
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<ActionState<null>> {
try {
await requirePermission(Permissions.LESSON_PLAN_UPDATE);
await deleteEvaluation(id);
return { success: true, data: null };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -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<string, unknown>,但 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);
}
}

View File

@@ -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<string, unknown>,
): Promise<ActionState<{ items: TeacherInvestmentDataPoint[] }>> {
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<ReturnType<typeof getGlobalLessonPlanStats>> }>
> {
try {
await requirePermission("lesson_plan:read");
const stats = await getGlobalLessonPlanStats();
return { success: true, data: { stats } };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -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<ReturnType<typeof getAttachmentsByPlanId>> }>
> {
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<string, unknown>,
): Promise<
ActionState<{ attachment: Awaited<ReturnType<typeof getAttachmentById>> }>
> {
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<ActionState<null>> {
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<string, unknown>,
): Promise<ActionState<null>> {
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);
}
}

View File

@@ -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<string, unknown>,
): Promise<ActionState<{ events: LessonPlanCalendarEvent[]; grouped: Record<string, LessonPlanCalendarEvent[]> }>> {
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<string, LessonPlanCalendarEvent[]> = {};
for (const [key, value] of groupedMap.entries()) {
grouped[key] = value;
}
return { success: true, data: { events, grouped } };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -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<ActionState<{ items: LessonPlanComment[] }>> {
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<string, unknown>,
): Promise<ActionState<{ comment: LessonPlanComment }>> {
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<string, unknown>,
): Promise<ActionState<null>> {
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<ActionState<null>> {
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<ActionState<null>> {
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<ActionState<{ count: number }>> {
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
const count = await countUnresolvedComments(planId);
return { success: true, data: { count } };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -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<ActionState<{ items: FormativeItem[] }>> {
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<string, unknown>,
): Promise<ActionState<{ item: FormativeItem }>> {
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<ActionState<null>> {
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<ActionState<null>> {
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<string, unknown>,
): Promise<ActionState<{ response: FormativeResponse }>> {
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<ActionState<{ responses: FormativeResponse[] }>> {
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<ActionState<{ stats: Awaited<ReturnType<typeof getFormativeItemStats>> }>> {
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
const stats = await getFormativeItemStats(itemId);
return { success: true, data: { stats } };
} catch (e) {
return handleActionError(e);
}
}

View File

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

View File

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

View File

@@ -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 ActionV4 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<ActionState<{ data: QuestionPickerItem[] }>> {
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);
}
}

View File

@@ -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<string, unknown>,
): Promise<ActionState<{ status: string }>> {
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<string, unknown>,
): Promise<ActionState<{ newStatus: string; record: LessonPlanReviewRecord }>> {
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<ActionState<{ records: LessonPlanReviewRecord[] }>> {
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<ReturnType<typeof getPendingReviewPlans>> }>
> {
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<ActionState<{ status: string }>> {
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);
}
}

View File

@@ -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<ActionState<{ items: LessonPlanSubstitute[] }>> {
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<string, unknown>,
): Promise<ActionState<{ substitute: LessonPlanSubstitute }>> {
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<string, unknown>,
): Promise<ActionState<null>> {
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<ActionState<null>> {
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<ActionState<{ canAccess: boolean }>> {
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);
}
}

View File

@@ -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<string, unknown>
// 通过 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 {

View File

@@ -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<string, unknown> 以进行字段检查
const record = node as Record<string, unknown>
const data = record.data
if (!data || typeof data !== "object") return ""
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
const dataRecord = data as Record<string, unknown>
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 }],

View File

@@ -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 (
<div
ref={setNodeRef}
style={style}
className="border border-outline-variant rounded-lg bg-surface-container-lowest"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-outline-variant bg-surface-container-low">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-outline hover:text-on-surface"
>
<GripVertical className="w-4 h-4" />
</button>
<input
value={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
/>
<button
onClick={() => updateNode(node.id, { order: index - 1 })}
disabled={index === 0}
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => updateNode(node.id, { order: index + 1 })}
disabled={index === total - 1}
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
>
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={() => removeNode(node.id)}
className="p-1 text-error hover:text-error/80"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-2">
{isRichText ? (
<RichTextBlock
data={node.data as RichTextBlockData}
textbookId={textbookId}
chapterId={chapterId}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : node.type === "exercise" ? (
<ExerciseBlock
blockId={node.id}
data={node.data as ExerciseBlockData}
classes={classes ?? []}
/>
) : node.type === "text_study" ? (
<TextStudyBlock
blockId={node.id}
data={node.data as TextStudyBlockData}
/>
) : node.type === "reflection" ? (
<ReflectionBlock
data={node.data as ReflectionBlockData}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : (
<div className="text-on-surface-variant text-sm p-4">
block
</div>
)}
</div>
</div>
);
}
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 (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={doc.nodes.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{doc.nodes
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
.map((b, i) => (
<SortableBlock
key={b.id}
node={b}
index={i}
total={doc.nodes.length}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -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
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -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 }:
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
{t("status.publishedAsHomework")}
</span>
<a
{/* V4 P1-5 修复:原生 <a> 替换为 next/link 的 <Link> */}
<Link
href="/teacher/homework"
className="text-primary underline"
>
{t("action.viewHomework")}
</a>
</Link>
</div>
) : (
data.purpose === "after_class_homework" &&
@@ -140,31 +143,37 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
)}
</div>
{showBank && (
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
<LessonPlanErrorBoundary>
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
</LessonPlanErrorBoundary>
)}
{showInline && (
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
<LessonPlanErrorBoundary>
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
</LessonPlanErrorBoundary>
)}
{showPublish && (
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
<LessonPlanErrorBoundary>
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => router.refresh()}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -43,7 +43,7 @@ export function HomeworkBlock({ data, onUpdate }: Props) {
{t("homework.hint")}
</div>
{data.assignments.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`homework-${idx}`} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) => {

View File

@@ -43,7 +43,7 @@ export function KeyPointBlock({ data, onUpdate }: Props) {
{t("keyPoint.hint")}
</div>
{data.keyPoints.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`key-point-${idx}`} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) => {

View File

@@ -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")}
</div>
{data.teachingPoints.map((point, idx) => (
<div key={idx} className="border border-outline-variant rounded p-2 space-y-2">
<div key={`teaching-${idx}`} className="border border-outline-variant rounded p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{t("newTeaching.pointIndex", { index: idx + 1 })}
@@ -106,13 +107,15 @@ export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Prop
{t("newTeaching.addPoint")}
</Button>
{pickerFor !== null && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -46,7 +46,7 @@ export function ObjectiveBlock({ data, onUpdate }: Props) {
{t("objective.hint")}
</div>
{data.objectives.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`objective-${idx}`} className="flex items-start gap-2">
<select
value={item.dimension}
onChange={(e) => {

View File

@@ -43,7 +43,7 @@ export function ReflectionBlock({ data, onUpdate }: Props) {
{t("reflection.hint")}
</div>
{data.reflection.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`reflection-${idx}`} className="flex items-start gap-2">
<select
value={item.aspect}
onChange={(e) => {

View File

@@ -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({
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -38,7 +38,7 @@ export function SummaryBlock({ data, onUpdate }: Props) {
{t("summary.hint")}
</div>
{data.summaryPoints.map((point, idx) => (
<div key={idx} className="flex items-start gap-2">
<div key={`summary-${idx}`} className="flex items-start gap-2">
<span className="text-xs text-on-surface-variant mt-1">{idx + 1}.</span>
<input
type="text"

View File

@@ -0,0 +1,417 @@
"use client";
import type { JSX } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Skeleton } from "@/shared/components/ui/skeleton";
import { cn } from "@/shared/lib/utils";
import { getCalendarEventsAction } from "../actions-calendar";
import type { LessonPlanCalendarEvent } from "../data-access-calendar";
type ViewMode = "week" | "month";
interface Props {
initialTeacherId: string;
}
const WEEK_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
/** 计算所在周的第一天(周日为起点) */
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - d.getDay());
return d;
}
/** 计算所在月的第一天 */
function getMonthStart(date: Date): Date {
const d = new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
return d;
}
/** 计算所在月的最后一天 */
/** 生成日历网格6 行 × 7 列 = 42 天,覆盖整月) */
function buildMonthGrid(monthDate: Date): Date[] {
const start = getMonthStart(monthDate);
const gridStart = getWeekStart(start);
const days: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
days.push(d);
}
return days;
}
/** 生成周历网格7 天) */
function buildWeekGrid(weekDate: Date): Date[] {
const start = getWeekStart(weekDate);
const days: Date[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push(d);
}
return days;
}
function formatDateKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** 事件颜色映射 */
function getEventColor(eventType: LessonPlanCalendarEvent["eventType"]): string {
switch (eventType) {
case "created":
return "bg-blue-100 text-blue-700 border-blue-200";
case "updated":
return "bg-slate-100 text-slate-700 border-slate-200";
case "version_saved":
return "bg-purple-100 text-purple-700 border-purple-200";
case "submitted":
return "bg-amber-100 text-amber-700 border-amber-200";
case "published":
return "bg-emerald-100 text-emerald-700 border-emerald-200";
case "reviewed":
return "bg-indigo-100 text-indigo-700 border-indigo-200";
default:
return "bg-slate-100 text-slate-700 border-slate-200";
}
}
export function CalendarView({ initialTeacherId }: Props): JSX.Element {
const t = useTranslations("lessonPreparation");
const [viewMode, setViewMode] = useState<ViewMode>("week");
const [cursor, setCursor] = useState<Date>(() => new Date());
const [events, setEvents] = useState<LessonPlanCalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, LessonPlanCalendarEvent[]>();
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 (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev} aria-label={t("calendar.prev")}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleNext} aria-label={t("calendar.next")}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleToday}>
{t("calendar.today")}
</Button>
<span className="ml-2 text-sm font-medium">{title}</span>
</div>
<div className="flex items-center gap-1 border rounded-md p-0.5">
<Button
variant={viewMode === "week" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("week")}
>
{t("calendar.weekView")}
</Button>
<Button
variant={viewMode === "month" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("month")}
>
{t("calendar.monthView")}
</Button>
</div>
</div>
{/* 错误提示 */}
{error !== null && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* 周历视图 */}
{viewMode === "week" ? (
<WeekGrid
days={days}
grouped={grouped}
today={today}
loading={loading}
t={t}
/>
) : (
<MonthGrid
days={days}
cursor={cursor}
grouped={grouped}
today={today}
loading={loading}
t={t}
/>
)}
</div>
);
}
interface GridProps {
days: Date[];
grouped: Map<string, LessonPlanCalendarEvent[]>;
today: Date;
loading: boolean;
t: ReturnType<typeof useTranslations>;
}
function WeekGrid({ days, grouped, today, loading, t }: GridProps): JSX.Element {
return (
<div className="grid grid-cols-7 gap-2">
{days.map((d) => {
const key = formatDateKey(d);
const dayEvents = grouped.get(key) ?? [];
const isToday = isSameDay(d, today);
return (
<div key={key} className="flex flex-col">
<div
className={cn(
"text-center text-xs font-medium pb-1 border-b",
isToday ? "text-primary border-primary" : "text-muted-foreground border-border",
)}
>
{t(`calendar.weekDays.${WEEK_DAY_KEYS[d.getDay()]}`)}
<span className="ml-1">{d.getDate()}</span>
</div>
<div className="flex-1 min-h-[200px] space-y-1 pt-1">
{loading ? (
<Skeleton className="h-8 w-full" />
) : dayEvents.length === 0 ? (
<p className="text-xs text-muted-foreground/60 italic mt-2 text-center">
{t("calendar.noEvents")}
</p>
) : (
dayEvents.slice(0, 6).map((e) => <EventChip key={e.id} event={e} t={t} />)
)}
{dayEvents.length > 6 && (
<p className="text-xs text-muted-foreground">+{dayEvents.length - 6}</p>
)}
</div>
</div>
);
})}
</div>
);
}
function MonthGrid({ days, cursor, grouped, today, loading, t }: GridProps & { cursor: Date }): JSX.Element {
return (
<div className="border rounded-md overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-7 bg-muted/40">
{WEEK_DAY_KEYS.map((dk) => (
<div key={dk} className="text-center text-xs font-medium py-2 border-r last:border-r-0">
{t(`calendar.weekDays.${dk}`)}
</div>
))}
</div>
{/* 日期格子 */}
<div className="grid grid-cols-7">
{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 (
<div
key={key}
className={cn(
"min-h-[120px] border-r border-b p-1",
(i + 1) % 7 === 0 && "border-r-0",
i >= days.length - 7 && "border-b-0",
!isCurrentMonth && "bg-muted/20",
)}
>
<div
className={cn(
"text-xs font-medium mb-1 inline-flex items-center justify-center w-6 h-6 rounded-full",
isToday ? "bg-primary text-primary-foreground" : "text-muted-foreground",
!isCurrentMonth && "opacity-40",
)}
>
{d.getDate()}
</div>
<div className="space-y-1">
{loading ? (
<Skeleton className="h-4 w-full" />
) : (
dayEvents.slice(0, 3).map((e) => <EventChip key={e.id} event={e} t={t} compact />)
)}
{dayEvents.length > 3 && (
<p className="text-xs text-muted-foreground">+{dayEvents.length - 3}</p>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
interface EventChipProps {
event: LessonPlanCalendarEvent;
t: ReturnType<typeof useTranslations>;
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 (
<Link
href={href}
className={cn(
"block rounded border px-1.5 py-0.5 text-xs transition-colors hover:opacity-80",
color,
compact && "truncate",
)}
title={`${label}${event.title}${meta ?? ""}`}
>
<span className="font-medium">{label}</span>
<span className="mx-1 opacity-60">·</span>
<span className="truncate">{event.title}</span>
{meta !== null && <span className="ml-1 opacity-70">{meta}</span>}
</Link>
);
}

View File

@@ -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<string, StandardsCoverageCell>();
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 (
<div className="rounded-lg border p-8 text-center text-muted-foreground">
</div>
);
}
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<th className="border border-border bg-muted/50 p-2 text-left font-medium sticky left-0 z-10 min-w-[100px]">
</th>
{grades.map((g) => (
<th
key={g.id}
className="border border-border bg-muted/50 p-2 text-center font-medium min-w-[80px]"
>
{g.name}
</th>
))}
</tr>
</thead>
<tbody>
{subjects.map((s) => (
<tr key={s.id}>
<td className="border border-border p-2 font-medium sticky left-0 z-10 bg-card min-w-[100px]">
{s.name}
</td>
{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 (
<td
key={key}
className={cn(
"border border-border p-2 text-center transition-colors hover:opacity-80 cursor-default",
getCoverageColor(percent),
)}
title={`学科:${s.name}\年级:${g.name}\n课案总数${total}\n已关联课标${linked}\n覆盖率${percent}%`}
>
{total === 0 ? (
<span className="text-xs opacity-60"></span>
) : (
<div className="flex flex-col items-center">
<span className="text-lg font-bold">{percent}%</span>
<span className="text-xs opacity-70">
{linked}/{total}
</span>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* 图例 */}
<div className="flex flex-wrap items-center gap-4 text-xs">
<span className="text-muted-foreground"></span>
<LegendItem color="bg-muted/40" label="无数据" />
<LegendItem color="bg-red-100 dark:bg-red-950/50" label="0-25%" />
<LegendItem color="bg-amber-100 dark:bg-amber-950/50" label="25-50%" />
<LegendItem color="bg-blue-100 dark:bg-blue-950/50" label="50-75%" />
<LegendItem color="bg-emerald-100 dark:bg-emerald-950/50" label="75-100%" />
</div>
</div>
);
}
function LegendItem({ color, label }: { color: string; label: string }): JSX.Element {
return (
<span className="flex items-center gap-1">
<span className={cn("inline-block w-4 h-4 rounded border border-border", color)} />
{label}
</span>
);
}

View File

@@ -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<InlineQuestionType>("single_choice");
const [difficulty, setDifficulty] = useState(3);
const [text, setText] = useState("");
const [options, setOptions] = useState<string[]>(["", ""]);
@@ -28,12 +41,6 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
const [kpIds, setKpIds] = useState<string[]>([]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-label={t("questionBank.inlineTitle")}>
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label htmlFor="inline-question-type" className="text-sm font-medium">
{t("questionBank.typeLabel")}
</label>
<select
id="inline-question-type"
value={type}
onChange={(e) => {
if (isQuestionType(e.target.value)) {
setType(e.target.value);
}
}}
className="w-full border rounded px-2 py-1 mt-1"
>
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
<option value="text">{t("questionBank.type.text")}</option>
<option value="judgment">{t("questionBank.type.judgment")}</option>
</select>
<div
className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("questionBank.inlineTitle")}
>
{/* P1 修复:包裹 FocusTrap 实现焦点陷阱,支持键盘 Tab 循环与关闭后焦点恢复。
className="contents" 使 FocusTrap 容器不生成盒子,子元素直接参与父级 flex 布局。 */}
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div>
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
<textarea
id="inline-question-stem"
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
/>
</div>
{type === "single_choice" && (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div>
<label className="text-sm font-medium">
{t("questionBank.optionsLabel")}
<label htmlFor="inline-question-type" className="text-sm font-medium">
{t("questionBank.typeLabel")}
</label>
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2 mt-1">
<input
type="radio"
checked={correctIdx === i}
onChange={() => setCorrectIdx(i)}
/>
<input
value={opt}
onChange={(e) =>
setOptions(
options.map((o, j) =>
j === i ? e.target.value : o,
),
)
}
className="flex-1 border rounded px-2 py-1"
/>
{options.length > 2 && (
<button
onClick={() =>
setOptions(options.filter((_, j) => j !== i))
}
>
{t("action.delete")}
</button>
)}
</div>
))}
{options.length < 6 && (
<button
onClick={() => setOptions([...options, ""])}
className="text-sm text-primary mt-1"
>
{t("questionBank.addOption")}
</button>
)}
<select
id="inline-question-type"
value={type}
onChange={(e) => {
if (isInlineQuestionType(e.target.value)) {
setType(e.target.value);
}
}}
className="w-full border rounded px-2 py-1 mt-1"
>
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
<option value="text">{t("questionBank.type.text")}</option>
<option value="judgment">{t("questionBank.type.judgment")}</option>
</select>
</div>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
{t("questionBank.correct")}
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
{t("questionBank.incorrect")}
<label htmlFor="inline-question-stem" className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
<textarea
id="inline-question-stem"
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
/>
</div>
{type === "single_choice" && (
<div>
<label className="text-sm font-medium">
{t("questionBank.optionsLabel")}
</label>
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2 mt-1">
<input
type="radio"
checked={correctIdx === i}
onChange={() => setCorrectIdx(i)}
aria-label={t("questionBank.correctAnswer")}
/>
<input
value={opt}
onChange={(e) =>
setOptions(
options.map((o, j) =>
j === i ? e.target.value : o,
),
)
}
className="flex-1 border rounded px-2 py-1"
aria-label={t("questionBank.optionLabel", { index: i + 1 })}
/>
{options.length > 2 && (
<button
onClick={() =>
setOptions(options.filter((_, j) => j !== i))
}
aria-label={t("action.delete")}
>
{t("action.delete")}
</button>
)}
</div>
))}
{options.length < 6 && (
<button
onClick={() => setOptions([...options, ""])}
className="text-sm text-primary mt-1"
>
{t("questionBank.addOption")}
</button>
)}
</div>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
{t("questionBank.correct")}
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
{t("questionBank.incorrect")}
</label>
</div>
</div>
)}
<div>
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
<select
id="inline-question-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-1"
>
{[1, 2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{t("questionBank.difficulty", { level: d })}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
<div className="flex items-center gap-2 mt-1">
{kpIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.selected", { count: kpIds.length })}
</span>
)}
<button
type="button"
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
aria-label={t("knowledgePoint.select")}
>
<Tag className="w-3 h-3" aria-hidden="true" />
{t("knowledgePoint.select")}
</button>
</div>
</div>
)}
<div>
<label htmlFor="inline-question-difficulty" className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
<select
id="inline-question-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
className="w-full border rounded px-2 py-1 mt-1"
>
{[1, 2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{t("questionBank.difficulty", { level: d })}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
<div className="flex items-center gap-2 mt-1">
{kpIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.selected", { count: kpIds.length })}
</span>
)}
<button
type="button"
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.select")}
</button>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
</div>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
</div>
</FocusTrap>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={kpIds}
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
<LessonPlanErrorBoundary>
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={kpIds}
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
</LessonPlanErrorBoundary>
)}
</div>
);

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { KnowledgePointSkeleton } from "./lesson-plan-skeleton";
import { X } from "lucide-react";
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
import type { KnowledgePointOption } from "../providers/lesson-plan-provider";
@@ -30,35 +32,38 @@ export function KnowledgePointPicker({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
if (!textbookId || !service) {
return;
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
useEffect(() => {
if (!textbookId || !service) return;
let cancelled = false;
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 Promise.resolve().then()
(async () => {
setLoading(true);
setError(null);
try {
const res = await service.getKnowledgePointOptions({ textbookId, chapterId });
if (cancelled) return;
setLoading(true);
setError(null);
return service.getKnowledgePointOptions({ textbookId, chapterId });
})
.then((res) => {
if (cancelled || !res) return;
if (res.success && res.data) {
setOptions(res.data.options);
} else {
setError(res.message ?? t("error.loadFailed"));
}
})
.catch((e) => {
} catch (e) {
if (cancelled) return;
console.error("[KnowledgePointPicker] load options failed", e);
setError(t("error.loadFailed"));
})
.finally(() => {
} finally {
if (!cancelled) setLoading(false);
});
}
})();
return () => {
cancelled = true;
};
@@ -78,6 +83,7 @@ export function KnowledgePointPicker({
aria-label={t("knowledgePoint.title")}
className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -86,9 +92,7 @@ export function KnowledgePointPicker({
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-on-surface-variant text-sm">
{t("knowledgePoint.loading")}
</p>
<KnowledgePointSkeleton />
) : error ? (
<p className="text-error text-sm">{error}</p>
) : options.length === 0 ? (
@@ -127,6 +131,7 @@ export function KnowledgePointPicker({
{t("action.confirm")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -69,7 +70,8 @@ export function LessonPlanCard({
// 只读视图(非 teacher不显示编辑操作
const isReadOnly = viewMode !== "teacher";
async function handleArchive() {
// P2 修复:使用 useCallback 包裹异步处理函数,避免每次渲染重新创建
const handleArchive = useCallback(async () => {
if (!service) return;
try {
const res = await service.deleteLessonPlan(plan.id);
@@ -84,9 +86,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] archive failed", e);
toast.error(t("error.delete"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handleDuplicate() {
const handleDuplicate = useCallback(async () => {
if (!service) return;
try {
const res = await service.duplicateLessonPlan(plan.id);
@@ -100,9 +102,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] duplicate failed", e);
toast.error(t("error.duplicate"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handlePublish() {
const handlePublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.publishLessonPlan(plan.id);
@@ -117,9 +119,9 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] publish failed", e);
toast.error(t("error.save"));
}
}
}, [service, plan.id, tracker, t, router]);
async function handleUnpublish() {
const handleUnpublish = useCallback(async () => {
if (!service) return;
try {
const res = await service.unpublishLessonPlan(plan.id);
@@ -134,7 +136,7 @@ export function LessonPlanCard({
console.error("[LessonPlanCard] unpublish failed", e);
toast.error(t("error.save"));
}
}
}, [service, plan.id, tracker, t, router]);
return (
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">

View File

@@ -4,13 +4,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel } from "./node-edit-panel";
import { NodeEditPanel, type AiContentGeneratorSlot } from "./node-edit-panel";
import { VersionHistoryDrawer } from "./version-history-drawer";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import {
useLessonPlanContextSafe,
useLessonPlanTrackerSafe,
} from "../providers/lesson-plan-provider";
import type { BlockType } from "../types";
import type { BlockType, LessonPlanStatus } from "../types";
import { Button } from "@/shared/components/ui/button";
import {
AlertDialog,
@@ -30,12 +31,14 @@ interface Props {
planId: string;
initialTitle: string;
initialDoc: import("../types").LessonPlanDocument;
initialStatus?: "draft" | "published" | "archived";
initialStatus?: LessonPlanStatus;
textbookId?: string;
chapterId?: string;
textbookTitle?: string;
chapterTitle?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合P0-11 修复)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
const BLOCK_TYPES_TO_ADD: BlockType[] = [
@@ -63,6 +66,7 @@ export function LessonPlanEditor({
textbookTitle,
chapterTitle,
classes,
aiContentGenerator,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useLessonPlanEditor();
@@ -71,7 +75,7 @@ export function LessonPlanEditor({
const service = ctx?.service ?? null;
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [planStatus, setPlanStatus] = useState<"draft" | "published" | "archived">(initialStatus);
const [planStatus, setPlanStatus] = useState<LessonPlanStatus>(initialStatus);
const [publishing, setPublishing] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -324,7 +328,9 @@ export function LessonPlanEditor({
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<NodeEditor />
<LessonPlanErrorBoundary>
<NodeEditor />
</LessonPlanErrorBoundary>
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
@@ -359,17 +365,20 @@ export function LessonPlanEditor({
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
aiContentGenerator={aiContentGenerator}
/>
</div>
)}
</div>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
<LessonPlanErrorBoundary>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
</LessonPlanErrorBoundary>
</div>
);
}

View File

@@ -11,9 +11,11 @@ interface Props {
status?: string;
}) => void;
subjects: { id: string; name: string }[];
// P2 修复:加载状态,禁用输入避免重复请求
isLoading?: boolean;
}
export function LessonPlanFilters({ onFilter, subjects }: Props) {
export function LessonPlanFilters({ onFilter, subjects, isLoading = false }: Props) {
const t = useTranslations("lessonPreparation");
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState<string>("");
@@ -45,7 +47,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
placeholder={t("filters.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
/>
<label htmlFor="lesson-plan-subject" className="sr-only">
{t("filters.allSubjects")}
@@ -54,7 +58,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
id="lesson-plan-subject"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allSubjects")}</option>
{subjects.map((s) => (
@@ -70,7 +76,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
id="lesson-plan-status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
disabled={isLoading}
aria-busy={isLoading}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm disabled:opacity-50"
>
<option value="">{t("filters.allStatus")}</option>
<option value="draft">{t("status.draft")}</option>

View File

@@ -24,6 +24,8 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
const t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems);
const [error, setError] = useState<string | null>(null);
// P2 修复:增加筛选加载状态,避免用户重复点击
const [isLoading, setIsLoading] = useState(false);
const ctx = useLessonPlanContextSafe();
const service = ctx?.service ?? null;
@@ -37,6 +39,7 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
}) => {
setError(null);
if (!service) return;
setIsLoading(true);
try {
const res = await service.getLessonPlans(params);
if (res.success && res.data) {
@@ -47,6 +50,8 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
} catch (e) {
console.error("[LessonPlanList] filter failed", e);
setError(t("error.loadFailed"));
} finally {
setIsLoading(false);
}
},
[service, t],
@@ -54,13 +59,17 @@ export function LessonPlanList({ initialItems, subjects, viewMode = "teacher" }:
return (
<div className="space-y-4">
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} isLoading={isLoading} />
{error && (
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
{error}
</p>
)}
{items.length === 0 ? (
{isLoading && items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12">
{t("list.loading")}
</p>
) : items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12">
{t("list.empty")}
</p>

View File

@@ -9,7 +9,6 @@ import {
Controls,
MiniMap,
type Node,
type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { LessonNode } from "./nodes/lesson-node";
@@ -43,8 +42,8 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
const rfNodes = useMemo(() => toRfNodes(doc.nodes, selectedNodeId), [doc.nodes, selectedNodeId]);
const rfEdges = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? []),
[doc.edges, doc.anchors, selectedNodeId],
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors ?? [], doc.nodes),
[doc.edges, doc.anchors, selectedNodeId, doc.nodes],
);
// 为正文节点准备 data锚点、选中节点、选择回调
@@ -88,7 +87,7 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
<ReactFlow
nodes={nodesWithData}
edges={rfEdges as Edge[]}
edges={rfEdges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
@@ -97,6 +96,8 @@ export function LessonPlanReadonlyView({ doc, textbookTitle, chapterTitle }: Pro
zoomOnScroll={true}
zoomOnPinch={true}
panOnScroll={false}
zoomOnDoubleClick={false}
selectionOnDrag={false}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}

View File

@@ -1,29 +1,41 @@
"use client";
import { useState } from "react";
import { useState, type ComponentType } from "react";
import { useTranslations } from "next-intl";
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { BlockRenderer } from "../config/block-registry";
import { BlockRenderer, BLOCK_REGISTRY } from "../config/block-registry";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
import { getNodeColor } from "../lib/node-summary";
/**
* P0-11 修复AI 内容生成器 slot 类型。
* 通过 props 注入 AI 组件,避免直接 import @/modules/ai。
* 调用方teacher edit 页面)提供具体实现。
*/
export interface AiContentGeneratorSlotProps {
topic: string;
textbookId?: string;
chapterId?: string;
}
export type AiContentGeneratorSlot = ComponentType<AiContentGeneratorSlotProps>;
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
/** AI 内容生成器(可选,通过 props 注入避免模块耦合)*/
aiContentGenerator?: AiContentGeneratorSlot;
}
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
export function NodeEditPanel({ textbookId, chapterId, classes, aiContentGenerator }: Props) {
const t = useTranslations("lessonPreparation");
const tAi = useTranslations("ai");
const { doc, selectedNodeId, updateNode, removeNode, selectNode, removeAnchor } =
useLessonPlanEditor();
const aiClient = useAiClientOptional();
const [showAiPanel, setShowAiPanel] = useState(false);
const node = doc.nodes.find((n) => n.id === selectedNodeId);
@@ -160,8 +172,8 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
<UnknownBlockHint type={lessonNode.type} t={t} />
</LessonPlanErrorBoundary>
{/* AI 内容生成区(可折叠) */}
{aiClient ? (
{/* AI 内容生成区(可折叠)— P0-11 修复:通过 props 注入,不直接 import @/modules/ai */}
{aiContentGenerator ? (
<div className="mt-4 border-t border-outline-variant pt-3">
<Button
variant="ghost"
@@ -182,11 +194,16 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
</Button>
{showAiPanel ? (
<div className="mt-2">
<AiLessonContentGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
{(() => {
const AiGenerator = aiContentGenerator;
return (
<AiGenerator
topic={aiTopic}
textbookId={textbookId}
chapterId={chapterId}
/>
);
})()}
</div>
) : null}
</div>
@@ -220,12 +237,8 @@ function UnknownBlockHint({
type: string;
t: ReturnType<typeof useTranslations>;
}) {
// 已知类型不显示提示
const knownTypes = [
"objective", "key_point", "import", "new_teaching", "consolidation",
"summary", "homework", "blackboard", "rich_text", "exercise",
"text_study", "reflection",
];
// V4 P1-9 修复knownTypes 从 BLOCK_REGISTRY 派生,消除重复定义
const knownTypes: readonly string[] = Object.keys(BLOCK_REGISTRY);
if (knownTypes.includes(type)) {
return null;
}

View File

@@ -127,8 +127,8 @@ export function NodeEditor({}: Props) {
);
const rfEdges: Edge[] = useMemo(
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
[doc.edges, selectedNodeId, doc.anchors],
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors, doc.nodes),
[doc.edges, selectedNodeId, doc.anchors, doc.nodes],
);
const onNodesChange = useCallback(
@@ -221,6 +221,10 @@ export function NodeEditor({}: Props) {
nodesDraggable
edgesFocusable
elementsSelectable
panOnDrag
zoomOnPinch
zoomOnDoubleClick={false}
selectionOnDrag={false}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode={["Shift", "Meta", "Control"]}
defaultEdgeOptions={{

View File

@@ -6,12 +6,31 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { LessonPlanNode } from "../../types";
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
// P1 修复:类型守卫替代 as 断言,安全从 React Flow NodeProps.data 收窄
function isLessonNodeData(data: unknown): data is { node: LessonPlanNode } {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.node === "object" &&
obj.node !== null &&
typeof (obj.node as Record<string, unknown>).type === "string"
);
}
export const LessonNode = memo(function LessonNode({
data,
selected,
}: NodeProps) {
const t = useTranslations("lessonPreparation");
const nodeData = (data as { node: LessonPlanNode }).node;
// P1 修复:使用类型守卫安全收窄,若数据形状不符则渲染空状态避免运行时崩溃
if (!isLessonNodeData(data)) {
return (
<div className="rounded-lg border-2 border-outline-variant bg-surface p-3 text-xs text-on-surface-variant">
{t("editor.unknownBlockType")}
</div>
);
}
const nodeData = data.node;
const color = getNodeColor(nodeData.type);
// 适配 next-intl 的 t 到 NodeSummaryT 接口
const summaryT: NodeSummaryT = (key, values) => t(key, values);

View File

@@ -1,4 +1,4 @@
import type { ReactNode, CSSProperties } from "react";
import type { ReactNode, CSSProperties, KeyboardEvent } from "react";
import type { NodeAnchor } from "../../types";
import {
@@ -14,6 +14,22 @@ interface RenderSegmentsParams {
anchors?: NodeAnchor[];
}
/**
* 键盘激活回调Enter / Space 触发与点击同等的选中逻辑V4 P0-3 修复 a11y
*/
function handleAnchorKeyDown(
e: KeyboardEvent<HTMLSpanElement>,
anchor: NodeAnchor | undefined,
onSelectNode: ((id: string | null) => void) | undefined,
): void {
if (!anchor || !onSelectNode) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
onSelectNode(anchor.nodeId);
}
}
/**
* 渲染锚点段落数组(简化版:直接遍历 segments不使用 ReactMarkdown
* 解决问题 7避免每个段落重复渲染整个文档内容
@@ -33,9 +49,14 @@ export function renderSegments({
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
const anchor = anchors?.find((a) => a.id === seg.anchorId);
// V4 P0-3 修复:锚点 span 添加 role/tabIndex/onKeyDown/aria-label支持键盘导航与读屏
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`range-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
@@ -50,6 +71,7 @@ export function renderSegments({
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{seg.content}
</span>
@@ -62,9 +84,14 @@ export function renderSegments({
const pointIndex = anchor
? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1
: 1;
// V4 P0-3 修复point anchor 同样补齐 a11y 属性
return (
<span
key={idx}
role={anchor ? "button" : undefined}
tabIndex={anchor ? 0 : undefined}
aria-label={anchor ? `跳转到关联节点 ${anchor.nodeId}` : undefined}
aria-pressed={anchor ? isActive : undefined}
className={`point-anchor ${isActive ? "active" : ""}`}
// CSS 自定义属性需要断言,因为 TS 的 CSSProperties 不包含 --* 变量
style={
@@ -79,6 +106,7 @@ export function renderSegments({
onSelectNode(anchor.nodeId);
}
}}
onKeyDown={(e) => handleAnchorKeyDown(e, anchor, onSelectNode)}
>
{toCircledNumber(pointIndex ?? 1)}
</span>

View File

@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { X } from "lucide-react";
interface Props {
@@ -31,6 +32,15 @@ export function PublishHomeworkDialog({
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [onClose]);
async function handlePublish() {
if (!service) return;
if (selectedClasses.length === 0) {
@@ -74,6 +84,7 @@ export function PublishHomeworkDialog({
aria-label={t("publish.title")}
className="bg-surface rounded-lg shadow-xl w-96"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("publish.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -137,6 +148,7 @@ export function PublishHomeworkDialog({
{loading ? t("publish.publishing") : t("publish.publish")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);

View File

@@ -2,9 +2,11 @@
import { useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider"
import { useQuestionService } from "../providers/lesson-plan-provider"
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
import { Button } from "@/shared/components/ui/button"
import { FocusTrap } from "@/shared/components/a11y/focus-trap"
import { QuestionBankSkeleton } from "./lesson-plan-skeleton"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { X } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
@@ -31,13 +33,22 @@ interface Props {
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const t = useTranslations("lessonPreparation")
const ctx = useLessonPlanContextSafe()
const service = ctx?.service ?? null
// V4 P0-4 修复:通过 QuestionService 接口获取题目,不直接依赖 questions 模块
const questionService = useQuestionService()
const [questions, setQuestions] = useState<QuestionPickerItem[]>([])
const [picked, setPicked] = useState<ExerciseItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// P1-1 修复ESC 键关闭对话框
useEffect(() => {
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", handleEsc)
return () => document.removeEventListener("keydown", handleEsc)
}, [onClose])
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
const [searchValue, setSearchValue] = useState("")
const [typeValue, setTypeValue] = useState<string>("all")
@@ -58,36 +69,32 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const debouncedFilters = useDebounce(filters, 300)
useEffect(() => {
if (!service) return
if (!questionService) return
let cancelled = false
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
Promise.resolve()
.then(() => {
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 Promise.resolve().then()
;(async () => {
setLoading(true)
setError(null)
try {
const res = await questionService.getQuestions(debouncedFilters)
if (cancelled) return
setLoading(true)
setError(null)
return service.getQuestions(debouncedFilters)
})
.then((res) => {
if (cancelled || !res) return
if (res.success && res.data) {
setQuestions(res.data.data)
} else {
setError(res.message ?? t("error.loadFailed"))
}
})
.catch((e) => {
} catch (e) {
if (cancelled) return
console.error("[QuestionBankPicker] load questions failed", e)
setError(t("error.loadFailed"))
})
.finally(() => {
} finally {
if (!cancelled) setLoading(false)
})
}
})()
return () => {
cancelled = true
}
}, [debouncedFilters, t, service])
}, [debouncedFilters, t, questionService])
function add(q: QuestionPickerItem) {
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
@@ -119,6 +126,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
aria-label={t("questionBank.title")}
className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b">
<h3 className="font-title-md">{t("questionBank.title")}</h3>
<button onClick={onClose} aria-label={t("action.close")}>
@@ -138,9 +146,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<p className="text-on-surface-variant text-sm text-center py-8">
{t("questionBank.loading")}
</p>
<QuestionBankSkeleton />
) : error ? (
<p className="text-error text-sm text-center py-8">{error}</p>
) : questions.length === 0 ? (
@@ -177,6 +183,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
{t("questionBank.insert")}
</Button>
</div>
</FocusTrap>
</div>
</div>
)

View File

@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import type { TextbookPickerOption, ChapterPickerOption } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import { SYSTEM_TEMPLATES } from "../constants";
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
import type { LessonPlanTemplate } from "../types";
@@ -105,7 +106,8 @@ export function TemplatePicker() {
for (const ch of list) {
result.push({ id: ch.id, title: ch.title, depth });
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
walk(ch.children as ChapterPickerOption[], depth + 1);
// P1 修复ChapterPickerOption.children 已改为递归类型,无需 as 断言
walk(ch.children, depth + 1);
}
}
}
@@ -261,11 +263,13 @@ export function TemplatePicker() {
type="button"
key={tpl.id}
onClick={() => setSelected(tpl.id)}
className={`text-left p-4 border-2 rounded-lg transition-colors ${
// V4 P1-7 修复:使用 cn() 替代模板字符串拼接 className
className={cn(
"text-left p-4 border-2 rounded-lg transition-colors",
selected === tpl.id
? "border-primary bg-primary/5"
: "border-outline-variant hover:border-primary/50"
}`}
: "border-outline-variant hover:border-primary/50",
)}
>
<div className="font-title-md">{t(`template.names.${tpl.id}`)}</div>
<div className="text-sm text-on-surface-variant mt-1">
@@ -292,11 +296,13 @@ export function TemplatePicker() {
type="button"
key={tpl.id}
onClick={() => setSelected(tpl.id)}
className={`text-left p-4 border-2 rounded-lg transition-colors ${
// V4 P1-7 修复:使用 cn() 替代模板字符串拼接 className
className={cn(
"text-left p-4 border-2 rounded-lg transition-colors",
selected === tpl.id
? "border-primary bg-primary/5"
: "border-outline-variant hover:border-primary/50"
}`}
: "border-outline-variant hover:border-primary/50",
)}
>
<div className="font-title-md flex items-center gap-2">
<span className="truncate">{tpl.name}</span>

View File

@@ -0,0 +1,106 @@
/**
* M11 版本 diff 预览组件
*
* 在 version-history-drawer 中弹出,对比选中版本与当前文档的差异。
* 调用 lib/document-diff.ts 的纯函数计算 diff渲染为彩色段落。
*/
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { X } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import {
computeDocumentDiff,
summarizeDiff,
} from "../lib/document-diff";
import type { LessonPlanDocument } from "../types";
import type { LessonPlanVersion } from "../types";
interface Props {
open: boolean;
onClose: () => void;
selectedVersion: LessonPlanVersion | null;
currentDoc: LessonPlanDocument;
}
export function VersionDiffViewer({
open,
onClose,
selectedVersion,
currentDoc,
}: Props) {
const t = useTranslations("lessonPreparation");
const diffSegments = useMemo(() => {
if (!selectedVersion) return [];
const versionContent = selectedVersion.content as unknown as LessonPlanDocument;
return computeDocumentDiff(versionContent, currentDoc);
}, [selectedVersion, currentDoc]);
const summary = useMemo(() => summarizeDiff(diffSegments), [diffSegments]);
if (!open || !selectedVersion) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
<div
role="dialog"
aria-modal="true"
aria-label={t("version.diff.title", { versionNo: selectedVersion.versionNo })}
className="bg-surface rounded-lg shadow-xl w-[900px] max-h-[85vh] flex flex-col"
>
<FocusTrap className="contents">
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
<div>
<h3 className="font-title-md">
{t("version.diff.title", { versionNo: selectedVersion.versionNo })}
</h3>
<p className="text-xs text-on-surface-variant mt-1">
{t("version.diff.summary", {
added: summary.added,
removed: summary.removed,
unchanged: summary.unchanged,
})}
</p>
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label={t("action.close")}>
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-sm">
{diffSegments.length === 0 ? (
<p className="text-on-surface-variant text-center py-8">
{t("version.diff.noChanges")}
</p>
) : (
<pre className="whitespace-pre-wrap">
{diffSegments.map((seg, idx) => {
const prefix = seg.type === "added" ? "+ " : seg.type === "removed" ? "- " : " ";
const colorClass =
seg.type === "added"
? "bg-success-container/30 text-success"
: seg.type === "removed"
? "bg-error-container/30 text-error"
: "text-on-surface-variant";
return (
<div key={idx} className={`px-2 py-0.5 ${colorClass}`}>
{prefix}
{seg.content}
</div>
);
})}
</pre>
)}
</div>
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("action.close")}
</Button>
</div>
</FocusTrap>
</div>
</div>
);
}

View File

@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useLessonPlanContextSafe, useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
import { Button } from "@/shared/components/ui/button";
import { FocusTrap } from "@/shared/components/a11y/focus-trap";
import { VersionListSkeleton } from "./lesson-plan-skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -39,26 +41,33 @@ export function VersionHistoryDrawer({
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
const [loading, setLoading] = useState(false);
// P1-1 修复ESC 键关闭抽屉open 时才监听)
useEffect(() => {
if (!open) return;
function handleEsc(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [open, onClose]);
useEffect(() => {
if (!open || !service) return;
let cancelled = false;
// 用微任务延迟避免同步 setState 触发级联渲染
queueMicrotask(() => {
if (cancelled) return;
if (!service) return;
// V4 P2-3 修复:使用 async IIFE + ignore flag 替代 queueMicrotask
(async () => {
setLoading(true);
service.getLessonPlanVersions(planId)
.then((res) => {
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
})
.catch((e) => {
console.error("[VersionHistoryDrawer] load versions failed", e);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
});
try {
const res = await service.getLessonPlanVersions(planId);
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
} catch (e) {
if (cancelled) return;
console.error("[VersionHistoryDrawer] load versions failed", e);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
@@ -87,9 +96,10 @@ export function VersionHistoryDrawer({
<div className="fixed inset-0 z-50 flex">
<div className="flex-1 bg-black/30" onClick={onClose} />
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
<FocusTrap className="contents">
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? (
<p>{t("version.loading")}</p>
<VersionListSkeleton />
) : versions.length === 0 ? (
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (
@@ -138,6 +148,7 @@ export function VersionHistoryDrawer({
))}
</div>
)}
</FocusTrap>
</div>
</div>
);

View File

@@ -1,3 +1,5 @@
"use client";
import type { ReactElement } from "react";
import type {
BlockData,
@@ -47,10 +49,9 @@ export interface BlockRegistryEntry {
isRichText?: boolean;
}
const RICH_TEXT_TYPES: BlockType[] = ["rich_text", "consolidation"];
/**
* Block 注册表元数据(用于查询 isRichText 等属性)。
* Block 注册表元数据(单一数据源P0-9 修复)。
* isRichText 属性是判断富文本 block 的唯一权威来源。
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
*/
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
@@ -68,8 +69,13 @@ export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
reflection: {},
};
/**
* 判断某 BlockType 是否为富文本类。
* P0-9 修复:统一以 BLOCK_REGISTRY.isRichText 为唯一数据源,
* 删除 constants.ts 中的 RICH_TEXT_BLOCK_TYPES 和此处的 RICH_TEXT_TYPES 冗余定义。
*/
export function isRichTextBlock(type: BlockType): boolean {
return RICH_TEXT_TYPES.includes(type);
return BLOCK_REGISTRY[type]?.isRichText === true;
}
/**

View File

@@ -21,19 +21,8 @@ export const BLOCK_TYPE_KEYS: Record<BlockType, string> = {
// @deprecated 使用 BLOCK_TYPE_KEYS 或 useTranslations("lessonPreparation").blockType.${type} 替代
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = BLOCK_TYPE_KEYS;
// 富文本 block(共享同一编辑组件)
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"rich_text",
"reflection",
];
// P0-9 修复:富文本 block 类型判断统一由 block-registry.tsx 的 isRichTextBlock() 提供。
// 已删除 RICH_TEXT_BLOCK_TYPES(原 10 项定义与 isRichTextBlock 的 2 项语义冲突)。
// 系统预设模板骨架seed 用)
// V2-2 修复title/hint 存储 i18n 键,由 createLessonPlan 调用 getTranslations 翻译

View File

@@ -0,0 +1,184 @@
/**
* M8 AI 课案质量评估 - 数据访问层
*
* 评估 5 个维度clarity清晰度 alignment课标对齐/ engagement参与度
* differentiation差异化 assessment评价
* 评估结果存入 lessonPlanAiEvaluations 表。
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlanAiEvaluations } from "@/shared/db/schema";
import { eq, desc } from "drizzle-orm";
import type { LessonPlanDocument } from "./types";
/** 单次评估结果 */
export interface AiEvaluation {
id: string;
planId: string;
versionNo: number;
overallScore: number | null;
dimensionScores: DimensionScores | null;
suggestions: string | null;
recommendedStandardIds: string[] | null;
evaluatedBy: string;
createdAt: Date;
}
/** 维度评分 */
export interface DimensionScores {
clarity: number;
alignment: number;
engagement: number;
differentiation: number;
assessment: number;
}
/**
* 查询某课案的所有 AI 评估记录
*/
export async function getEvaluationsByPlanId(
planId: string,
): Promise<AiEvaluation[]> {
const rows = await db
.select()
.from(lessonPlanAiEvaluations)
.where(eq(lessonPlanAiEvaluations.planId, planId))
.orderBy(desc(lessonPlanAiEvaluations.createdAt));
return rows.map(mapRowToEvaluation);
}
/**
* 查询最新一次评估
*/
export async function getLatestEvaluation(
planId: string,
): Promise<AiEvaluation | null> {
const rows = await db
.select()
.from(lessonPlanAiEvaluations)
.where(eq(lessonPlanAiEvaluations.planId, planId))
.orderBy(desc(lessonPlanAiEvaluations.createdAt))
.limit(1);
return rows.length === 0 ? null : mapRowToEvaluation(rows[0]!);
}
/**
* 创建 AI 评估记录
*/
export async function createEvaluation(
input: {
planId: string;
versionNo: number;
overallScore?: number;
dimensionScores?: DimensionScores;
suggestions?: string;
recommendedStandardIds?: string[];
},
evaluatedBy: string,
): Promise<AiEvaluation> {
const [row] = await db.insert(lessonPlanAiEvaluations).values({
planId: input.planId,
versionNo: input.versionNo,
overallScore: input.overallScore ?? null,
dimensionScores: input.dimensionScores ?? null,
suggestions: input.suggestions ?? null,
recommendedStandardIds: input.recommendedStandardIds ?? null,
evaluatedBy,
});
const insertedId = row.insertId;
const all = await getEvaluationsByPlanId(input.planId);
const created = all.find((e) => e.id === String(insertedId));
if (!created) throw new Error("AI_EVALUATION_CREATE_FAILED");
return created;
}
/**
* 删除评估记录
*/
export async function deleteEvaluation(id: string): Promise<void> {
await db
.delete(lessonPlanAiEvaluations)
.where(eq(lessonPlanAiEvaluations.id, id));
}
// ---- AI 评估算法(本地实现,可替换为远程 API---
/**
* 评估课案文档质量
*
* 当前为基于规则的简化评估,未来可替换为 AI 模型。
* - clarity基于节点数量、标题长度、文本长度
* - alignment基于课标关联数如有
* - engagement基于互动组件数poll/quiz 计数,需传入 hasInteractiveItems
* - differentiation基于分层练习数exercise block 数)
* - assessment基于作业 block 数 + 互动组件数
*/
export function evaluateDocument(
doc: LessonPlanDocument,
options: { standardsLinkedCount?: number; hasInteractiveItems?: boolean } = {},
): { overallScore: number; dimensionScores: DimensionScores; suggestions: string } {
const nodes = doc.nodes;
const teachingNodes = nodes.filter((n) => n.type !== "textbook_content");
// clarity: 节点数 + 标题平均长度
const avgTitleLength =
teachingNodes.length > 0
? teachingNodes.reduce((sum, n) => sum + (n.title?.length ?? 0), 0) /
teachingNodes.length
: 0;
const clarity = Math.min(
100,
Math.round((teachingNodes.length >= 5 ? 50 : teachingNodes.length * 10) + avgTitleLength * 2),
);
// alignment: 课标关联数
const alignment = Math.min(100, (options.standardsLinkedCount ?? 0) * 25);
// engagement: 是否含互动组件
const engagement = options.hasInteractiveItems ? 80 : 30;
// differentiation: 练习 block 数
const exerciseBlocks = teachingNodes.filter((n) => n.type === "exercise").length;
const differentiation = Math.min(100, exerciseBlocks * 30);
// assessment: 作业 block + 互动
const homeworkBlocks = teachingNodes.filter((n) => n.type === "homework").length;
const assessment = Math.min(
100,
homeworkBlocks * 40 + (options.hasInteractiveItems ? 30 : 0),
);
const overallScore = Math.round(
(clarity + alignment + engagement + differentiation + assessment) / 5,
);
const suggestions: string[] = [];
if (clarity < 60) suggestions.push("addMoreNodeDetails");
if (alignment < 50) suggestions.push("linkMoreStandards");
if (engagement < 50) suggestions.push("addInteractiveComponents");
if (differentiation < 50) suggestions.push("addDifferentiatedExercises");
if (assessment < 50) suggestions.push("addAssessmentBlocks");
return {
overallScore,
dimensionScores: { clarity, alignment, engagement, differentiation, assessment },
suggestions: suggestions.join(","),
};
}
function mapRowToEvaluation(
row: typeof lessonPlanAiEvaluations.$inferSelect,
): AiEvaluation {
return {
id: row.id,
planId: row.planId,
versionNo: row.versionNo,
overallScore: row.overallScore,
dimensionScores: (row.dimensionScores as DimensionScores) ?? null,
suggestions: row.suggestions,
recommendedStandardIds:
(row.recommendedStandardIds as string[] | null) ?? null,
evaluatedBy: row.evaluatedBy,
createdAt: row.createdAt,
};
}

View File

@@ -0,0 +1,241 @@
/**
* M10 备课分析仪表盘 - 数据访问层
*
* 查询备课分析快照表 + 课标覆盖热力图 + 模板使用率统计。
* 用于 admin 备课分析仪表盘的 3 张图表:
* - 教师备课投入(按日聚合)
* - 模板使用率(按 template 分组)
* - 课标覆盖热力图(按 subject+grade 矩阵)
*/
import "server-only";
import { db } from "@/shared/db";
import {
lessonPlanAnalyticsDaily,
lessonPlans,
lessonPlanStandards,
} from "@/shared/db/schema";
import { and, eq, gte, lte, desc, sql, count } from "drizzle-orm";
import type { LessonPlanStatus } from "./types";
/** 教师备课投入数据点 */
export interface TeacherInvestmentDataPoint {
snapshotDate: Date;
teacherId: string;
newPlansCount: number;
editDurationMin: number;
savedVersionsCount: number;
submittedCount: number;
publishedCount: number;
standardsLinked: number;
}
/** 模板使用率数据点 */
export interface TemplateUsageDataPoint {
templateId: string;
templateName: string;
usageCount: number;
}
/** 课标覆盖热力图单元 */
export interface StandardsCoverageCell {
subjectId: string | null;
gradeId: string | null;
totalPlans: number;
standardsLinkedPlans: number;
coveragePercent: number;
}
/**
* 查询教师备课投入(按日期范围)
*/
export async function getTeacherInvestment(
startDate: Date,
endDate: Date,
teacherId?: string,
): Promise<TeacherInvestmentDataPoint[]> {
const conditions = [
gte(lessonPlanAnalyticsDaily.snapshotDate, startDate),
lte(lessonPlanAnalyticsDaily.snapshotDate, endDate),
];
if (teacherId) conditions.push(eq(lessonPlanAnalyticsDaily.teacherId, teacherId));
const rows = await db
.select()
.from(lessonPlanAnalyticsDaily)
.where(and(...conditions))
.orderBy(desc(lessonPlanAnalyticsDaily.snapshotDate));
return rows.map((r) => ({
snapshotDate: r.snapshotDate,
teacherId: r.teacherId,
newPlansCount: r.newPlansCount,
editDurationMin: r.editDurationMin,
savedVersionsCount: r.savedVersionsCount,
submittedCount: r.submittedCount,
publishedCount: r.publishedCount,
standardsLinked: r.standardsLinked,
}));
}
/**
* 查询模板使用率(按 templateId 分组)
*/
export async function getTemplateUsageStats(): Promise<TemplateUsageDataPoint[]> {
const rows = await db
.select({
templateId: lessonPlans.templateId,
templateName: lessonPlans.templateName,
usageCount: count(lessonPlans.id),
})
.from(lessonPlans)
.where(sql`${lessonPlans.templateId} IS NOT NULL`)
.groupBy(lessonPlans.templateId, lessonPlans.templateName)
.orderBy(desc(count(lessonPlans.id)));
return rows.map((r) => ({
templateId: r.templateId!,
templateName: r.templateName ?? "(unknown)",
usageCount: r.usageCount,
}));
}
/**
* 查询课标覆盖热力图(按 subject+grade 矩阵)
*/
export async function getStandardsCoverageHeatmap(): Promise<
StandardsCoverageCell[]
> {
// 总课案数(按 subject+grade 分组)
const totalRows = await db
.select({
subjectId: lessonPlans.subjectId,
gradeId: lessonPlans.gradeId,
total: count(lessonPlans.id),
})
.from(lessonPlans)
.where(sql`${lessonPlans.subjectId} IS NOT NULL AND ${lessonPlans.gradeId} IS NOT NULL`)
.groupBy(lessonPlans.subjectId, lessonPlans.gradeId);
// 关联课标的课案数(按 subject+grade 分组)
const linkedRows = await db
.select({
subjectId: lessonPlans.subjectId,
gradeId: lessonPlans.gradeId,
linkedCount: sql<number>`COUNT(DISTINCT ${lessonPlanStandards.planId})`,
})
.from(lessonPlanStandards)
.innerJoin(lessonPlans, eq(lessonPlanStandards.planId, lessonPlans.id))
.where(sql`${lessonPlans.subjectId} IS NOT NULL AND ${lessonPlans.gradeId} IS NOT NULL`)
.groupBy(lessonPlans.subjectId, lessonPlans.gradeId);
const linkedMap = new Map<string, number>();
for (const r of linkedRows) {
const key = `${r.subjectId ?? ""}|${r.gradeId ?? ""}`;
linkedMap.set(key, Number(r.linkedCount));
}
return totalRows.map((r) => {
const key = `${r.subjectId ?? ""}|${r.gradeId ?? ""}`;
const linkedCount = linkedMap.get(key) ?? 0;
const total = Number(r.total);
return {
subjectId: r.subjectId,
gradeId: r.gradeId,
totalPlans: total,
standardsLinkedPlans: linkedCount,
coveragePercent: total > 0 ? Math.round((linkedCount / total) * 100) : 0,
};
});
}
/**
* 查询全局备课统计(仪表盘顶部卡片)
*/
export async function getGlobalLessonPlanStats(): Promise<{
totalTeachers: number;
totalPlans: number;
totalPublished: number;
totalSubmitted: number;
totalStandardsLinked: number;
averageScore: number | null;
}> {
const totalTeachersRows = await db
.select({ count: sql<number>`COUNT(DISTINCT ${lessonPlans.creatorId})` })
.from(lessonPlans);
const totalPlansRows = await db
.select({ count: count() })
.from(lessonPlans);
const publishedRows = await db
.select({ count: count() })
.from(lessonPlans)
.where(eq(lessonPlans.status, "published" as LessonPlanStatus));
const submittedRows = await db
.select({ count: count() })
.from(lessonPlans)
.where(eq(lessonPlans.status, "submitted" as LessonPlanStatus));
const standardsRows = await db
.select({ count: count() })
.from(lessonPlanStandards);
return {
totalTeachers: Number(totalTeachersRows[0]?.count ?? 0),
totalPlans: Number(totalPlansRows[0]?.count ?? 0),
totalPublished: Number(publishedRows[0]?.count ?? 0),
totalSubmitted: Number(submittedRows[0]?.count ?? 0),
totalStandardsLinked: Number(standardsRows[0]?.count ?? 0),
averageScore: null, // TODO: 聚合 lessonPlanAiEvaluations
};
}
/**
* 写入或更新当日分析快照(教师保存版本时触发)
*/
export async function upsertDailyAnalytics(
snapshotDate: Date,
teacherId: string,
subjectId: string | null,
gradeId: string | null,
patch: Partial<TeacherInvestmentDataPoint>,
): Promise<void> {
// 简化实现:先尝试更新,不存在则插入
const existing = await db
.select()
.from(lessonPlanAnalyticsDaily)
.where(
and(
eq(lessonPlanAnalyticsDaily.snapshotDate, snapshotDate),
eq(lessonPlanAnalyticsDaily.teacherId, teacherId),
),
)
.limit(1);
if (existing.length > 0) {
const row = existing[0]!;
await db
.update(lessonPlanAnalyticsDaily)
.set({
newPlansCount: (row.newPlansCount ?? 0) + (patch.newPlansCount ?? 0),
editDurationMin:
(row.editDurationMin ?? 0) + (patch.editDurationMin ?? 0),
savedVersionsCount:
(row.savedVersionsCount ?? 0) + (patch.savedVersionsCount ?? 0),
submittedCount: (row.submittedCount ?? 0) + (patch.submittedCount ?? 0),
publishedCount: (row.publishedCount ?? 0) + (patch.publishedCount ?? 0),
standardsLinked: (row.standardsLinked ?? 0) + (patch.standardsLinked ?? 0),
})
.where(eq(lessonPlanAnalyticsDaily.id, row.id));
} else {
await db.insert(lessonPlanAnalyticsDaily).values({
snapshotDate,
teacherId,
subjectId,
gradeId,
newPlansCount: patch.newPlansCount ?? 0,
editDurationMin: patch.editDurationMin ?? 0,
savedVersionsCount: patch.savedVersionsCount ?? 0,
submittedCount: patch.submittedCount ?? 0,
publishedCount: patch.publishedCount ?? 0,
standardsLinked: patch.standardsLinked ?? 0,
});
}
}

View File

@@ -0,0 +1,141 @@
/**
* M6 资源附件库 - 数据访问层
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlanAttachments } from "@/shared/db/schema";
import { and, eq, desc } from "drizzle-orm";
import type { AttachmentType } from "./lib/type-guards";
import { isAttachmentType } from "./lib/type-guards";
/** 附件记录 */
export interface LessonPlanAttachment {
id: string;
planId: string;
blockId?: string;
fileId: string;
displayName: string;
attachmentType: AttachmentType;
uploadedBy: string;
createdAt: Date;
}
/**
* 查询课案的所有附件
*/
export async function getAttachmentsByPlanId(
planId: string,
): Promise<LessonPlanAttachment[]> {
const rows = await db
.select()
.from(lessonPlanAttachments)
.where(eq(lessonPlanAttachments.planId, planId))
.orderBy(desc(lessonPlanAttachments.createdAt));
return rows.map(mapRowToAttachment);
}
/**
* 查询特定 Block 的附件
*/
export async function getAttachmentsByBlockId(
planId: string,
blockId: string,
): Promise<LessonPlanAttachment[]> {
const rows = await db
.select()
.from(lessonPlanAttachments)
.where(
and(
eq(lessonPlanAttachments.planId, planId),
eq(lessonPlanAttachments.blockId, blockId),
),
)
.orderBy(desc(lessonPlanAttachments.createdAt));
return rows.map(mapRowToAttachment);
}
/**
* 添加附件
*/
export async function createAttachment(
input: {
planId: string;
blockId?: string;
fileId: string;
displayName: string;
attachmentType?: AttachmentType;
},
uploadedBy: string,
): Promise<LessonPlanAttachment> {
const [row] = await db.insert(lessonPlanAttachments).values({
planId: input.planId,
blockId: input.blockId,
fileId: input.fileId,
displayName: input.displayName,
attachmentType: input.attachmentType ?? "reference",
uploadedBy,
});
const insertedId = row.insertId;
const all = await getAttachmentsByPlanId(input.planId);
const created = all.find((a) => a.id === String(insertedId));
if (!created) throw new Error("ATTACHMENT_CREATE_FAILED");
return created;
}
/**
* 删除附件
*/
export async function deleteAttachment(
attachmentId: string,
): Promise<void> {
await db
.delete(lessonPlanAttachments)
.where(eq(lessonPlanAttachments.id, attachmentId));
}
/**
* 更新附件类型
*/
export async function updateAttachmentType(
attachmentId: string,
attachmentType: AttachmentType,
): Promise<void> {
await db
.update(lessonPlanAttachments)
.set({ attachmentType })
.where(eq(lessonPlanAttachments.id, attachmentId));
}
/**
* 按 ID 获取附件
*/
export async function getAttachmentById(
id: string,
): Promise<LessonPlanAttachment | null> {
const rows = await db
.select()
.from(lessonPlanAttachments)
.where(eq(lessonPlanAttachments.id, id))
.limit(1);
return rows.length === 0 ? null : mapRowToAttachment(rows[0]!);
}
function mapRowToAttachment(
row: typeof lessonPlanAttachments.$inferSelect,
): LessonPlanAttachment {
const attachmentType = isAttachmentType(row.attachmentType)
? row.attachmentType
: "reference";
return {
id: row.id,
planId: row.planId,
blockId: row.blockId ?? undefined,
fileId: row.fileId,
displayName: row.displayName,
attachmentType,
uploadedBy: row.uploadedBy,
createdAt: row.createdAt,
};
}

View File

@@ -0,0 +1,178 @@
/**
* M9 日历视图 - 数据访问层
*
* 按日期范围查询教师备课记录,用于周历/月历展示。
* 关联查询 createdAt/updatedAt/submittedAt 用于在日历上标记事件。
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlans, lessonPlanVersions, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, gte, lte, asc } from "drizzle-orm";
import type { LessonPlanStatus } from "./types";
/** 日历事件 */
export interface LessonPlanCalendarEvent {
id: string;
planId: string;
title: string;
status: LessonPlanStatus;
eventType: "created" | "updated" | "version_saved" | "submitted" | "published" | "reviewed";
occurredAt: Date;
creatorId: string;
versionNo?: number;
}
/**
* 按日期范围查询教师备课日历事件
*/
export async function getCalendarEvents(
teacherId: string,
startDate: Date,
endDate: Date,
): Promise<LessonPlanCalendarEvent[]> {
const events: LessonPlanCalendarEvent[] = [];
// 1. 课案创建/更新事件
const planRows = await db
.select({
id: lessonPlans.id,
title: lessonPlans.title,
status: lessonPlans.status,
createdAt: lessonPlans.createdAt,
updatedAt: lessonPlans.updatedAt,
creatorId: lessonPlans.creatorId,
})
.from(lessonPlans)
.where(
and(
eq(lessonPlans.creatorId, teacherId),
gte(lessonPlans.updatedAt, startDate),
lte(lessonPlans.updatedAt, endDate),
),
)
.orderBy(asc(lessonPlans.updatedAt));
for (const p of planRows) {
// 创建事件
if (p.createdAt >= startDate && p.createdAt <= endDate) {
events.push({
id: `${p.id}-created`,
planId: p.id,
title: p.title,
status: p.status as LessonPlanStatus,
eventType: "created",
occurredAt: p.createdAt,
creatorId: p.creatorId,
});
}
// 更新事件(如果不是同一天创建的)
if (p.updatedAt > p.createdAt) {
events.push({
id: `${p.id}-updated`,
planId: p.id,
title: p.title,
status: p.status as LessonPlanStatus,
eventType: "updated",
occurredAt: p.updatedAt,
creatorId: p.creatorId,
});
}
// 提交/发布事件
if (p.status === "submitted" as LessonPlanStatus || p.status === "published" as LessonPlanStatus) {
events.push({
id: `${p.id}-${p.status}`,
planId: p.id,
title: p.title,
status: p.status as LessonPlanStatus,
eventType: p.status === "submitted" ? "submitted" : "published",
occurredAt: p.updatedAt,
creatorId: p.creatorId,
});
}
}
// 2. 版本保存事件
const versionRows = await db
.select({
id: lessonPlanVersions.id,
planId: lessonPlanVersions.planId,
versionNo: lessonPlanVersions.versionNo,
createdAt: lessonPlanVersions.createdAt,
title: lessonPlans.title,
creatorId: lessonPlanVersions.creatorId,
})
.from(lessonPlanVersions)
.innerJoin(lessonPlans, eq(lessonPlanVersions.planId, lessonPlans.id))
.where(
and(
eq(lessonPlanVersions.creatorId, teacherId),
gte(lessonPlanVersions.createdAt, startDate),
lte(lessonPlanVersions.createdAt, endDate),
),
)
.orderBy(asc(lessonPlanVersions.createdAt));
for (const v of versionRows) {
events.push({
id: v.id,
planId: v.planId,
title: v.title,
status: "draft" as LessonPlanStatus,
eventType: "version_saved",
occurredAt: v.createdAt,
creatorId: v.creatorId,
versionNo: v.versionNo,
});
}
// 3. 审核事件
const reviewRows = await db
.select({
id: lessonPlanReviewRecords.id,
planId: lessonPlanReviewRecords.planId,
decision: lessonPlanReviewRecords.decision,
createdAt: lessonPlanReviewRecords.createdAt,
reviewerId: lessonPlanReviewRecords.reviewerId,
title: lessonPlans.title,
})
.from(lessonPlanReviewRecords)
.innerJoin(lessonPlans, eq(lessonPlanReviewRecords.planId, lessonPlans.id))
.where(
and(
eq(lessonPlanReviewRecords.reviewerId, teacherId),
gte(lessonPlanReviewRecords.createdAt, startDate),
lte(lessonPlanReviewRecords.createdAt, endDate),
),
);
for (const r of reviewRows) {
events.push({
id: r.id,
planId: r.planId,
title: r.title,
status: "approved" as LessonPlanStatus,
eventType: "reviewed",
occurredAt: r.createdAt,
creatorId: r.reviewerId,
});
}
// 按时间排序
return events.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
}
/**
* 按日期分组日历事件
*/
export function groupEventsByDate(
events: LessonPlanCalendarEvent[],
): Map<string, LessonPlanCalendarEvent[]> {
const map = new Map<string, LessonPlanCalendarEvent[]>();
for (const e of events) {
const dateKey = e.occurredAt.toISOString().split("T")[0]!;
const list = map.get(dateKey) ?? [];
list.push(e);
map.set(dateKey, list);
}
return map;
}

View File

@@ -0,0 +1,168 @@
/**
* M2 协同备课 - Block 级评论数据访问层
*
* 评论挂载到 Block 级别支持嵌套回复parentCommentId与解决状态resolved
* 实时多人编辑部分Yjs/Liveblocks需要单独的 WebSocket 服务,本文件仅实现评论 CRUD。
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlanComments } from "@/shared/db/schema";
import { and, eq, asc, isNull } from "drizzle-orm";
/** 评论记录 */
export interface LessonPlanComment {
id: string;
planId: string;
blockId: string;
content: string;
authorId: string;
resolved: boolean;
parentCommentId?: string;
createdAt: Date;
updatedAt: Date;
}
/**
* 查询课案的所有评论(含子回复)
*/
export async function getCommentsByPlanId(
planId: string,
): Promise<LessonPlanComment[]> {
const rows = await db
.select()
.from(lessonPlanComments)
.where(eq(lessonPlanComments.planId, planId))
.orderBy(asc(lessonPlanComments.createdAt));
return rows.map(mapRowToComment);
}
/**
* 查询特定 Block 的评论
*/
export async function getCommentsByBlockId(
planId: string,
blockId: string,
): Promise<LessonPlanComment[]> {
const rows = await db
.select()
.from(lessonPlanComments)
.where(
and(
eq(lessonPlanComments.planId, planId),
eq(lessonPlanComments.blockId, blockId),
),
)
.orderBy(asc(lessonPlanComments.createdAt));
return rows.map(mapRowToComment);
}
/**
* 创建评论
*/
export async function createComment(
input: {
planId: string;
blockId: string;
content: string;
parentCommentId?: string;
},
authorId: string,
): Promise<LessonPlanComment> {
const [row] = await db.insert(lessonPlanComments).values({
planId: input.planId,
blockId: input.blockId,
content: input.content,
authorId,
resolved: false,
parentCommentId: input.parentCommentId,
});
const insertedId = row.insertId;
const all = await getCommentsByPlanId(input.planId);
const created = all.find((c) => c.id === String(insertedId));
if (!created) throw new Error("COMMENT_CREATE_FAILED");
return created;
}
/**
* 更新评论内容
*/
export async function updateCommentContent(
commentId: string,
content: string,
): Promise<void> {
await db
.update(lessonPlanComments)
.set({ content, updatedAt: new Date() })
.where(eq(lessonPlanComments.id, commentId));
}
/**
* 切换评论解决状态
*/
export async function toggleCommentResolved(
commentId: string,
): Promise<void> {
const rows = await db
.select({ resolved: lessonPlanComments.resolved })
.from(lessonPlanComments)
.where(eq(lessonPlanComments.id, commentId))
.limit(1);
if (rows.length === 0) return;
const newResolved = !rows[0]!.resolved;
await db
.update(lessonPlanComments)
.set({ resolved: newResolved, updatedAt: new Date() })
.where(eq(lessonPlanComments.id, commentId));
}
/**
* 删除评论(递归删除子回复)
*/
export async function deleteComment(commentId: string): Promise<void> {
// 先删除所有子回复
const children = await db
.select({ id: lessonPlanComments.id })
.from(lessonPlanComments)
.where(eq(lessonPlanComments.parentCommentId, commentId));
for (const child of children) {
await deleteComment(child.id);
}
await db
.delete(lessonPlanComments)
.where(eq(lessonPlanComments.id, commentId));
}
/**
* 统计课案未解决评论数
*/
export async function countUnresolvedComments(planId: string): Promise<number> {
const rows = await db
.select({ id: lessonPlanComments.id })
.from(lessonPlanComments)
.where(
and(
eq(lessonPlanComments.planId, planId),
eq(lessonPlanComments.resolved, false),
isNull(lessonPlanComments.parentCommentId),
),
);
return rows.length;
}
function mapRowToComment(
row: typeof lessonPlanComments.$inferSelect,
): LessonPlanComment {
return {
id: row.id,
planId: row.planId,
blockId: row.blockId,
content: row.content,
authorId: row.authorId,
resolved: row.resolved,
parentCommentId: row.parentCommentId ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}

View File

@@ -0,0 +1,264 @@
/**
* M5 形成性评价闭环 - 数据访问层
*
* publish 课案时嵌入互动组件poll/quiz/exit_ticket
* 学生课中作答后结果回写到 lessonPlanFormativeResponses 表,
* 教师可查看实时反馈,闭环到课案优化。
*/
import "server-only";
import { db } from "@/shared/db";
import {
lessonPlanFormativeItems,
lessonPlanFormativeResponses,
} from "@/shared/db/schema";
import { eq, desc, asc } from "drizzle-orm";
import type { FormativeInteractionType } from "./lib/type-guards";
import { isFormativeInteractionType } from "./lib/type-guards";
/** 互动组件项 */
export interface FormativeItem {
id: string;
planId: string;
blockId: string;
interactionType: FormativeInteractionType;
payload: unknown;
instantFeedback: boolean;
orderIndex: number;
createdAt: Date;
updatedAt: Date;
}
/** 学生作答记录 */
export interface FormativeResponse {
id: string;
itemId: string;
studentId: string;
classId?: string;
response: unknown;
isCorrect?: boolean;
durationSec?: number;
createdAt: Date;
}
/**
* 查询课案的所有互动组件
*/
export async function getFormativeItemsByPlanId(
planId: string,
): Promise<FormativeItem[]> {
const rows = await db
.select()
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.planId, planId))
.orderBy(asc(lessonPlanFormativeItems.orderIndex));
return rows.map(mapRowToItem);
}
/**
* 查询单个互动组件
*/
export async function getFormativeItemById(
id: string,
): Promise<FormativeItem | null> {
const rows = await db
.select()
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.id, id))
.limit(1);
return rows.length === 0 ? null : mapRowToItem(rows[0]!);
}
/**
* 创建互动组件
*/
export async function createFormativeItem(
input: {
planId: string;
blockId: string;
interactionType: FormativeInteractionType;
payload: unknown;
instantFeedback?: boolean;
orderIndex?: number;
},
): Promise<FormativeItem> {
const [row] = await db.insert(lessonPlanFormativeItems).values({
planId: input.planId,
blockId: input.blockId,
interactionType: input.interactionType,
payload: input.payload,
instantFeedback: input.instantFeedback ?? false,
orderIndex: input.orderIndex ?? 0,
});
const insertedId = row.insertId;
const created = await getFormativeItemById(String(insertedId));
if (!created) throw new Error("FORMATIVE_ITEM_CREATE_FAILED");
return created;
}
/**
* 更新互动组件
*/
export async function updateFormativeItem(
id: string,
patch: {
payload?: unknown;
instantFeedback?: boolean;
orderIndex?: number;
},
): Promise<void> {
await db
.update(lessonPlanFormativeItems)
.set({
...(patch.payload !== undefined ? { payload: patch.payload } : {}),
...(patch.instantFeedback !== undefined ? { instantFeedback: patch.instantFeedback } : {}),
...(patch.orderIndex !== undefined ? { orderIndex: patch.orderIndex } : {}),
})
.where(eq(lessonPlanFormativeItems.id, id));
}
/**
* 删除互动组件
*/
export async function deleteFormativeItem(id: string): Promise<void> {
await db
.delete(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.id, id));
}
/**
* 提交学生作答
*/
export async function submitFormativeResponse(
input: {
itemId: string;
studentId: string;
classId?: string;
response: unknown;
isCorrect?: boolean;
durationSec?: number;
},
): Promise<FormativeResponse> {
const [row] = await db.insert(lessonPlanFormativeResponses).values({
itemId: input.itemId,
studentId: input.studentId,
classId: input.classId,
response: input.response,
isCorrect: input.isCorrect,
durationSec: input.durationSec,
});
const insertedId = row.insertId;
const items = await getResponsesByItemId(input.itemId);
const created = items.find((r) => r.id === String(insertedId));
if (!created) throw new Error("FORMATIVE_RESPONSE_CREATE_FAILED");
return created;
}
/**
* 查询互动组件的所有作答
*/
export async function getResponsesByItemId(
itemId: string,
): Promise<FormativeResponse[]> {
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId))
.orderBy(desc(lessonPlanFormativeResponses.createdAt));
return rows.map(mapRowToResponse);
}
/**
* 查询某学生作答历史
*/
export async function getResponsesByStudentId(
studentId: string,
planId?: string,
): Promise<FormativeResponse[]> {
// 简化查询:按 studentId 查询,可选按 planId 过滤
if (planId) {
const items = await db
.select({ id: lessonPlanFormativeItems.id })
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.planId, planId));
if (items.length === 0) return [];
const itemIds = items.map((i) => i.id);
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.studentId, studentId));
return rows.filter((r) => itemIds.includes(r.itemId)).map(mapRowToResponse);
}
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.studentId, studentId));
return rows.map(mapRowToResponse);
}
/**
* 统计互动组件的作答情况(用于教师查看实时反馈)
*/
export async function getFormativeItemStats(
itemId: string,
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId));
const total = rows.length;
let correct = 0;
let incorrect = 0;
let totalDuration = 0;
let durationCount = 0;
for (const r of rows) {
if (r.isCorrect === true) correct++;
else if (r.isCorrect === false) incorrect++;
if (r.durationSec !== null && r.durationSec !== undefined) {
totalDuration += r.durationSec;
durationCount++;
}
}
return {
total,
correct,
incorrect,
avgDurationSec: durationCount > 0 ? Math.round(totalDuration / durationCount) : 0,
};
}
function mapRowToItem(
row: typeof lessonPlanFormativeItems.$inferSelect,
): FormativeItem {
const interactionType = isFormativeInteractionType(row.interactionType)
? row.interactionType
: "poll";
return {
id: row.id,
planId: row.planId,
blockId: row.blockId,
interactionType,
payload: row.payload,
instantFeedback: row.instantFeedback,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapRowToResponse(
row: typeof lessonPlanFormativeResponses.$inferSelect,
): FormativeResponse {
return {
id: row.id,
itemId: row.itemId,
studentId: row.studentId,
classId: row.classId ?? undefined,
response: row.response,
isCorrect: row.isCorrect ?? undefined,
durationSec: row.durationSec ?? undefined,
createdAt: row.createdAt,
};
}

View File

@@ -1,95 +1,140 @@
import "server-only";
import { like } from "drizzle-orm";
import { and, eq, like, sql } from "drizzle-orm";
import { db } from "@/shared/db";
import { lessonPlans } from "@/shared/db/schema";
import { escapeLikePattern } from "@/shared/lib/action-utils";
import { isRecord } from "@/shared/lib/type-guards";
import { isLessonPlanStatus } from "./lib/type-guards";
import { normalizeDocument } from "./data-access";
import type { LessonPlanListItem } from "./types";
// ---- 安全字段提取辅助(替代 as 断言,从 BlockData 联合类型收窄)----
// 类型守卫:判断 unknown 是否为 string[](替代 as string[] 断言)
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
function getStringArray(v: unknown): string[] | undefined {
return isStringArray(v) ? v : undefined;
}
/** 从 block.data 安全读取 knowledgePointIds 字段 */
function getKnowledgePointIds(data: unknown): string[] | undefined {
return isRecord(data) ? getStringArray(data.knowledgePointIds) : undefined;
}
/** 从 block.data 安全读取 items 字段中的 questionId 列表 */
function getQuestionIds(data: unknown): string[] {
if (!isRecord(data) || !Array.isArray(data.items)) return [];
return data.items
.filter((it): it is Record<string, unknown> => isRecord(it))
.map((it) => it.questionId)
.filter((id): id is string => typeof id === "string");
}
/**
* 将 DB 行映射为 LessonPlanListItem无 join 版本)。
* V4 P1-15 修复:提取共享辅助函数,消除 getLessonPlansByKnowledgePoint 与
* getLessonPlansByQuestion 中的重复 .map 实现。
*/
function mapRowToListItemWithoutJoin(r: {
id: string;
title: string;
textbookId: string | null;
chapterId: string | null;
coursePlanItemId: string | null;
subjectId: string | null;
gradeId: string | null;
templateId: string | null;
templateName: string | null;
content: unknown;
status: string;
creatorId: string;
lastSavedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): LessonPlanListItem {
return {
id: r.id,
title: r.title,
textbookId: r.textbookId,
chapterId: r.chapterId,
coursePlanItemId: r.coursePlanItemId,
subjectId: r.subjectId,
gradeId: r.gradeId,
templateId: r.templateId,
templateName: r.templateName,
content: normalizeDocument(r.content),
status: isLessonPlanStatus(r.status) ? r.status : "draft",
creatorId: r.creatorId,
lastSavedAt: r.lastSavedAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
textbookTitle: null,
chapterTitle: null,
subjectName: null,
gradeName: null,
creatorName: null,
versionCount: 1,
versions: [],
};
}
// 查询关联了某知识点的课案
export async function getLessonPlansByKnowledgePoint(
knowledgePointId: string,
userId: string,
): Promise<LessonPlanListItem[]> {
// content 是 JSON用 LIKE 粗筛后内存精确过滤
const rows = await db
.select()
.from(lessonPlans)
.where(like(lessonPlans.content, `%${knowledgePointId}%`));
.where(
and(
eq(lessonPlans.creatorId, userId),
sql`${lessonPlans.status} != 'archived'`,
like(lessonPlans.content, `%${escapeLikePattern(knowledgePointId)}%`),
),
);
// 类型守卫:过滤出包含该知识点的课案,并映射为 LessonPlanListItem
return rows
.filter((r) => {
const doc = normalizeDocument(r.content);
return doc.nodes.some((b) => {
const data = b.data as { knowledgePointIds?: string[] };
return data?.knowledgePointIds?.includes(knowledgePointId);
// P1 修复:使用安全字段提取替代 as unknown as 断言
const kpIds = getKnowledgePointIds(b.data);
return kpIds?.includes(knowledgePointId);
});
})
.map((r) => ({
id: r.id,
title: r.title,
textbookId: r.textbookId,
chapterId: r.chapterId,
coursePlanItemId: r.coursePlanItemId,
subjectId: r.subjectId,
gradeId: r.gradeId,
templateId: r.templateId,
templateName: r.templateName,
content: normalizeDocument(r.content),
status: r.status as LessonPlanListItem["status"],
creatorId: r.creatorId,
lastSavedAt: r.lastSavedAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
textbookTitle: null,
chapterTitle: null,
subjectName: null,
gradeName: null,
creatorName: null,
versionCount: 1,
versions: [],
}));
.map(mapRowToListItemWithoutJoin);
}
// 查询使用了某题目的课案
export async function getLessonPlansByQuestion(
questionId: string,
userId: string,
): Promise<LessonPlanListItem[]> {
const rows = await db
.select()
.from(lessonPlans)
.where(like(lessonPlans.content, `%${questionId}%`));
.where(
and(
eq(lessonPlans.creatorId, userId),
sql`${lessonPlans.status} != 'archived'`,
like(lessonPlans.content, `%${escapeLikePattern(questionId)}%`),
),
);
return rows
.filter((r) => {
const doc = normalizeDocument(r.content);
return doc.nodes.some((b) => {
if (b.type !== "exercise") return false;
const data = b.data as { items?: Array<{ questionId: string }> };
return data?.items?.some((it) => it.questionId === questionId);
// P1 修复:使用安全字段提取替代 as unknown as 断言
const questionIds = getQuestionIds(b.data);
return questionIds.includes(questionId);
});
})
.map((r) => ({
id: r.id,
title: r.title,
textbookId: r.textbookId,
chapterId: r.chapterId,
coursePlanItemId: r.coursePlanItemId,
subjectId: r.subjectId,
gradeId: r.gradeId,
templateId: r.templateId,
templateName: r.templateName,
content: normalizeDocument(r.content),
status: r.status as LessonPlanListItem["status"],
creatorId: r.creatorId,
lastSavedAt: r.lastSavedAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
textbookTitle: null,
chapterTitle: null,
subjectName: null,
gradeName: null,
creatorName: null,
versionCount: 1,
versions: [],
}));
.map(mapRowToListItemWithoutJoin);
}

View File

@@ -0,0 +1,272 @@
/**
* M3 审核工作台 - 数据访问层
*
* 处理课案状态迁移、审核记录、审核队列查询。
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlans, lessonPlanReviewRecords } from "@/shared/db/schema";
import { and, eq, desc, asc, inArray } from "drizzle-orm";
import type { LessonPlanStatus } from "./types";
import { LESSON_PLAN_STATUS_TRANSITIONS, isReviewableStatus } from "./types";
/** 审核记录 */
export interface LessonPlanReviewRecord {
id: string;
planId: string;
reviewerId: string;
decision: "approved" | "rejected";
comment?: string;
previousStatus: LessonPlanStatus;
newStatus: LessonPlanStatus;
createdAt: Date;
}
/**
* 校验状态迁移合法性
*/
export function isValidTransition(
from: LessonPlanStatus,
to: LessonPlanStatus,
): boolean {
return LESSON_PLAN_STATUS_TRANSITIONS[from]?.includes(to) ?? false;
}
/**
* 提交课案审核draft/rejected → submitted
*/
export async function submitForReview(
planId: string,
_userId: string,
): Promise<LessonPlanStatus> {
const plan = await db
.select({ status: lessonPlans.status })
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
const currentStatus = plan[0]!.status as LessonPlanStatus;
if (!isReviewableStatus(currentStatus) && currentStatus !== "draft" && currentStatus !== "rejected") {
throw new Error("INVALID_STATUS_TRANSITION");
}
await db
.update(lessonPlans)
.set({ status: "submitted", updatedAt: new Date() })
.where(eq(lessonPlans.id, planId));
return "submitted";
}
/**
* 审核课案submitted → approved/rejected
* 同时写入审核记录
*/
export async function reviewPlan(
planId: string,
reviewerId: string,
decision: "approved" | "rejected",
comment?: string,
): Promise<{ newStatus: LessonPlanStatus; record: LessonPlanReviewRecord }> {
const plan = await db
.select({ status: lessonPlans.status })
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
const previousStatus = plan[0]!.status as LessonPlanStatus;
if (!isReviewableStatus(previousStatus)) {
throw new Error("PLAN_NOT_REVIEWABLE");
}
const newStatus: LessonPlanStatus = decision === "approved" ? "approved" : "rejected";
if (!isValidTransition(previousStatus, newStatus)) {
throw new Error("INVALID_STATUS_TRANSITION");
}
// 事务:更新状态 + 写入审核记录
await db.transaction(async (tx) => {
await tx
.update(lessonPlans)
.set({ status: newStatus, updatedAt: new Date() })
.where(eq(lessonPlans.id, planId));
await tx.insert(lessonPlanReviewRecords).values({
planId,
reviewerId,
decision,
comment: comment ?? null,
previousStatus,
newStatus,
});
});
const record: LessonPlanReviewRecord = {
id: "",
planId,
reviewerId,
decision,
comment,
previousStatus,
newStatus,
createdAt: new Date(),
};
return { newStatus, record };
}
/**
* 查询课案的所有审核记录
*/
export async function getReviewRecordsByPlanId(
planId: string,
): Promise<LessonPlanReviewRecord[]> {
const rows = await db
.select()
.from(lessonPlanReviewRecords)
.where(eq(lessonPlanReviewRecords.planId, planId))
.orderBy(desc(lessonPlanReviewRecords.createdAt));
return rows.map((r) => ({
id: r.id,
planId: r.planId,
reviewerId: r.reviewerId,
decision: r.decision as "approved" | "rejected",
comment: r.comment ?? undefined,
previousStatus: r.previousStatus as LessonPlanStatus,
newStatus: r.newStatus as LessonPlanStatus,
createdAt: r.createdAt,
}));
}
/**
* 查询待审核队列(教研组长用)
*/
export async function getPendingReviewPlans(
reviewerGradeIds?: string[],
reviewerSubjectIds?: string[],
): Promise<
Array<{
id: string;
title: string;
status: LessonPlanStatus;
creatorId: string;
gradeId: string | null;
subjectId: string | null;
submittedAt: Date;
}>
> {
const conditions = [eq(lessonPlans.status, "submitted")];
const rows = await db
.select({
id: lessonPlans.id,
title: lessonPlans.title,
status: lessonPlans.status,
creatorId: lessonPlans.creatorId,
gradeId: lessonPlans.gradeId,
subjectId: lessonPlans.subjectId,
submittedAt: lessonPlans.updatedAt,
})
.from(lessonPlans)
.where(and(...conditions))
.orderBy(asc(lessonPlans.updatedAt));
// 按教研组长权限过滤gradeId/subjectId
return rows
.filter((r) => {
if (reviewerGradeIds && reviewerGradeIds.length > 0 && !reviewerGradeIds.includes(r.gradeId ?? "")) {
return false;
}
if (reviewerSubjectIds && reviewerSubjectIds.length > 0 && !reviewerSubjectIds.includes(r.subjectId ?? "")) {
return false;
}
return true;
})
.map((r) => ({
id: r.id,
title: r.title,
status: r.status as LessonPlanStatus,
creatorId: r.creatorId,
gradeId: r.gradeId,
subjectId: r.subjectId,
submittedAt: r.submittedAt,
}));
}
/**
* 撤回审核教师主动撤回submitted → draft
*/
export async function withdrawSubmission(
planId: string,
): Promise<LessonPlanStatus> {
const plan = await db
.select({ status: lessonPlans.status })
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
if (plan.length === 0) {
throw new Error("PLAN_NOT_FOUND");
}
const currentStatus = plan[0]!.status as LessonPlanStatus;
if (!isValidTransition(currentStatus, "draft")) {
throw new Error("INVALID_STATUS_TRANSITION");
}
await db
.update(lessonPlans)
.set({ status: "draft", updatedAt: new Date() })
.where(eq(lessonPlans.id, planId));
return "draft";
}
/**
* 按状态批量查询课案(审核仪表盘用途。
*/
export async function getPlansByStatuses(
statuses: LessonPlanStatus[],
creatorId?: string,
): Promise<
Array<{
id: string;
title: string;
status: LessonPlanStatus;
creatorId: string;
updatedAt: Date;
}>
> {
const conditions = [];
if (statuses.length > 0) {
conditions.push(inArray(lessonPlans.status, statuses));
}
if (creatorId) {
conditions.push(eq(lessonPlans.creatorId, creatorId));
}
const rows = await db
.select({
id: lessonPlans.id,
title: lessonPlans.title,
status: lessonPlans.status,
creatorId: lessonPlans.creatorId,
updatedAt: lessonPlans.updatedAt,
})
.from(lessonPlans)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(lessonPlans.updatedAt));
return rows.map((r) => ({
id: r.id,
title: r.title,
status: r.status as LessonPlanStatus,
creatorId: r.creatorId,
updatedAt: r.updatedAt,
}));
}

View File

@@ -0,0 +1,155 @@
/**
* M12 代课教师机制 - 数据访问层
*/
import "server-only";
import { db } from "@/shared/db";
import { lessonPlanSubstitutes, lessonPlans } from "@/shared/db/schema";
import { and, eq, gte, lte, or, isNull, desc } from "drizzle-orm";
import type { SubstituteStatus } from "./lib/type-guards";
import { isSubstituteStatus } from "./lib/type-guards";
/** 代课教师记录 */
export interface LessonPlanSubstitute {
id: string;
planId: string;
originalTeacherId: string;
substituteTeacherId: string;
startDate: Date;
endDate?: Date;
reason?: string;
status: SubstituteStatus;
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
/**
* 查询某课案的代课教师列表
*/
export async function getSubstitutesByPlanId(
planId: string,
): Promise<LessonPlanSubstitute[]> {
const rows = await db
.select()
.from(lessonPlanSubstitutes)
.where(eq(lessonPlanSubstitutes.planId, planId))
.orderBy(desc(lessonPlanSubstitutes.createdAt));
return rows.map(mapRowToSubstitute);
}
/**
* 查询某教师当前生效的代课任务(作为代课教师或原教师)
*/
export async function getActiveSubstitutesByTeacherId(
teacherId: string,
): Promise<LessonPlanSubstitute[]> {
const now = new Date();
const rows = await db
.select()
.from(lessonPlanSubstitutes)
.where(
and(
eq(lessonPlanSubstitutes.status, "active"),
or(eq(lessonPlanSubstitutes.originalTeacherId, teacherId), eq(lessonPlanSubstitutes.substituteTeacherId, teacherId)),
lte(lessonPlanSubstitutes.startDate, now),
or(isNull(lessonPlanSubstitutes.endDate), gte(lessonPlanSubstitutes.endDate, now)),
),
);
return rows.map(mapRowToSubstitute);
}
/**
* 创建代课教师映射
*/
export async function createSubstitute(
input: {
planId: string;
originalTeacherId: string;
substituteTeacherId: string;
startDate: Date;
endDate?: Date;
reason?: string;
},
createdBy: string,
): Promise<LessonPlanSubstitute> {
const [row] = await db.insert(lessonPlanSubstitutes).values({
planId: input.planId,
originalTeacherId: input.originalTeacherId,
substituteTeacherId: input.substituteTeacherId,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
status: "active",
createdBy,
});
const insertedId = row.insertId;
const all = await getSubstitutesByPlanId(input.planId);
const created = all.find((s) => s.id === String(insertedId));
if (!created) throw new Error("SUBSTITUTE_CREATE_FAILED");
return created;
}
/**
* 更新代课教师状态cancel/expire
*/
export async function updateSubstituteStatus(
substituteId: string,
status: SubstituteStatus,
): Promise<void> {
await db
.update(lessonPlanSubstitutes)
.set({ status, updatedAt: new Date() })
.where(eq(lessonPlanSubstitutes.id, substituteId));
}
/**
* 删除代课教师映射
*/
export async function deleteSubstitute(substituteId: string): Promise<void> {
await db
.delete(lessonPlanSubstitutes)
.where(eq(lessonPlanSubstitutes.id, substituteId));
}
/**
* 检查某教师对某课案是否具有代课权限
* - 是原教师 → true
* - 是当前生效的代课教师 → true
*/
export async function canTeacherAccessPlan(
planId: string,
teacherId: string,
): Promise<boolean> {
// 原教师
const plan = await db
.select({ creatorId: lessonPlans.creatorId })
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
if (plan.length === 0) return false;
if (plan[0]!.creatorId === teacherId) return true;
// 代课教师
const substitutes = await getActiveSubstitutesByTeacherId(teacherId);
return substitutes.some((s) => s.planId === planId);
}
function mapRowToSubstitute(
row: typeof lessonPlanSubstitutes.$inferSelect,
): LessonPlanSubstitute {
const status = isSubstituteStatus(row.status) ? row.status : "active";
return {
id: row.id,
planId: row.planId,
originalTeacherId: row.originalTeacherId,
substituteTeacherId: row.substituteTeacherId,
startDate: row.startDate,
endDate: row.endDate ?? undefined,
reason: row.reason ?? undefined,
status,
createdBy: row.createdBy,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}

View File

@@ -7,23 +7,16 @@ import { db } from "@/shared/db";
import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema";
import { SYSTEM_TEMPLATES } from "./constants";
import { normalizeDocument, LessonPlanDataError } from "./data-access";
import {
normalizeTemplateBlocks,
isTemplateType,
isTemplateScope,
} from "./lib/type-guards";
import type {
LessonPlanTemplate,
TemplateBlockSkeleton,
TemplateType,
TemplateScope,
} from "./types";
// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ----
const TEMPLATE_TYPES = ["system", "personal"] as const;
function isTemplateType(v: string): v is TemplateType {
return (TEMPLATE_TYPES as readonly string[]).includes(v);
}
const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const;
function isTemplateScope(v: string): v is TemplateScope {
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
}
// ---- 类型映射Drizzle 行 → LessonPlanTemplateDate → ISO string----
function mapRowToTemplate(row: {
id: string;
@@ -40,8 +33,8 @@ function mapRowToTemplate(row: {
name: row.name,
type: isTemplateType(row.type) ? row.type : "personal",
scope: isTemplateScope(row.scope) ? row.scope : "custom",
// 从 unknown 转换为 TemplateBlockSkeleton[]DB JSON 字段
blocks: row.blocks as LessonPlanTemplate["blocks"],
// P1 修复:使用 normalizeTemplateBlocks 安全转换 DB JSON 字段,替代 as 断言
blocks: normalizeTemplateBlocks(row.blocks),
creatorId: row.creatorId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),

View File

@@ -59,9 +59,19 @@ export async function createLessonPlanVersion(input: {
userId: string;
isAuto: boolean;
label?: string;
}): Promise<{ versionNo: number }> {
}): Promise<{ versionNo: number } | null> {
// P0 修复max(versionNo)+1 必须在事务内完成,避免并发产生重复版本号
return await db.transaction(async (tx) => {
// 校验 planId 归属
const plan = await tx
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(
and(eq(lessonPlans.id, input.planId), eq(lessonPlans.creatorId, input.userId)),
)
.limit(1);
if (plan.length === 0) return null;
const maxRow = await tx
.select({ maxNo: max(lessonPlanVersions.versionNo) })
.from(lessonPlanVersions)
@@ -151,8 +161,17 @@ export async function revertToVersion(
export async function pruneAutoVersions(
planId: string,
userId: string,
keep = 50,
): Promise<void> {
): Promise<number> {
// 校验 planId 归属
const plan = await db
.select({ id: lessonPlans.id })
.from(lessonPlans)
.where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)))
.limit(1);
if (plan.length === 0) return 0;
const rows = await db
.select({
id: lessonPlanVersions.id,
@@ -163,10 +182,10 @@ export async function pruneAutoVersions(
.where(eq(lessonPlanVersions.planId, planId))
.orderBy(desc(lessonPlanVersions.versionNo));
if (rows.length <= keep) return;
if (rows.length <= keep) return 0;
// 保留前 keep 条;超出部分只删 isAuto=true 的
const toDelete = rows.slice(keep).filter((r) => r.isAuto);
if (toDelete.length === 0) return;
if (toDelete.length === 0) return 0;
await db
.delete(lessonPlanVersions)
.where(
@@ -178,4 +197,5 @@ export async function pruneAutoVersions(
),
),
);
return toDelete.length;
}

View File

@@ -23,7 +23,8 @@ import {
buildInitialContent,
buildDefaultSkeleton,
} from "./lib/document-migration";
import { getChaptersByTextbookId, getTextbooks } from "@/modules/textbooks/data-access";
import { normalizeTemplateBlocks } from "./lib/type-guards";
import { getChaptersByTextbookId, getTextbooks, findChapterById } from "@/modules/textbooks/data-access";
import type {
LessonPlan,
LessonPlanDocument,
@@ -148,8 +149,8 @@ function mapRowToTemplate(row: {
name: row.name,
type: isTemplateType(row.type) ? row.type : "personal",
scope: isTemplateScope(row.scope) ? row.scope : "custom",
// 从 unknown 转换为 TemplateBlockSkeleton[]DB JSON 字段)
blocks: row.blocks as LessonPlanTemplate["blocks"],
// P1 修复:使用 normalizeTemplateBlocks 安全转换 DB JSON 字段,替代 as unknown as 断言
blocks: normalizeTemplateBlocks(row.blocks),
creatorId: row.creatorId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -195,12 +196,22 @@ function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
];
}
case "class_members": {
// 学生:仅查看 published 课案
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
// 学生:仅查看自己所在年级的 published 课案
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const gradeFilter =
scope.gradeIds && scope.gradeIds.length > 0
? inArray(lessonPlans.gradeId, scope.gradeIds)
: sql<boolean>`false`;
return [and(publishedFilter, gradeFilter)!];
}
case "children": {
// 家长:仅查看 published 课案
return [sql<boolean>`(${lessonPlans.status} = 'published')`];
// 家长:仅查看孩子所在年级的 published 课案
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
const gradeFilter =
scope.gradeIds && scope.gradeIds.length > 0
? inArray(lessonPlans.gradeId, scope.gradeIds)
: sql<boolean>`false`;
return [and(publishedFilter, gradeFilter)!];
}
}
}
@@ -310,6 +321,9 @@ export const getLessonPlans = cache(
);
// ---- 单课案 ----
// 安全说明:此函数仅校验 creator 或 published 状态。
// 对于 parent/student 角色,调用方(页面层)必须额外校验 plan.gradeId
// 是否在 ctx.dataScope.gradeIds 范围内,防止跨年级信息泄露。
export const getLessonPlanById = cache(
async (id: string, userId: string): Promise<LessonPlan | null> => {
const rows = await db
@@ -384,19 +398,8 @@ export async function createLessonPlan(input: {
}
// ---- 工具:在章节树中查找章节(含子章节)----
function findChapterById(
chapters: Awaited<ReturnType<typeof getChaptersByTextbookId>>,
chapterId: string,
): { content?: string | null } | null {
for (const ch of chapters) {
if (ch.id === chapterId) return ch;
if (ch.children && ch.children.length > 0) {
const found = findChapterById(ch.children, chapterId);
if (found) return found;
}
}
return null;
}
// P1-6 修复:使用 textbooks 模块共享的 findChapterById,消除重复代码
// (原本地函数已删除,改为从 @/modules/textbooks/data-access 导入)
// ---- 获取教材列表(供 picker 使用)----
export async function getTextbooksForPicker(): Promise<
@@ -415,14 +418,8 @@ export async function getTextbooksForPicker(): Promise<
export async function getChaptersForPicker(
textbookId: string,
): Promise<
{
id: string;
title: string;
parentId: string | null;
order: number | null;
content?: string | null;
children?: unknown[];
}[]
// P1 修复:使用 ChapterPickerOption 递归类型,替代内联的 children?: unknown[]
import("./providers/lesson-plan-provider").ChapterPickerOption[]
> {
const chapters = await getChaptersByTextbookId(textbookId);
return chapters.map((c) => ({
@@ -516,30 +513,43 @@ export async function unpublishLessonPlan(
// ---- 复制 ----
// V3 修复duplicateSuffix 由 actions 层 i18n 翻译后传入,避免 data-access 硬编码中文
// 事务修复:读源课案 + 写副本必须在同一事务内,避免读后写之间源课案被删除/改动造成数据不一致
export async function duplicateLessonPlan(
planId: string,
userId: string,
duplicateSuffix: string = " - Copy",
): Promise<{ newPlanId: string }> {
const src = await getLessonPlanById(planId, userId);
if (!src) throw new LessonPlanDataError("NOT_FOUND");
return db.transaction(async (tx) => {
const rows = await tx
.select()
.from(lessonPlans)
.where(eq(lessonPlans.id, planId))
.limit(1);
if (rows.length === 0) throw new LessonPlanDataError("NOT_FOUND");
const row = rows[0];
// 权限creator 可复制 draft非 creator 仅可复制 published
if (row.creatorId !== userId && row.status !== "published") {
throw new LessonPlanDataError("NOT_FOUND");
}
const src = mapRowToLessonPlan(row);
const newId = createId();
await db.insert(lessonPlans).values({
id: newId,
title: `${src.title}${duplicateSuffix}`,
textbookId: src.textbookId,
chapterId: src.chapterId,
subjectId: src.subjectId,
gradeId: src.gradeId,
templateId: src.templateId,
templateName: src.templateName,
content: src.content,
status: "draft",
creatorId: userId,
lastSavedAt: new Date(),
const newId = createId();
await tx.insert(lessonPlans).values({
id: newId,
title: `${src.title}${duplicateSuffix}`,
textbookId: src.textbookId,
chapterId: src.chapterId,
subjectId: src.subjectId,
gradeId: src.gradeId,
templateId: src.templateId,
templateName: src.templateName,
content: src.content,
status: "draft",
creatorId: userId,
lastSavedAt: new Date(),
});
return { newPlanId: newId };
});
return { newPlanId: newId };
}
// ---- 统计:各状态课案数量(供管理员看板使用,避免 app 层直查 DB----
@@ -551,17 +561,20 @@ export interface LessonPlanStats {
}
export async function getLessonPlanStats(): Promise<LessonPlanStats> {
// 统计所有课案(含 archived按 status 分组计数
const rows = await db
.select({ status: lessonPlans.status, count: sql<number>`count(*)` })
.from(lessonPlans)
.where(sql`${lessonPlans.status} != 'archived'`)
.groupBy(lessonPlans.status);
const map = new Map(rows.map((r) => [r.status, Number(r.count)]));
const published = map.get("published") ?? 0;
const draft = map.get("draft") ?? 0;
const archived = map.get("archived") ?? 0;
return {
total: Array.from(map.values()).reduce((a, b) => a + b, 0),
published: map.get("published") ?? 0,
draft: map.get("draft") ?? 0,
archived: 0, // 已排除 archived
total: published + draft + archived,
published,
draft,
archived,
};
}

View File

@@ -0,0 +1,257 @@
import type { StateCreator } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block,
BlockType,
FlowEdge,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
import type { EditorState } from "./use-lesson-plan-editor";
export interface EditorSlice {
planId: string;
title: string;
doc: LessonPlanDocument;
setTitle: (title: string) => void;
setPlanId: (planId: string) => void;
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
end?: number;
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i }));
}
export const createEditorSlice: StateCreator<
EditorState,
[],
[],
EditorSlice
> = (set, get) => ({
planId: "",
title: "",
doc: {
version: 3,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
},
setTitle: (title) => set({ title, isDirty: true }),
setPlanId: (planId) => set({ planId }),
addNode: (type, position, title) => {
const id = createId();
const state = get();
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = {
id,
type,
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
y: 80 + Math.floor(nodeCount / 4) * 200,
},
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
isDirty: true,
selectedNodeId: id,
}));
return id;
},
updateNode: (id, patch) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, ...patch } as TextbookContentNode)
: ({ ...n, ...patch } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
updateNodePosition: (id, position) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, position } as TextbookContentNode)
: ({ ...n, position } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
removeNode: (id) =>
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
),
);
const textbookNode = s.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
const nodes = textbookNode
? [textbookNode, ...remainingTeachingNodes]
: remainingTeachingNodes;
return {
doc: {
...s.doc,
nodes,
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
),
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
},
isDirty: true,
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
updateTextbookContent: (data) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
? { ...n, data: { ...n.data, ...data } }
: n,
),
},
isDirty: true,
})),
getTextbookContentNode: () => {
const state = get();
return state.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
},
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
},
isDirty: true,
}));
return anchorId;
},
removeAnchor: (anchorId) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
edges: s.doc.edges.filter(
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
),
},
isDirty: true,
})),
updateAnchor: (anchorId, patch) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.map((a) =>
a.id === anchorId ? { ...a, ...patch } : a,
),
},
isDirty: true,
})),
connect: (source, target) =>
set((s) => {
if (
s.doc.edges.some((e) => e.source === source && e.target === target)
)
return s;
const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
type: "flow",
};
return {
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
isDirty: true,
};
}),
disconnect: (edgeId) =>
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
})),
setEdges: (edges) =>
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
});

View File

@@ -0,0 +1,17 @@
import type { StateCreator } from "zustand";
import type { EditorState } from "./use-lesson-plan-editor";
export interface SelectionSlice {
selectedNodeId: string | null;
selectNode: (id: string | null) => void;
}
export const createSelectionSlice: StateCreator<
EditorState,
[],
[],
SelectionSlice
> = (set) => ({
selectedNodeId: null,
selectNode: (id) => set({ selectedNodeId: id }),
});

View File

@@ -1,303 +1,25 @@
"use client";
import { create } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block,
BlockType,
FlowEdge,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
import { createEditorSlice, type EditorSlice } from "./editor-slice";
import { createSelectionSlice, type SelectionSlice } from "./selection-slice";
import { createVersionSlice, type VersionSlice } from "./version-slice";
interface EditorState {
planId: string;
title: string;
doc: LessonPlanDocument;
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
selectedNodeId: string | null;
/**
* V4 P2-6 修复:将单体 Zustand store原 303 行)拆分为 3 个独立 slice。
*
* - editor-slice: 文档结构planId/title/doc及所有文档操作方法
* - selection-slice: 节点选中状态selectedNodeId / selectNode
* - version-slice: 草稿版本与保存状态isDirty/isSaving/lastSavedAt/hydrate/markSaved/replaceDoc
*
* 主文件仅负责组合 slice 并导出统一的 EditorState 类型,方便测试与维护。
* 各 slice 通过 `import type { EditorState }` 引用合并后的类型TypeScript
* 编译后该类型导入会被完全移除,运行时无循环依赖。
*/
export type EditorState = EditorSlice & SelectionSlice & VersionSlice;
setTitle: (title: string) => void;
setPlanId: (planId: string) => void;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
// V3 修复patch 排除 type 字段,防止改变节点类型,同时消除 as 断言
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
// 正文节点操作
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
// 锚点操作
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
end?: number;
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
// 连线
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
selectNode: (id: string | null) => void;
markSaved: () => void;
setSaving: (saving: boolean) => void;
replaceDoc: (doc: LessonPlanDocument) => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i }));
}
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
planId: "",
title: "",
doc: {
version: 3,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
},
isDirty: false,
isSaving: false,
lastSavedAt: null,
selectedNodeId: null,
setTitle: (title) => set({ title, isDirty: true }),
setPlanId: (planId) => set({ planId }),
// 仅在 planId 变化时调用,避免覆盖用户编辑内容(修复 P1-3
hydrate: (planId, title, doc) =>
set({
planId,
title,
doc,
isDirty: false,
lastSavedAt: Date.now(),
selectedNodeId: null,
}),
addNode: (type, position, title) => {
const id = createId();
const state = get();
// 教学节点 order 从 0 开始(正文节点 order=-1 不计入)
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = {
id,
type,
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
y: 80 + Math.floor(nodeCount / 4) * 200,
},
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
isDirty: true,
selectedNodeId: id,
}));
return id;
},
updateNode: (id, patch) =>
set((s) => ({
doc: {
...s.doc,
// V3 修复patch 已排除 type 字段,但 TypeScript 仍会因 spread 拓宽 data 类型
// BlockData 联合不包含 TextbookContentNodeData而报错此处 as 为必要断言。
// 实际安全:调用方不会对 textbook_content 节点通过 updateNode 传入 data。
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, ...patch } as TextbookContentNode)
: ({ ...n, ...patch } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
// 实时拖动:每次调用立即更新位置(不再等待 dragging=false
updateNodePosition: (id, position) =>
set((s) => ({
doc: {
...s.doc,
// 同 updateNodespread 后 TypeScript 拓宽类型,需 as 断言收窄
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, position } as TextbookContentNode)
: ({ ...n, position } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
removeNode: (id) =>
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
),
);
const textbookNode = s.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
const nodes = textbookNode ? [textbookNode, ...remainingTeachingNodes] : remainingTeachingNodes;
return {
doc: {
...s.doc,
nodes,
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
),
// 同时移除关联的锚点
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
},
isDirty: true,
selectedNodeId:
s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
// ---- 正文节点操作 ----
updateTextbookContent: (data) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
? { ...n, data: { ...n.data, ...data } }
: n,
),
},
isDirty: true,
})),
getTextbookContentNode: () => {
const state = get();
return state.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
},
// ---- 锚点操作 ----
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
},
isDirty: true,
}));
return anchorId;
},
removeAnchor: (anchorId) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
edges: s.doc.edges.filter(
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
),
},
isDirty: true,
})),
updateAnchor: (anchorId, patch) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.map((a) =>
a.id === anchorId ? { ...a, ...patch } : a,
),
},
isDirty: true,
})),
// ---- 连线 ----
connect: (source, target) =>
set((s) => {
// 避免重复连线
if (
s.doc.edges.some(
(e) => e.source === source && e.target === target,
)
)
return s;
const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
type: "flow",
};
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
}),
disconnect: (edgeId) =>
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
})),
setEdges: (edges) => set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
selectNode: (id) => set({ selectedNodeId: id }),
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
setSaving: (saving) => set({ isSaving: saving }),
replaceDoc: (doc) => set({ doc, isDirty: false }),
export const useLessonPlanEditor = create<EditorState>()((...a) => ({
...createEditorSlice(...a),
...createSelectionSlice(...a),
...createVersionSlice(...a),
}));

View File

@@ -0,0 +1,38 @@
import type { StateCreator } from "zustand";
import type { LessonPlanDocument } from "../types";
import type { EditorState } from "./use-lesson-plan-editor";
export interface VersionSlice {
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
markSaved: () => void;
setSaving: (saving: boolean) => void;
replaceDoc: (doc: LessonPlanDocument) => void;
}
export const createVersionSlice: StateCreator<
EditorState,
[],
[],
VersionSlice
> = (set) => ({
isDirty: false,
isSaving: false,
lastSavedAt: null,
hydrate: (planId, title, doc) =>
set({
planId,
title,
doc,
isDirty: false,
lastSavedAt: Date.now(),
selectedNodeId: null,
}),
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
setSaving: (saving) => set({ isSaving: saving }),
replaceDoc: (doc) => set({ doc, isDirty: false }),
});

View File

@@ -40,7 +40,7 @@ export function markdownToPlainText(markdown: string): string {
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
// 去除引用标记
.replace(/^>\s+/gm, "")
.replace(/^\s*>\s+/gm, "")
// 去除列表标记
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
@@ -114,12 +114,13 @@ function buildOffsetMap(markdown: string): {
// 简化映射:逐字符遍历 Markdown跳过被去除的字符
// 这里采用与 markdownToPlainText 一致的简化逻辑
// V4 P2-5 修复:统一 regex 标志为 gm与 markdownToPlainText 保持一致
const skipPatterns: RegExp[] = [
/^#{1,6}\s+/m,
/^\s*[-*+]\s+/m,
/^\s*\d+\.\s+/m,
/^\s*>\s+/m,
/^---+$/m,
/^#{1,6}\s+/gm,
/^\s*[-*+]\s+/gm,
/^\s*\d+\.\s+/gm,
/^\s*>\s+/gm,
/^---+$/gm,
];
while (mdIdx < markdown.length) {

View File

@@ -0,0 +1,167 @@
/**
* M11 版本 diff 预览 - 纯函数模块
*
* 基于字符串差异对比算法,输出可用于 React 渲染的 diff 段落。
* 实现思路:
* - 将 LessonPlanDocument 序列化为可读文本(节点列表 + 标题)
* - 使用简化的 LCS 算法对比两段文本
* - 输出 added/removed/unchanged 三种段落
*
* 此模块仅做纯计算UI 渲染由 version-diff-viewer.tsx 负责。
*/
import type { LessonPlanDocument } from "../types";
/** diff 段落类型 */
export type DiffSegmentType = "added" | "removed" | "unchanged";
/** diff 单个段落 */
export interface DiffSegment {
type: DiffSegmentType;
content: string;
lineNumber?: number;
}
/**
* 将 LessonPlanDocument 序列化为可读文本(用于 diff 比对)
* 每行一个节点,包含节点类型、标题、关键字段摘要
*/
export function serializeDocumentToText(doc: LessonPlanDocument): string {
const lines: string[] = [];
lines.push(`Version: ${doc.version}`);
lines.push(`TextbookNodeId: ${doc.textbookContentNodeId}`);
lines.push(`Nodes (${doc.nodes.length}):`);
for (const node of doc.nodes) {
const summary = summarizeNode(node);
lines.push(` [${node.type}] ${node.title ?? "(no title)"} - ${summary}`);
}
lines.push(`Edges (${doc.edges.length}):`);
for (const edge of doc.edges) {
lines.push(` ${edge.source}${edge.target} (${"type" in edge ? edge.type : "unknown"})`);
}
lines.push(`Anchors (${doc.anchors.length}):`);
for (const anchor of doc.anchors) {
lines.push(
` ${anchor.id}: node=${anchor.nodeId} type=${anchor.type} start=${anchor.start}${anchor.end !== undefined ? ` end=${anchor.end}` : ""}`,
);
}
return lines.join("\n");
}
/**
* 节点摘要(提取关键字段以便 diff 可读)
*/
function summarizeNode(node: { data?: unknown; type: string }): string {
if (!node.data || typeof node.data !== "object") return "";
const data = node.data as Record<string, unknown>;
const fields: string[] = [];
for (const key of Object.keys(data).slice(0, 5)) {
const value = data[key];
if (typeof value === "string") {
fields.push(`${key}="${value.slice(0, 60)}"`);
} else if (Array.isArray(value)) {
fields.push(`${key}=[${value.length} items]`);
} else if (typeof value === "number" || typeof value === "boolean") {
fields.push(`${key}=${value}`);
}
}
return fields.join(", ");
}
/**
* 简化 LCS diff 算法
* 输入两段文本,输出 diff 段落数组
*
* 时间复杂度 O(n*m),对于教案文档(通常 < 500 行)可接受。
* 对于更长文本可换用 diff-match-patch 库。
*/
export function computeTextDiff(
oldText: string,
newText: string,
): DiffSegment[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
// 构建 LCS 矩阵
const m = oldLines.length;
const n = newLines.length;
const lcs: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
// 回溯生成 diff 段落
const segments: DiffSegment[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
segments.unshift({
type: "unchanged",
content: oldLines[i - 1]!,
lineNumber: i,
});
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
segments.unshift({
type: "added",
content: newLines[j - 1]!,
lineNumber: j,
});
j--;
} else if (i > 0) {
segments.unshift({
type: "removed",
content: oldLines[i - 1]!,
lineNumber: i,
});
i--;
}
}
return segments;
}
/**
* 计算两个课案文档的 diff
*/
export function computeDocumentDiff(
oldDoc: LessonPlanDocument,
newDoc: LessonPlanDocument,
): DiffSegment[] {
return computeTextDiff(
serializeDocumentToText(oldDoc),
serializeDocumentToText(newDoc),
);
}
/**
* 统计 diff 摘要
*/
export function summarizeDiff(
segments: DiffSegment[],
): { added: number; removed: number; unchanged: number; total: number } {
let added = 0;
let removed = 0;
let unchanged = 0;
for (const seg of segments) {
if (seg.type === "added") added++;
else if (seg.type === "removed") removed++;
else unchanged++;
}
return { added, removed, unchanged, total: segments.length };
}

View File

@@ -21,6 +21,9 @@ export async function translateFieldErrors(
result[field] = messages.map((msg) => {
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
if (msg.startsWith("error.")) {
// V4 P1-13 修复next-intl 的 t 函数对键有字面量类型约束,
// 动态字符串需断言为键类型。msg 已通过 startsWith 校验为合法 i18n 键前缀,
// 此处 as 属于"从 string 收窄到字面量联合类型"的类型收窄,符合项目规则例外。
return t(msg as Parameters<typeof t>[0]);
}
return msg;

View File

@@ -1,3 +1,4 @@
import { isRecord } from "@/shared/lib/type-guards";
import type { LessonPlanNode, TextbookContentNode } from "../types";
/**
@@ -19,79 +20,81 @@ export interface NodeSummaryT {
): string;
}
// ---- 安全字段提取辅助(替代 as 断言,从 unknown 收窄)----
function getArrayLength(v: unknown): number | undefined {
return Array.isArray(v) ? v.length : undefined;
}
function getString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function getNumber(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
/**
* 纯函数:获取节点摘要文本(用于节点卡片显示)。
* 从 lesson-node.tsx 抽取,便于单元测试。
* 翻译文本由调用方通过 t 函数注入,保证纯函数可测性。
*
* P1 修复:使用类型守卫从 unknown 安全收窄 node.data 字段,
* 替代原先的 `as { html?: string; ... }` 断言。
*/
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
const data = node.data as {
// 富文本类
html?: string;
// 文本研习
sourceText?: string;
annotations?: unknown[];
// 练习
items?: unknown[];
// 教学目标
objectives?: unknown[];
// 重难点
keyPoints?: unknown[];
// 导入
durationMin?: number;
prompt?: string;
// 新授
teachingPoints?: unknown[];
// 小结
summaryPoints?: unknown[];
// 作业
assignments?: unknown[];
// 板书
content?: string;
// 反思
reflection?: unknown[];
// 知识点
knowledgePointIds?: string[];
};
// node.data 是 BlockData 联合类型,这里安全地作为 unknown 读取可选字段
const data: unknown = node.data;
// 按类型优先级提取摘要
if (data.items !== undefined) {
return t("editor.questionCount", { count: data.items.length });
// 按类型优先级提取摘要(使用安全字段提取,避免 as 断言)
const itemsLen = isRecord(data) ? getArrayLength(data.items) : undefined;
if (itemsLen !== undefined) {
return t("editor.questionCount", { count: itemsLen });
}
if (data.objectives !== undefined) {
return t("editor.itemCount", { count: data.objectives.length });
const objectivesLen = isRecord(data) ? getArrayLength(data.objectives) : undefined;
if (objectivesLen !== undefined) {
return t("editor.itemCount", { count: objectivesLen });
}
if (data.keyPoints !== undefined) {
return t("editor.itemCount", { count: data.keyPoints.length });
const keyPointsLen = isRecord(data) ? getArrayLength(data.keyPoints) : undefined;
if (keyPointsLen !== undefined) {
return t("editor.itemCount", { count: keyPointsLen });
}
if (data.teachingPoints !== undefined) {
return t("editor.pointCount", { count: data.teachingPoints.length });
const teachingPointsLen = isRecord(data) ? getArrayLength(data.teachingPoints) : undefined;
if (teachingPointsLen !== undefined) {
return t("editor.pointCount", { count: teachingPointsLen });
}
if (data.summaryPoints !== undefined) {
return t("editor.itemCount", { count: data.summaryPoints.length });
const summaryPointsLen = isRecord(data) ? getArrayLength(data.summaryPoints) : undefined;
if (summaryPointsLen !== undefined) {
return t("editor.itemCount", { count: summaryPointsLen });
}
if (data.assignments !== undefined) {
return t("editor.assignmentCount", { count: data.assignments.length });
const assignmentsLen = isRecord(data) ? getArrayLength(data.assignments) : undefined;
if (assignmentsLen !== undefined) {
return t("editor.assignmentCount", { count: assignmentsLen });
}
if (data.reflection !== undefined) {
return t("editor.itemCount", { count: data.reflection.length });
const reflectionLen = isRecord(data) ? getArrayLength(data.reflection) : undefined;
if (reflectionLen !== undefined) {
return t("editor.itemCount", { count: reflectionLen });
}
if (data.durationMin !== undefined) {
return t("editor.durationMin", { count: data.durationMin });
const durationMin = isRecord(data) ? getNumber(data.durationMin) : undefined;
if (durationMin !== undefined) {
return t("editor.durationMin", { count: durationMin });
}
if (data.annotations !== undefined && data.sourceText !== undefined) {
return t("editor.charCount", { count: data.sourceText.length });
const sourceText = isRecord(data) ? getString(data.sourceText) : undefined;
const hasAnnotations = isRecord(data) ? Array.isArray(data.annotations) : false;
if (hasAnnotations && sourceText !== undefined) {
return t("editor.charCount", { count: sourceText.length });
}
if (data.sourceText !== undefined && data.sourceText) {
return t("editor.charCount", { count: data.sourceText.length });
if (sourceText) {
return t("editor.charCount", { count: sourceText.length });
}
if (data.content !== undefined && data.content) {
const text = data.content.replace(/<[^>]+>/g, "").trim();
const content = isRecord(data) ? getString(data.content) : undefined;
if (content) {
const text = content.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
if (data.html) {
const html = isRecord(data) ? getString(data.html) : undefined;
if (html) {
// 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim();
const text = html.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
return t("editor.nodeSummaryEmpty");
@@ -109,25 +112,40 @@ export function getTextbookContentSummary(
}
/**
* 节点类型 → 图标颜色Material Design 色板)。
* 节点类型 → CSS 变量名V4 P1-4 修复:从硬编码 hex 提取到 globals.css 设计令牌)。
* 供 lesson-node 和 minimap 复用。
*/
export const NODE_COLORS: Record<string, string> = {
objective: "#4caf50",
key_point: "#f44336",
import: "#2196f3",
new_teaching: "#9c27b0",
consolidation: "#ff9800",
summary: "#607d8b",
homework: "#795548",
blackboard: "#009688",
text_study: "#3f51b5",
exercise: "#e91e63",
rich_text: "#9e9e9e",
reflection: "#cddc39",
textbook_content: "#455a64",
};
export const NODE_COLOR_VARS: Record<string, string> = {
objective: "var(--lesson-node-objective)",
key_point: "var(--lesson-node-key-point)",
import: "var(--lesson-node-import)",
new_teaching: "var(--lesson-node-new-teaching)",
consolidation: "var(--lesson-node-consolidation)",
summary: "var(--lesson-node-summary)",
homework: "var(--lesson-node-homework)",
blackboard: "var(--lesson-node-blackboard)",
text_study: "var(--lesson-node-text-study)",
exercise: "var(--lesson-node-exercise)",
rich_text: "var(--lesson-node-rich-text)",
reflection: "var(--lesson-node-reflection)",
textbook_content: "var(--lesson-node-textbook-content)",
}
/** 选中态颜色变量 */
export const NODE_SELECTED_COLOR_VAR = "var(--lesson-node-selected)"
/** 默认颜色变量(未匹配类型时使用) */
export const NODE_DEFAULT_COLOR_VAR = "var(--lesson-node-default)"
/**
* @deprecated 使用 `getNodeColorVar` 替代。保留是为了向后兼容旧代码引用。
*/
export const NODE_COLORS: Record<string, string> = NODE_COLOR_VARS
export function getNodeColor(type: string): string {
return NODE_COLORS[type] ?? "#9e9e9e";
return NODE_COLOR_VARS[type] ?? NODE_DEFAULT_COLOR_VAR
}
export function getNodeColorVar(type: string): string {
return NODE_COLOR_VARS[type] ?? NODE_DEFAULT_COLOR_VAR
}

View File

@@ -6,6 +6,18 @@ import type {
} from "../types";
import { getNodeColor } from "./node-summary";
/**
* 纯函数:根据 nodeId 从节点列表中查找节点类型。
* P0-8 修复toRfEdges 需要节点类型来获取颜色,而非传入 nodeId。
*/
function getNodeTypeById(
nodes: AnyLessonPlanNode[],
nodeId: string,
): string {
const node = nodes.find((n) => n.id === nodeId);
return node?.type ?? "rich_text";
}
/**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。
@@ -103,14 +115,16 @@ export function toRfEdges(
edges: AnyLessonPlanEdge[],
selectedNodeId: string | null,
anchors: NodeAnchor[],
nodes: AnyLessonPlanNode[] = [],
): Edge[] {
return edges.map((e) => {
if (e.type === "anchor") {
// 锚点边:默认 40% 透明度,选中关联节点时 100%
const anchor = anchors.find((a) => a.id === e.anchorId);
const isActive = anchor && anchor.nodeId === selectedNodeId;
// P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色
const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e";
// P0-8 修复:传入节点 type 而非 nodeId使颜色映射正确生效
const nodeType = anchor ? getNodeTypeById(nodes, anchor.nodeId) : "rich_text";
const strokeColor = getNodeColor(nodeType);
return {
...e,
animated: isActive,

View File

@@ -0,0 +1,74 @@
import "server-only"
import type { LessonPlan } from "../types"
import type { AuthContext, DataScope } from "@/shared/types/permissions"
/**
* 课案权限作用域校验工具V4 P0-1 修复)。
*
* data-access 层的 `getLessonPlanById` 仅校验 `creatorId` 或 `status = "published"`
* 对于 parent/student/grade_head 等角色的跨年级隔离需由调用方(页面层)补齐。
* 本模块提供纯函数辅助,避免在每个路由重复实现。
*/
/**
* 判断单个课案是否落在当前用户的 DataScope 内。
*
* - admin (`type: "all"`):永远返回 true
* - teacher (`type: "class_taught"`)creator 自有课案权限由 data-access 层校验;
* 若 plan 非 creator 自有且非 publisheddata-access 已返回 null这里只兜底 gradeId
* - parent/student/grade_head需要校验 `plan.gradeId` 是否在 scope.gradeIds 范围内
*
* @returns `true` 表示通过;`false` 表示越权(应返回 notFound
*/
export function isPlanInScope(plan: LessonPlan, scope: DataScope): boolean {
switch (scope.type) {
case "all":
return true
case "owned":
// owned 仅允许查看自己的课案data-access 层 creatorId 已校验,这里兜底
return true
case "class_taught":
// class_taught 的课案权限依赖 creator + subjectId/gradeId 过滤;
// data-access 的 buildScopeCondition 已处理列表查询;单课案由 creator 校验
return true
case "class_members":
// student仅可查看已发布 + 本年级课案
return plan.status === "published" && isGradeInScope(plan, scope.gradeIds)
case "children":
// parent仅可查看已发布 + 孩子所在年级课案
return plan.status === "published" && isGradeInScope(plan, scope.gradeIds)
case "grade_managed":
// grade_head/teaching_head仅可查看所管年级的课案
return isGradeInScope(plan, scope.gradeIds)
default: {
// 穷尽性检查unknown 类型分支兜底拒绝
const _exhaustive: never = scope
void _exhaustive
return false
}
}
}
/** 校验 plan.gradeId 是否在允许的 gradeIds 集合内(无 gradeId 的课案视为通过) */
function isGradeInScope(plan: LessonPlan, gradeIds?: string[]): boolean {
if (!plan.gradeId) return true
if (!gradeIds || gradeIds.length === 0) return false
return gradeIds.includes(plan.gradeId)
}
/**
* 断言式调用:失败时抛 `LessonPlanScopeError`,由 Next.js error.tsx 兜底。
* 用于 Server Component 页面层。
*/
export class LessonPlanScopeError extends Error {
constructor(public readonly reason: "not_found" | "grade_scope_violation") {
super(reason)
this.name = "LessonPlanScopeError"
}
}
export function assertPlanInScope(plan: LessonPlan, ctx: AuthContext): void {
if (!isPlanInScope(plan, ctx.dataScope)) {
throw new LessonPlanScopeError("grade_scope_violation")
}
}

View File

@@ -19,6 +19,7 @@ import type {
ReflectionItem,
RichTextBlockData,
SummaryBlockData,
TemplateBlockSkeleton,
TemplateScope,
TemplateType,
TextStudyBlockData,
@@ -26,11 +27,47 @@ import type {
} from "../types";
// ---- 基础类型守卫 ----
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
// M3 审核工作流:扩展为 6 种状态
const LESSON_PLAN_STATUSES = ["draft", "submitted", "approved", "published", "rejected", "archived"] as const;
export function isLessonPlanStatus(v: string): v is LessonPlanStatus {
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
}
/** M3 审核工作流:审核决策类型守卫 */
const REVIEW_DECISIONS = ["approved", "rejected"] as const;
export type ReviewDecision = (typeof REVIEW_DECISIONS)[number];
export function isReviewDecision(v: string): v is ReviewDecision {
return (REVIEW_DECISIONS as readonly string[]).includes(v);
}
/** M3 审核工作流:代课教师状态类型守卫 */
const SUBSTITUTE_STATUSES = ["active", "expired", "cancelled"] as const;
export type SubstituteStatus = (typeof SUBSTITUTE_STATUSES)[number];
export function isSubstituteStatus(v: string): v is SubstituteStatus {
return (SUBSTITUTE_STATUSES as readonly string[]).includes(v);
}
/** M3 审核工作流:附件类型守卫 */
const ATTACHMENT_TYPES = ["reference", "material", "supplementary"] as const;
export type AttachmentType = (typeof ATTACHMENT_TYPES)[number];
export function isAttachmentType(v: string): v is AttachmentType {
return (ATTACHMENT_TYPES as readonly string[]).includes(v);
}
/** M3 审核工作流:形成性评价互动类型守卫 */
const FORMATIVE_INTERACTION_TYPES = ["poll", "quiz", "exit_ticket"] as const;
export type FormativeInteractionType = (typeof FORMATIVE_INTERACTION_TYPES)[number];
export function isFormativeInteractionType(v: string): v is FormativeInteractionType {
return (FORMATIVE_INTERACTION_TYPES as readonly string[]).includes(v);
}
/** M3 审核工作流:标准层级类型守卫 */
const STANDARD_LEVELS = ["national", "curriculum", "custom"] as const;
export type StandardLevel = (typeof STANDARD_LEVELS)[number];
export function isStandardLevel(v: string): v is StandardLevel {
return (STANDARD_LEVELS as readonly string[]).includes(v);
}
const TEMPLATE_TYPES = ["system", "personal"] as const;
export function isTemplateType(v: string): v is TemplateType {
return (TEMPLATE_TYPES as readonly string[]).includes(v);
@@ -180,7 +217,7 @@ export function isLessonPlanNode(
}
// ---- 题目类型守卫 ----
const VALID_QUESTION_TYPES = [
export const VALID_QUESTION_TYPES = [
"single_choice",
"multiple_choice",
"text",
@@ -211,3 +248,30 @@ const VALID_BLOCK_TYPES: BlockType[] = [
export function isBlockType(v: string): v is BlockType {
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
}
// ---- TemplateBlockSkeleton 守卫与规范化(替代 as 断言从 DB unknown 转换)----
// isObject 已在上方定义(第 56 行),复用同一类型守卫
/**
* 类型守卫:判断 unknown 是否为合法的 TemplateBlockSkeleton。
* 用于从 DB JSON 字段安全收窄,替代 `as LessonPlanTemplate["blocks"]` 断言。
*/
export function isTemplateBlockSkeleton(v: unknown): v is TemplateBlockSkeleton {
return (
isObject(v) &&
typeof v.type === "string" &&
isBlockType(v.type) &&
typeof v.title === "string" &&
(v.hint === undefined || typeof v.hint === "string")
);
}
/**
* 规范化函数:将 unknownDB JSON 字段)安全转换为 TemplateBlockSkeleton[]。
* 过滤掉结构不合法的项,避免畸形数据导致运行时错误。
* 替代 `as LessonPlanTemplate["blocks"]` / `as unknown as LessonPlanTemplate["blocks"]` 断言。
*/
export function normalizeTemplateBlocks(v: unknown): TemplateBlockSkeleton[] {
if (!Array.isArray(v)) return [];
return v.filter(isTemplateBlockSkeleton);
}

View File

@@ -7,11 +7,12 @@ import {
type LessonPlanRoleConfig,
} from "./lesson-plan-provider";
import { createDefaultDataService } from "../services/default-data-service";
import { createDefaultQuestionService } from "../services/default-question-service";
/**
* 备课模块 Provider 设置组件V3 新增)。
* 在页面层包裹此组件,自动注入默认数据服务角色配置。
* 组件通过 useLessonPlanContextSafe() 获取 service,不直接 import actions。
* 备课模块 Provider 设置组件V3 新增 / V4 P0-4 扩展)。
* 在页面层包裹此组件,自动注入默认数据服务角色配置和跨模块题目服务
* 组件通过 useLessonPlanContextSafe() / useQuestionService() 获取依赖,不直接 import actions。
*/
export function LessonPlanProviderSetup({
children,
@@ -21,8 +22,14 @@ export function LessonPlanProviderSetup({
roleConfig?: LessonPlanRoleConfig;
}) {
const service = useMemo(() => createDefaultDataService(), []);
// V4 P0-4注入跨模块题目服务避免 question-bank-picker 直接 import questions 模块
const questionService = useMemo(() => createDefaultQuestionService(), []);
return (
<LessonPlanProvider service={service} roleConfig={roleConfig}>
<LessonPlanProvider
service={service}
roleConfig={roleConfig}
questionService={questionService}
>
{children}
</LessonPlanProvider>
);

View File

@@ -21,14 +21,16 @@ export interface TextbookPickerOption {
grade: string | null;
}
/** 章节选项template-picker 使用)*/
/** 章节选项template-picker 使用)
* P1 修复children 改为递归类型 ChapterPickerOption[],消除 template-picker 中的 as 断言
*/
export interface ChapterPickerOption {
id: string;
title: string;
parentId: string | null;
order: number | null;
content?: string | null;
children?: unknown[];
children?: ChapterPickerOption[];
}
/** 知识点选项knowledge-point-picker 使用)*/
@@ -169,8 +171,16 @@ export interface LessonPlanDataService {
message?: string;
errors?: Record<string, string[]>;
}>;
}
/** 获取题库题目question-bank-picker 使用,跨模块)*/
/**
* 跨模块题目服务接口V4 P0-4 修复:完全解耦)。
*
* question-bank-picker 通过此接口调用题目查询,不直接 import questions 模块。
* 由 LessonPlanProviderSetup 在 app 层注入具体实现(默认从 external-questions-bridge 取得)。
*/
export interface QuestionService {
/** 获取题库题目question-bank-picker 使用)*/
getQuestions(params: QuestionPickerParams): Promise<{
success: boolean;
data?: { data: QuestionPickerItem[] };
@@ -249,12 +259,26 @@ export const PARENT_ROLE_CONFIG: LessonPlanRoleConfig = {
readOnly: true,
};
/** 教研组长/年级主任配置V4 P1-11查看所管年级的课案只读 + 可看版本历史) */
export const GRADE_HEAD_ROLE_CONFIG: LessonPlanRoleConfig = {
canCreate: false,
canEdit: false,
canPublish: false,
canDuplicate: false,
canArchive: false,
canViewVersions: true,
canUseAiSuggest: false,
readOnly: true,
};
/** 角色配置注册表 */
export const ROLE_CONFIGS: Record<string, LessonPlanRoleConfig> = {
admin: ADMIN_ROLE_CONFIG,
teacher: TEACHER_ROLE_CONFIG,
student: STUDENT_ROLE_CONFIG,
parent: PARENT_ROLE_CONFIG,
grade_head: GRADE_HEAD_ROLE_CONFIG,
teaching_head: GRADE_HEAD_ROLE_CONFIG,
};
/** 监控埋点接口P2-4预留关键操作埋点 */
@@ -275,25 +299,29 @@ export interface LessonPlanContextValue {
roleConfig: LessonPlanRoleConfig;
/** 监控埋点 */
tracker: LessonPlanTracker;
/** 跨模块题目服务V4 P0-4可选未注入时 question-bank-picker 回退禁用) */
questionService?: QuestionService;
}
const LessonPlanContext = createContext<LessonPlanContextValue | null>(null);
/** Provider 组件:注入数据服务、角色配置、埋点 */
/** Provider 组件:注入数据服务、角色配置、埋点、题目服务 */
export function LessonPlanProvider({
children,
service,
roleConfig,
tracker = noopTracker,
questionService,
}: {
children: ReactNode;
service: LessonPlanDataService;
roleConfig: LessonPlanRoleConfig;
tracker?: LessonPlanTracker;
questionService?: QuestionService;
}) {
const value = useMemo<LessonPlanContextValue>(
() => ({ service, roleConfig, tracker }),
[service, roleConfig, tracker],
() => ({ service, roleConfig, tracker, questionService }),
[service, roleConfig, tracker, questionService],
);
return <LessonPlanContext.Provider value={value}>{children}</LessonPlanContext.Provider>;
}
@@ -316,7 +344,23 @@ export function useLessonPlanContext(): LessonPlanContextValue {
/** Hook获取角色配置若未在 Provider 内则返回教师默认配置) */
export function useRoleConfig(): LessonPlanRoleConfig {
const ctx = useContext(LessonPlanContext);
return ctx?.roleConfig ?? TEACHER_ROLE_CONFIG;
if (!ctx) {
// V4 P1-12 修复:开发环境告警,避免误用导致非教师角色看到教师操作按钮
if (process.env.NODE_ENV === "development") {
console.warn(
"[useRoleConfig] called outside LessonPlanProvider; falling back to TEACHER_ROLE_CONFIG. " +
"Wrap the consuming component with <LessonPlanProviderSetup roleConfig={...}>.",
)
}
return TEACHER_ROLE_CONFIG;
}
return ctx.roleConfig;
}
/** Hook获取跨模块题目服务V4 P0-4 修复) */
export function useQuestionService(): QuestionService | undefined {
const ctx = useContext(LessonPlanContext);
return ctx?.questionService;
}
/** Hook获取数据服务 */

View File

@@ -108,91 +108,115 @@ export async function publishLessonPlanHomework(
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const newData = newBlock.data;
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
const qt = item.inlineContent.type;
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
if (!isValidQuestionType(qt)) {
throw new PublishServiceError("INVALID_QUESTION_TYPE");
}
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: qt,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
}
await persistExamDraft({
examId,
title: input.homeworkTitle,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: input.homeworkDescription,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const examId = createId();
const assignmentId = createId();
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new PublishServiceError("NO_STUDENTS");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: input.homeworkTitle,
description: input.homeworkDescription,
structure: null,
status: "published",
creatorId: input.userId,
availableAt: input.availableAt ?? null,
dueAt: input.dueAt ?? null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
publish: true,
questions: newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
targetStudentIds,
});
const createdQuestionIds: string[] = [];
// 6. 回写溯源标记
newData.publishedExamId = examId;
newData.publishedAssignmentId = assignmentId;
newData.publishedAt = new Date().toISOString();
await db
.update(lessonPlans)
.set({ content: newContent })
.where(eq(lessonPlans.id, input.planId));
// P0-12 修复:将多步写操作包裹在事务中,保证原子性。
// 注意:跨模块 data-accesscreateQuestionWithRelations/persistExamDraft/addExamQuestions/
// createHomeworkAssignment使用各自的 db 连接,无法加入此事务。
// 若跨模块调用成功但下方事务失败,已创建的 exam/homework 将成为孤儿数据,
// 需通过补偿机制或定期清理任务处理。完整事务支持需跨模块 data-access 接受 tx 参数(中长期改进)。
try {
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
const qt = item.inlineContent.type;
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
if (!isValidQuestionType(qt)) {
throw new PublishServiceError("INVALID_QUESTION_TYPE");
}
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: qt,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
createdQuestionIds.push(questionId);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
await persistExamDraft({
examId,
title: input.homeworkTitle,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: input.homeworkDescription,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new PublishServiceError("NO_STUDENTS");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: input.homeworkTitle,
description: input.homeworkDescription,
structure: null,
status: "published",
creatorId: input.userId,
availableAt: input.availableAt ?? null,
dueAt: input.dueAt ?? null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
publish: true,
questions: newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
targetStudentIds,
});
// 6. 回写溯源标记 — 包裹在事务中保证原子性
newData.publishedExamId = examId;
newData.publishedAssignmentId = assignmentId;
newData.publishedAt = new Date().toISOString();
await db.transaction(async (tx) => {
await tx
.update(lessonPlans)
.set({ content: newContent })
.where(eq(lessonPlans.id, input.planId));
});
} catch (e) {
// 补偿错误处理:记录已创建的资源 ID 便于排查孤儿数据
if (e instanceof PublishServiceError) throw e;
console.error("[publishLessonPlanHomework] 部分失败,可能存在孤儿数据", {
planId: input.planId,
examId,
assignmentId,
createdQuestionIds,
error: e instanceof Error ? e.message : String(e),
});
throw e;
}
return { examId, assignmentId, updatedContent: newContent };
}

View File

@@ -11,25 +11,33 @@ export const createLessonPlanSchema = z.object({
});
export const updateLessonPlanContentSchema = z.object({
planId: z.string().min(1),
title: z.string().min(1).max(255).optional(),
planId: z.string().min(1, "error.planIdRequired"),
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong").optional(),
// Block 文档结构由 types 守卫,运行时只校验是对象
content: z.record(z.string(), z.unknown()),
});
// P0-5 修复saveVersionSchema 补全 content 字段校验
// LessonPlanDocument 至少包含 version 和 nodes 数组,具体结构由类型守卫保证
export const saveVersionSchema = z.object({
planId: z.string().min(1),
label: z.string().max(100).optional(),
planId: z.string().min(1, "error.planIdRequired"),
label: z.string().max(100, "error.labelTooLong").optional(),
content: z.object({
version: z.string(),
nodes: z.array(z.unknown()),
edges: z.array(z.unknown()).optional(),
anchors: z.array(z.unknown()).optional(),
}),
});
export const revertVersionSchema = z.object({
planId: z.string().min(1),
versionNo: z.number().int().positive(),
planId: z.string().min(1, "error.planIdRequired"),
versionNo: z.number().int().positive("error.versionNoInvalid"),
});
export const saveAsTemplateSchema = z.object({
sourcePlanId: z.string().min(1),
name: z.string().min(1).max(100),
sourcePlanId: z.string().min(1, "error.planIdRequired"),
name: z.string().min(1, "error.nameRequired").max(100, "error.nameTooLong"),
});
// AI 知识点推荐输入校验
@@ -45,19 +53,52 @@ export const getKnowledgePointOptionsSchema = z.object({
chapterId: z.string().optional(),
});
// P0-7 修复getLessonPlansAction params Zod 验证
export const getLessonPlansParamsSchema = z.object({
query: z.string().max(200, "error.queryTooLong").optional(),
textbookId: z.string().optional(),
chapterId: z.string().optional(),
subjectId: z.string().optional(),
status: z.enum(["draft", "submitted", "approved", "published", "rejected", "archived"]).optional(),
});
// 发布作业输入校验
const dateStringSchema = z
.string()
.refine((v) => !Number.isNaN(new Date(v).getTime()), "error.invalidDate");
export const publishLessonPlanHomeworkSchema = z.object({
planId: z.string().min(1),
blockId: z.string().min(1),
planId: z.string().min(1, "error.planIdRequired"),
blockId: z.string().min(1, "error.blockIdRequired"),
classIds: z.array(z.string().min(1)).min(1, "error.classRequired"),
availableAt: dateStringSchema.optional(),
dueAt: dateStringSchema.optional(),
});
// M3 审核工作流 schemas
export const submitLessonPlanForReviewSchema = z.object({
planId: z.string().min(1, "error.planIdRequired"),
reviewComment: z.string().max(500, "error.commentTooLong").optional(),
});
export const reviewLessonPlanSchema = z.object({
planId: z.string().min(1, "error.planIdRequired"),
decision: z.enum(["approved", "rejected"]),
reviewComment: z.string().max(500, "error.commentTooLong").optional(),
});
// M3 审核工作流:创建审核记录输入
export const createReviewRecordSchema = z.object({
planId: z.string().min(1, "error.planIdRequired"),
decision: z.enum(["approved", "rejected"]),
reviewerId: z.string().min(1, "error.reviewerRequired"),
comment: z.string().max(500, "error.commentTooLong").optional(),
});
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
export type PublishLessonPlanHomeworkInput = z.infer<typeof publishLessonPlanHomeworkSchema>;
export type GetLessonPlansParams = z.infer<typeof getLessonPlansParamsSchema>;
export type SubmitLessonPlanForReviewInput = z.infer<typeof submitLessonPlanForReviewSchema>;
export type ReviewLessonPlanInput = z.infer<typeof reviewLessonPlanSchema>;
export type CreateReviewRecordInput = z.infer<typeof createReviewRecordSchema>;

View File

@@ -18,7 +18,6 @@ import {
} from "../actions";
import { getKnowledgePointOptionsAction } from "../actions-kp";
import { publishLessonPlanHomeworkAction } from "../actions-publish";
import { getQuestionsAction } from "@/modules/questions/actions";
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
/**
@@ -28,7 +27,10 @@ import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
*
* V3 扩展:新增 picker/dialog 组件所需方法createLessonPlan / getTextbooksForPicker /
* getChaptersForPicker / getLessonPlanTemplates / getKnowledgePointOptions /
* publishLessonPlanHomework / getQuestions)。
* publishLessonPlanHomework
*
* V4 P0-4 修复:移除 `getQuestions` 方法及其对 `@/modules/questions/actions` 的直接 import
* 改由独立的 `QuestionService`(见 default-question-service.ts注入。
*/
export function createDefaultDataService(): LessonPlanDataService {
return {
@@ -146,20 +148,5 @@ export function createDefaultDataService(): LessonPlanDataService {
}
return { success: false, message: res.message, errors: res.errors };
},
async getQuestions(params) {
const res = await getQuestionsAction(params);
if (res.success && res.data) {
// 从 questions 模块的返回结构中提取 picker 所需字段
const items = res.data.data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
}));
return { success: true, data: { data: items } };
}
return { success: false, message: res.message };
},
};
}

View File

@@ -0,0 +1,30 @@
"use client";
import { getQuestionsForPickerAction } from "../actions-questions";
import type {
QuestionPickerItem,
QuestionPickerParams,
QuestionService,
} from "../providers/lesson-plan-provider";
/**
* 默认跨模块题目服务实现V4 P0-4 修复)。
*
* 包装 lesson-preparation 自有的 Server Action`getQuestionsForPickerAction`
* 该 Action 内部通过 `external-questions-bridge` 调用 questions 模块的 data-access
* 避免备课模块在客户端直接 import `@/modules/questions/actions`。
*
* 通过 LessonPlanProvider 注入question-bank-picker 使用 `useQuestionService()` 获取。
* 测试时可替换为 mock 实现。
*/
export function createDefaultQuestionService(): QuestionService {
return {
async getQuestions(params: QuestionPickerParams) {
const res = await getQuestionsForPickerAction(params);
if (res.success && res.data) {
return { success: true, data: { data: res.data.data as QuestionPickerItem[] } };
}
return { success: false, message: res.message };
},
};
}

View File

@@ -0,0 +1,44 @@
import "server-only"
import { getQuestions } from "@/modules/questions/data-access"
import type { QuestionPickerItem, QuestionPickerParams } from "../providers/lesson-plan-provider"
/**
* 跨模块题目查询桥接器V4 P0-4 修复)。
*
* 历史上 `services/default-data-service.ts` 直接 `import { getQuestionsAction } from "@/modules/questions/actions"`
* 违反"模块内部组件绝不直接 import 其他业务模块的 actions"原则。
*
* 本桥接器:
* 1. 改为 `data-access → data-access` 通信(项目规则允许)
* 2. 仅做"参数/返回值适配"——把 questions 模块的形状转换为备课模块 picker 期望的形状
* 3. 保留为独立文件,便于未来按"完全解耦"目标替换为 Context 注入的 QuestionService 实现
*
* 中长期目标(审计报告 M-category通过 LessonPlanProvider 在 app 层注入 QuestionService
* 备课模块不再有任何对 questions 模块的 import。
*/
export async function fetchExternalQuestions(
params: QuestionPickerParams,
): Promise<{ success: boolean; data?: { data: QuestionPickerItem[] }; message?: string }> {
try {
const res = await getQuestions({
q: params.q,
type: params.type,
difficulty: params.difficulty,
page: 1,
pageSize: 50,
})
const items: QuestionPickerItem[] = res.data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
}))
return { success: true, data: { data: items } }
} catch (e) {
console.error("[external-questions-bridge] fetchExternalQuestions failed", e)
return { success: false, message: "external_questions_unavailable" }
}
}

View File

@@ -1,5 +1,48 @@
// 课案状态
export type LessonPlanStatus = "draft" | "published" | "archived";
// M3 审核工作流:扩展为 draft → submitted → approved → published → rejected → archived
// - draft: 草稿(教师可编辑)
// - submitted: 已提交审核(教师不可编辑,等待审核)
// - approved: 审核通过(教师可发布)
// - published: 已发布(学生/家长可查看)
// - rejected: 审核驳回(教师可编辑后重新提交)
// - archived: 已归档
export type LessonPlanStatus =
| "draft"
| "submitted"
| "approved"
| "published"
| "rejected"
| "archived";
// M3 审核工作流:状态机迁移规则
export const LESSON_PLAN_STATUS_TRANSITIONS: Record<LessonPlanStatus, LessonPlanStatus[]> = {
draft: ["submitted", "archived"],
submitted: ["approved", "rejected"],
approved: ["published", "draft"],
published: ["draft"],
rejected: ["draft", "archived"],
archived: ["draft"],
};
// M3 审核工作流:判断是否可编辑(教师视角)
export function isEditableStatus(status: LessonPlanStatus): boolean {
return status === "draft" || status === "rejected";
}
// M3 审核工作流:判断是否可提交审核
export function isSubmittableStatus(status: LessonPlanStatus): boolean {
return status === "draft" || status === "rejected";
}
// M3 审核工作流:判断是否可审核(教研组长视角)
export function isReviewableStatus(status: LessonPlanStatus): boolean {
return status === "submitted";
}
// M3 审核工作流:判断是否可发布
export function isPublishableStatus(status: LessonPlanStatus): boolean {
return status === "approved";
}
// Block 类型枚举(教学节点)
export type BlockType =