Files
NextEdu/src/modules/lesson-preparation/data-access-formative.ts
SpecialX 20023e13fd 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
2026-07-03 10:25:21 +08:00

265 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}