- 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
265 lines
7.0 KiB
TypeScript
265 lines
7.0 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|